diff --git a/src/app.css b/src/app.css index d1a23f3..e0e22de 100644 --- a/src/app.css +++ b/src/app.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@500&family=Noto+Sans+JP:wght@500&family=Noto+Sans+KR:wght@500&family=Noto+Sans+SC:wght@500&family=Noto+Sans+TC:wght@500&family=Noto+Sans:wght@500&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@100..900&family=Noto+Sans+JP:wght@100..900&family=Noto+Sans+KR:wght@100..900&family=Noto+Sans+SC:wght@100..900&family=Noto+Sans+TC:wght@100..900&family=Noto+Sans:wght@100..900&display=swap'); @tailwind base; @tailwind components; diff --git a/src/lib/components/media/lazyImage.svelte b/src/lib/components/media/lazyImage.svelte new file mode 100644 index 0000000..1b47d5d --- /dev/null +++ b/src/lib/components/media/lazyImage.svelte @@ -0,0 +1,64 @@ + + + + +
diff --git a/src/lib/components/media/listItem.svelte b/src/lib/components/media/listItem.svelte new file mode 100644 index 0000000..b02557d --- /dev/null +++ b/src/lib/components/media/listItem.svelte @@ -0,0 +1,53 @@ + + +
+
+ {#if thumbnailUrl} + + {:else} +
+ +
+ {/if} +
+
{mediaItem.name}
+ + {#if 'artists' in mediaItem && mediaItem.artists} + {#if mediaItem.artists === 'Various Artists'} + Various Artists + {:else} + {#each mediaItem.artists as artist, index} + {artist.name} + {#if index < mediaItem.artists.length - 1} + ,  + {/if} + {/each} + {/if} + {:else if 'uploader' in mediaItem && mediaItem.uploader} + {mediaItem.uploader.name} + {:else if 'createdBy' in mediaItem && mediaItem.createdBy} + {mediaItem.createdBy.name} + {/if} + +
{date ?? ''}
+
+ + diff --git a/src/lib/components/media/mediaCard.svelte b/src/lib/components/media/mediaCard.svelte index fa6940c..3a43476 100644 --- a/src/lib/components/media/mediaCard.svelte +++ b/src/lib/components/media/mediaCard.svelte @@ -15,7 +15,7 @@ }).then((response) => response.json() as Promise<{ items: Song[] }>) const items = itemsResponse.items - queueRef.setQueue({ songs: items }) + queueRef.setQueue(items) } @@ -41,7 +41,7 @@ on:click={() => { switch (mediaItem.type) { case 'song': - queueRef.setQueue({ songs: [mediaItem] }) + queueRef.setQueue([mediaItem]) break case 'album': case 'playlist': diff --git a/src/lib/components/media/mediaPlayer.svelte b/src/lib/components/media/mediaPlayer.svelte index 88f0364..9639609 100644 --- a/src/lib/components/media/mediaPlayer.svelte +++ b/src/lib/components/media/mediaPlayer.svelte @@ -5,6 +5,7 @@ // import { FastAverageColor } from 'fast-average-color' import Slider from '$lib/components/util/slider.svelte' import Loader from '$lib/components/util/loader.svelte' + import LazyImage from './lazyImage.svelte' $: currentlyPlaying = $queue.current @@ -43,9 +44,9 @@ } onMount(() => { - const storedVolume = localStorage.getItem('volume') - if (storedVolume) { - volume = Number(storedVolume) + const storedVolume = Number(localStorage.getItem('volume')) + if (storedVolume >= 0 && storedVolume <= maxVolume) { + volume = storedVolume } else { localStorage.setItem('volume', (maxVolume / 2).toString()) volume = maxVolume / 2 @@ -89,14 +90,12 @@ {#if currentlyPlaying} -
+
{#if !expanded}
- {#key currentlyPlaying} -
- {/key} +
{currentlyPlaying.name}
@@ -168,9 +167,9 @@
{:else} -
+
-
+
{#key currentlyPlaying} @@ -199,7 +198,7 @@
-
+
-
+
(scrollDirection *= -1)} - class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} scrollingText absolute whitespace-nowrap text-3xl">{currentlyPlaying.name}{currentlyPlaying.name}
@@ -320,11 +320,14 @@ {/if} diff --git a/src/lib/components/navbar/playlistTab.svelte b/src/lib/components/navbar/playlistTab.svelte deleted file mode 100644 index dc193ee..0000000 --- a/src/lib/components/navbar/playlistTab.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - diff --git a/src/lib/components/util/searchBar.svelte b/src/lib/components/util/searchBar.svelte index b232f4c..5f81fb7 100644 --- a/src/lib/components/util/searchBar.svelte +++ b/src/lib/components/util/searchBar.svelte @@ -9,7 +9,12 @@ } - +
+ + diff --git a/src/routes/(app)/+layout.ts b/src/routes/(app)/+layout.ts deleted file mode 100644 index 3d5f127..0000000 --- a/src/routes/(app)/+layout.ts +++ /dev/null @@ -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 } -} diff --git a/src/routes/(app)/library/+layout.svelte b/src/routes/(app)/library/+layout.svelte new file mode 100644 index 0000000..eb887b9 --- /dev/null +++ b/src/routes/(app)/library/+layout.svelte @@ -0,0 +1,45 @@ + + +
+ + {#key currentPathname} +
+ +
+ {/key} +
+ + diff --git a/src/routes/(app)/library/+page.server.ts b/src/routes/(app)/library/+page.server.ts deleted file mode 100644 index 763bbe3..0000000 --- a/src/routes/(app)/library/+page.server.ts +++ /dev/null @@ -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()]) } -} diff --git a/src/routes/(app)/library/+page.svelte b/src/routes/(app)/library/+page.svelte index a968f60..86d3cfb 100644 --- a/src/routes/(app)/library/+page.svelte +++ b/src/routes/(app)/library/+page.svelte @@ -1 +1 @@ -

Welcome to the library page!

+

This would be a good place for listen history

diff --git a/src/routes/(app)/library/albums/+page.server.ts b/src/routes/(app)/library/albums/+page.server.ts new file mode 100644 index 0000000..e014b7f --- /dev/null +++ b/src/routes/(app)/library/albums/+page.server.ts @@ -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() } +} diff --git a/src/routes/(app)/library/albums/+page.svelte b/src/routes/(app)/library/albums/+page.svelte new file mode 100644 index 0000000..8a53d0f --- /dev/null +++ b/src/routes/(app)/library/albums/+page.svelte @@ -0,0 +1,39 @@ + + +
+ {#await data.albums} + + {:then albums} + {#if 'error' in albums} +

{albums.error}

+ {:else if $itemDisplayState === 'list'} +
+ {#each albums as album} + + {/each} +
+ {:else} +
+ {#each albums as album} + + {/each} +
+ {/if} + {/await} +
+ + diff --git a/src/routes/(app)/library/albums/albumCard.svelte b/src/routes/(app)/library/albums/albumCard.svelte new file mode 100644 index 0000000..1a5c0cd --- /dev/null +++ b/src/routes/(app)/library/albums/albumCard.svelte @@ -0,0 +1,67 @@ + + +
+
+ +
+ + + +
+
+
+
{album.name}
+
+ {#if album.artists === 'Various Artists'} + Various Artists + {:else} + {#each album.artists as artist, index} + {artist.name} + {#if index < album.artists.length - 1} + ,  + {/if} + {/each} + {/if} +
+
+
+ + diff --git a/src/routes/api/remoteImage/+server.ts b/src/routes/api/remoteImage/+server.ts index 1b637f6..2988c65 100644 --- a/src/routes/api/remoteImage/+server.ts +++ b/src/routes/api/remoteImage/+server.ts @@ -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 => { - 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 }) }