Trying out some UI changes. Added resizing support to remoteImage and LazyImage component
This commit is contained in:
@@ -2,47 +2,59 @@
|
||||
import SearchBar from '$lib/components/util/searchBar.svelte'
|
||||
import type { LayoutData } from './$types'
|
||||
import NavTab from '$lib/components/navbar/navTab.svelte'
|
||||
import PlaylistTab from '$lib/components/navbar/playlistTab.svelte'
|
||||
import MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
|
||||
export let data: LayoutData
|
||||
|
||||
const inPathnameHeirarchy = (pathname: string, rootPathname: string): boolean => {
|
||||
return (pathname.startsWith(rootPathname) && rootPathname !== '/') || (pathname === '/' && rootPathname === '/')
|
||||
}
|
||||
let playlistTooltip: HTMLDivElement
|
||||
|
||||
const setTooltip = (x: number, y: number, content: string): void => {
|
||||
const textWrapper = playlistTooltip.firstChild! as HTMLDivElement
|
||||
textWrapper.innerText = content
|
||||
playlistTooltip.style.display = 'block'
|
||||
playlistTooltip.style.left = `${x}px`
|
||||
playlistTooltip.style.top = `${y}px`
|
||||
}
|
||||
$: currentPathname = data.url.pathname
|
||||
</script>
|
||||
|
||||
<div class="h-full overflow-hidden">
|
||||
<div class="no-scrollbar fixed left-0 top-0 z-10 grid h-full w-20 grid-cols-1 grid-rows-[min-content_auto] gap-5 px-3 py-12">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each data.navTabs as nav}
|
||||
<NavTab {nav} disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="no-scrollbar flex flex-col gap-5 overflow-y-scroll px-1.5">
|
||||
{#each data.playlistTabs as playlist}
|
||||
<PlaylistTab {playlist} on:mouseenter={(event) => setTooltip(event.detail.x, event.detail.y, event.detail.content)} on:mouseleave={() => (playlistTooltip.style.display = 'none')} />
|
||||
{/each}
|
||||
</div>
|
||||
<div bind:this={playlistTooltip} class="absolute hidden max-w-48 -translate-y-1/2 translate-x-10 whitespace-nowrap rounded bg-neutral-800 px-2 py-1.5 text-sm">
|
||||
<div class="overflow-clip text-ellipsis">PLAYLIST_NAME</div>
|
||||
<div class="overflow-clip text-ellipsis text-neutral-400">Playlist • {data.user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="no-scrollbar h-full overflow-y-scroll px-[max(7rem,_7vw)]">
|
||||
<div class="sticky top-0 max-w-xl py-6">
|
||||
<SearchBar />
|
||||
<main id="grid-wrapper" class="h-full">
|
||||
<nav id="navbar" class="items-center">
|
||||
<strong class="pl-6 text-3xl">
|
||||
<i class="fa-solid fa-record-vinyl mr-1" />
|
||||
Lazuli
|
||||
</strong>
|
||||
<SearchBar />
|
||||
<div class="flex h-full justify-end p-4">
|
||||
<IconButton halo={true} on:click={() => goto('/user')}>
|
||||
<i slot="icon" class="fa-solid fa-user text-lg" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</nav>
|
||||
<section id="sidebar" class="pt-4 font-light">
|
||||
<NavTab label={'Home'} icon={'fa-solid fa-wave-square'} redirect={'/'} disabled={currentPathname === '/'} />
|
||||
<NavTab label={'Playlists'} icon={'fa-solid fa-bars-staggered'} redirect={'/playlists'} disabled={/^\/playlists.*$/.test(currentPathname)} />
|
||||
<NavTab label={'Library'} icon={'fa-solid fa-book'} redirect={'/library'} disabled={/^\/library.*$/.test(currentPathname)} />
|
||||
</section>
|
||||
<section id="content-wrapper" class="no-scrollbar overflow-x-clip overflow-y-scroll pr-8">
|
||||
<slot />
|
||||
</section>
|
||||
<MediaPlayer />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
#grid-wrapper,
|
||||
#navbar {
|
||||
display: grid;
|
||||
column-gap: 1rem;
|
||||
grid-template-columns: 14rem auto 14rem;
|
||||
}
|
||||
|
||||
#grid-wrapper {
|
||||
row-gap: 1rem;
|
||||
grid-template-rows: 4.5rem auto;
|
||||
}
|
||||
|
||||
#navbar {
|
||||
grid-area: 1 / 1 / 2 / 4;
|
||||
}
|
||||
#sidebar {
|
||||
grid-area: 2 / 1 / 3 / 2;
|
||||
}
|
||||
#content-wrapper {
|
||||
grid-area: 2 / 2 / 3 / 4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { LayoutLoad } from './$types'
|
||||
import type { NavTab } from '$lib/components/navbar/navTab.svelte'
|
||||
import type { PlaylistTab } from '$lib/components/navbar/playlistTab.svelte'
|
||||
|
||||
export const load: LayoutLoad = () => {
|
||||
const navTabs: NavTab[] = [
|
||||
{
|
||||
pathname: '/',
|
||||
name: 'Home',
|
||||
icon: 'fa-solid fa-house',
|
||||
},
|
||||
{
|
||||
pathname: '/user',
|
||||
name: 'User',
|
||||
icon: 'fa-solid fa-user', // This would be a cool spot for a user-uploaded pfp
|
||||
},
|
||||
{
|
||||
pathname: '/search',
|
||||
name: 'Search',
|
||||
icon: 'fa-solid fa-search',
|
||||
},
|
||||
{
|
||||
pathname: '/library',
|
||||
name: 'Libray',
|
||||
icon: 'fa-solid fa-bars-staggered',
|
||||
},
|
||||
]
|
||||
|
||||
const playlistTabs: PlaylistTab[] = [
|
||||
{
|
||||
id: 'AD:TRANCE 10',
|
||||
name: 'AD:TRANCE 10',
|
||||
thumbnail: 'https://www.diverse.direct/wp/wp-content/uploads/470_artwork.jpg',
|
||||
},
|
||||
{
|
||||
id: 'Fionaredica',
|
||||
name: 'Fionaredica',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a2436961975_10.jpg',
|
||||
},
|
||||
{
|
||||
id: 'Machinate',
|
||||
name: 'Machinate',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a3587136348_10.jpg',
|
||||
},
|
||||
{
|
||||
id: 'MAGGOD',
|
||||
name: 'MAGGOD',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a3641603617_10.jpg',
|
||||
},
|
||||
{
|
||||
id: 'The Requiem',
|
||||
name: 'The Requiem',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a2458067285_10.jpg',
|
||||
},
|
||||
{
|
||||
id: 'IRREPARABLE HARDCORE IS BACK 2 -Horai Gekka-',
|
||||
name: 'IRREPARABLE HARDCORE IS BACK 2 -Horai Gekka-',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a1483629734_10.jpg',
|
||||
},
|
||||
{
|
||||
id: '妄殺オタクティクス',
|
||||
name: '妄殺オタクティクス',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a1653481367_10.jpg',
|
||||
},
|
||||
{
|
||||
id: 'Collapse',
|
||||
name: 'Collapse',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a0524413952_10.jpg',
|
||||
},
|
||||
{
|
||||
id: 'Fleurix',
|
||||
name: 'Fleurix',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a1856993876_10.jpg',
|
||||
},
|
||||
{
|
||||
id: '天才失格 -No Longer Prodigy-',
|
||||
name: '天才失格 -No Longer Prodigy-',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a2186643420_10.jpg',
|
||||
},
|
||||
]
|
||||
|
||||
return { navTabs, playlistTabs }
|
||||
}
|
||||
45
src/routes/(app)/library/+layout.svelte
Normal file
45
src/routes/(app)/library/+layout.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { itemDisplayState } from '$lib/stores'
|
||||
import type { LayoutData } from './$types.js'
|
||||
import { fly, fade } from 'svelte/transition'
|
||||
|
||||
export let data: LayoutData
|
||||
|
||||
$: currentPathname = data.url.pathname
|
||||
</script>
|
||||
|
||||
<main class="py-8">
|
||||
<nav id="nav-options" class="mb-8 flex h-12 justify-between">
|
||||
<section class="relative flex h-full gap-4">
|
||||
<button disabled={/^\/library$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library')}>History</button>
|
||||
<button disabled={/^\/library\/albums.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/albums')}>Albums</button>
|
||||
<button disabled={/^\/library\/artists.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/artists')}>Artists</button>
|
||||
<button disabled={/^\/library\/collection.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/collection')}>My Collection</button>
|
||||
</section>
|
||||
<section class="h-full justify-self-end">
|
||||
<button disabled={$itemDisplayState === 'list'} class="view-toggle aspect-square h-full" on:click={() => ($itemDisplayState = 'list')}>
|
||||
<i class="fa-solid fa-list" />
|
||||
</button>
|
||||
<button disabled={$itemDisplayState === 'grid'} class="view-toggle aspect-square h-full" on:click={() => ($itemDisplayState = 'grid')}>
|
||||
<i class="fa-solid fa-grip" />
|
||||
</button>
|
||||
</section>
|
||||
</nav>
|
||||
{#key currentPathname}
|
||||
<div in:fade={{ duration: 200, delay: 200 }} out:fade={{ duration: 200 }}>
|
||||
<slot />
|
||||
</div>
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
button.view-toggle[disabled] {
|
||||
color: var(--lazuli-primary);
|
||||
}
|
||||
button.library-tab[disabled] {
|
||||
color: var(--lazuli-primary);
|
||||
border-top: 2px solid var(--lazuli-primary);
|
||||
background: linear-gradient(to bottom, var(--lazuli-primary) -150%, transparent 50%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { PageServerLoad } from '../$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
const connectionId = url.searchParams.get('connection')
|
||||
const id = url.searchParams.get('id')
|
||||
|
||||
async function getPlaylist() {
|
||||
const playlistResponse = (await fetch(`/api/connections/${connectionId}/playlist?id=${id}`, {
|
||||
credentials: 'include',
|
||||
}).then((response) => response.json())) as { playlist: Playlist }
|
||||
return playlistResponse.playlist
|
||||
}
|
||||
|
||||
async function getPlaylistItems() {
|
||||
const itemsResponse = (await fetch(`/api/connections/${connectionId}/playlist/${id}/items`, {
|
||||
credentials: 'include',
|
||||
}).then((response) => response.json())) as { items: Song[] }
|
||||
return itemsResponse.items
|
||||
}
|
||||
|
||||
return { playlistDetails: Promise.all([getPlaylist(), getPlaylistItems()]) }
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
<h1>Welcome to the library page!</h1>
|
||||
<h1>This would be a good place for listen history</h1>
|
||||
|
||||
11
src/routes/(app)/library/albums/+page.server.ts
Normal file
11
src/routes/(app)/library/albums/+page.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
const getLibraryAlbums = async () =>
|
||||
fetch(`/api/users/${locals.user.id}/library/albums`)
|
||||
.then((response) => response.json() as Promise<{ items: Album[] }>)
|
||||
.then((data) => data.items)
|
||||
.catch(() => ({ error: 'Failed to retrieve library albums' }))
|
||||
|
||||
return { albums: getLibraryAlbums() }
|
||||
}
|
||||
39
src/routes/(app)/library/albums/+page.svelte
Normal file
39
src/routes/(app)/library/albums/+page.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { PageServerData } from './$types'
|
||||
import { itemDisplayState } from '$lib/stores'
|
||||
import Loader from '$lib/components/util/loader.svelte'
|
||||
import AlbumCard from './albumCard.svelte'
|
||||
import ListItem from '$lib/components/media/listItem.svelte'
|
||||
|
||||
export let data: PageServerData
|
||||
</script>
|
||||
|
||||
<section>
|
||||
{#await data.albums}
|
||||
<Loader />
|
||||
{:then albums}
|
||||
{#if 'error' in albums}
|
||||
<h1>{albums.error}</h1>
|
||||
{:else if $itemDisplayState === 'list'}
|
||||
<div class="text-md flex flex-col gap-4">
|
||||
{#each albums as album}
|
||||
<ListItem mediaItem={album} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div id="library-wrapper">
|
||||
{#each albums as album}
|
||||
<AlbumCard {album} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#library-wrapper {
|
||||
display: grid;
|
||||
/* gap: 1.5rem; */
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
}
|
||||
</style>
|
||||
67
src/routes/(app)/library/albums/albumCard.svelte
Normal file
67
src/routes/(app)/library/albums/albumCard.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import LazyImage from '$lib/components/media/lazyImage.svelte'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { queue, newestAlert } from '$lib/stores'
|
||||
|
||||
export let album: Album
|
||||
|
||||
const queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly
|
||||
|
||||
async function playAlbum() {
|
||||
const itemsResponse = await fetch(`/api/connections/${album.connection.id}/album/${album.id}/items`, {
|
||||
credentials: 'include',
|
||||
}).catch(() => null)
|
||||
|
||||
if (!itemsResponse || !itemsResponse.ok) {
|
||||
$newestAlert = ['warning', 'Failed to play album']
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await itemsResponse.json()) as { items: Song[] }
|
||||
queueRef.setQueue(data.items)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3">
|
||||
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded-lg">
|
||||
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}>
|
||||
<LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} />
|
||||
</button>
|
||||
<div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0">
|
||||
<IconButton halo={true} on:click={playAlbum}>
|
||||
<i slot="icon" class="fa-solid fa-play text-2xl" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2 text-center text-sm">
|
||||
<div class="line-clamp-2">{album.name}</div>
|
||||
<div class="line-clamp-2 flex justify-center text-neutral-400">
|
||||
{#if album.artists === 'Various Artists'}
|
||||
<span>Various Artists</span>
|
||||
{:else}
|
||||
{#each album.artists as artist, index}
|
||||
<a class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={album.connection.id}">{artist.name}</a>
|
||||
{#if index < album.artists.length - 1}
|
||||
, 
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#thumbnail-wrapper:hover > #thumbnail {
|
||||
filter: brightness(40%);
|
||||
}
|
||||
#thumbnail-wrapper:hover > #play-button {
|
||||
opacity: 100%;
|
||||
}
|
||||
#thumbnail {
|
||||
transition: filter 150ms ease;
|
||||
}
|
||||
#play-button {
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
</style>
|
||||
@@ -1,27 +1,67 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
|
||||
// This endpoint exists to act as a proxy for images, bypassing any CORS or other issues
|
||||
// that could arise from using images from another origin
|
||||
const MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE = 16383
|
||||
|
||||
// TODO: It is possible to get images through many paths in the jellyfin API. To add support for a path, add a regex for it
|
||||
const jellyfinImagePathnames = [/^\/Items\/([0-9a-f]{32})\/Images\/(Primary|Art|Backdrop|Banner|Logo|Thumb|Disc|Box|Screenshot|Menu|Chapter|BoxRear|Profile)$/]
|
||||
|
||||
function modifyImageURL(imageURL: URL, options?: { maxWidth?: number; maxHeight?: number }): string | null {
|
||||
const maxWidth = options?.maxWidth
|
||||
const maxHeight = options?.maxHeight
|
||||
const baseURL = imageURL.origin.concat(imageURL.pathname)
|
||||
|
||||
// * YouTube Check
|
||||
switch (imageURL.origin) {
|
||||
case 'https://i.ytimg.com':
|
||||
case 'https://www.gstatic.com':
|
||||
// These two origins correspond to images that can't have their size modified with search params, so we just return them at the default res
|
||||
return baseURL
|
||||
case 'https://lh3.googleusercontent.com':
|
||||
case 'https://yt3.googleusercontent.com':
|
||||
case 'https://yt3.ggpht.com':
|
||||
case 'https://music.youtube.com':
|
||||
const fakeQueryParams = []
|
||||
if (maxWidth) fakeQueryParams.push(`w${Math.min(maxWidth, MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE)}`)
|
||||
if (maxHeight) fakeQueryParams.push(`h${Math.min(maxHeight, MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE)}`)
|
||||
return fakeQueryParams.length > 0 ? baseURL.concat(`=${fakeQueryParams.join('-')}`) : baseURL
|
||||
}
|
||||
// * YouTube Check
|
||||
|
||||
// * Jellyfin Check
|
||||
if (jellyfinImagePathnames.some((regex) => regex.test(imageURL.pathname))) {
|
||||
const imageParams = new URLSearchParams()
|
||||
if (maxWidth) imageParams.append('maxWidth', maxWidth.toString())
|
||||
if (maxHeight) imageParams.append('maxHeight', maxHeight.toString())
|
||||
return imageParams.size > 0 ? baseURL.concat(`?${imageParams.toString()}`) : baseURL
|
||||
}
|
||||
// * Jellyfin Check
|
||||
|
||||
// * By this point the URL does not match any of the expected formats, so we return null
|
||||
return null
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const imageUrl = url.searchParams.get('url')
|
||||
if (!imageUrl || !URL.canParse(imageUrl)) return new Response('Missing or invalid url parameter', { status: 400 })
|
||||
const imageUrlString = url.searchParams.get('url')
|
||||
if (!imageUrlString || !URL.canParse(imageUrlString)) return new Response('Missing or invalid url parameter', { status: 400 })
|
||||
|
||||
const fetchImage = async (): Promise<Response> => {
|
||||
const MAX_TRIES = 3
|
||||
let tries = 0
|
||||
while (tries < MAX_TRIES) {
|
||||
++tries
|
||||
const response = await fetch(imageUrl).catch(() => null)
|
||||
if (!response || !response.ok) continue
|
||||
const maxWidthInput = Number(url.searchParams.get('maxWidth'))
|
||||
const maxHeightInput = Number(url.searchParams.get('maxHeight'))
|
||||
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType || !contentType.startsWith('image')) throw Error(`Url ${imageUrl} does not link to an image`)
|
||||
const maxWidth = !Number.isNaN(maxWidthInput) && maxWidthInput > 0 ? Math.ceil(maxWidthInput) : undefined
|
||||
const maxHeight = !Number.isNaN(maxHeightInput) && maxHeightInput > 0 ? Math.ceil(maxHeightInput) : undefined
|
||||
|
||||
return response
|
||||
}
|
||||
const imageURL = modifyImageURL(new URL(imageUrlString), { maxWidth, maxHeight })
|
||||
if (!imageURL) return new Response('Unrecognized external image url format: ' + imageUrlString, { status: 400 })
|
||||
|
||||
throw Error(`Failed to fetch image at ${url} Exceed Max Retires`)
|
||||
for (let tries = 0; tries < 3; ++tries) {
|
||||
const response = await fetch(imageURL).catch(() => null)
|
||||
if (!response || !response.ok) continue
|
||||
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType || !contentType.startsWith('image')) return new Response(`Url ${imageUrlString} does not link to an image`, { status: 400 })
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
return await fetchImage()
|
||||
return new Response(`Failed to fetch image at ${imageURL}: Exceed Max Retires`, { status: 502 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user