Trying out some UI changes. Added resizing support to remoteImage and LazyImage component

This commit is contained in:
Eclypsed
2024-06-10 03:22:12 -04:00
parent cb4cc1d949
commit 9dab826e53
20 changed files with 462 additions and 288 deletions

View File

@@ -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 &bull; {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>

View File

@@ -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 }
}

View 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>

View File

@@ -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()]) }
}

View File

@@ -1 +1 @@
<h1>Welcome to the library page!</h1>
<h1>This would be a good place for listen history</h1>

View 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() }
}

View 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>

View 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}
&#44&#160
{/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>

View File

@@ -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 })
}