Trying out some UI changes. Added resizing support to remoteImage and LazyImage component
This commit is contained in:
@@ -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;
|
||||
|
||||
64
src/lib/components/media/lazyImage.svelte
Normal file
64
src/lib/components/media/lazyImage.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<!--
|
||||
@component
|
||||
A component to help render images in a smooth and efficient way. The url passed will be fetched via Lazuli's
|
||||
remoteImage API endpoint with size parameters that are dynamically calculated base off of the image's container's
|
||||
width and height. Images are lazily loaded unless 'eager' loading is specified.
|
||||
|
||||
@param thumbnailUrl A string of a URL that points to the desired image.
|
||||
@param alt Supplementary text in the event the image fails to load.
|
||||
@param loadingMethod Optional. Either the string 'lazy' or 'eager', defaults to lazy. The method by which to load the image.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let thumbnailUrl: string
|
||||
export let alt: string
|
||||
export let loadingMethod: 'lazy' | 'eager' = 'lazy'
|
||||
|
||||
let imageContainer: HTMLDivElement
|
||||
|
||||
function removeOldImage() {
|
||||
if (imageContainer.childElementCount > 1) {
|
||||
const oldImage = imageContainer.firstChild! as HTMLImageElement
|
||||
oldImage.style.opacity = '0'
|
||||
setTimeout(() => imageContainer.removeChild(oldImage), 500)
|
||||
}
|
||||
}
|
||||
|
||||
function updateImage(newThumbnailURL: string) {
|
||||
if (!imageContainer) return
|
||||
|
||||
const width = imageContainer.clientWidth
|
||||
const height = imageContainer.clientHeight
|
||||
|
||||
const newImage = new Image(width, height)
|
||||
imageContainer.appendChild(newImage)
|
||||
|
||||
newImage.loading = loadingMethod
|
||||
newImage.src = `/api/remoteImage?url=${newThumbnailURL}&`.concat(width > height ? `maxWidth=${width}` : `maxHeight=${height}`)
|
||||
newImage.alt = alt
|
||||
|
||||
newImage.style.width = '100%'
|
||||
newImage.style.height = '100%'
|
||||
newImage.style.objectFit = 'cover'
|
||||
newImage.style.opacity = '0'
|
||||
newImage.style.position = 'absolute'
|
||||
|
||||
newImage.onload = () => {
|
||||
removeOldImage()
|
||||
newImage.style.transition = 'opacity 500ms ease'
|
||||
newImage.style.opacity = '1'
|
||||
}
|
||||
|
||||
newImage.onerror = () => {
|
||||
removeOldImage()
|
||||
newImage.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => updateImage(thumbnailUrl))
|
||||
$: updateImage(thumbnailUrl)
|
||||
</script>
|
||||
|
||||
<div bind:this={imageContainer} class="relative h-full w-full"></div>
|
||||
53
src/lib/components/media/listItem.svelte
Normal file
53
src/lib/components/media/listItem.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import LazyImage from './lazyImage.svelte'
|
||||
|
||||
export let mediaItem: Song | Album | Artist | Playlist
|
||||
|
||||
const thumbnailUrl = 'thumbnailUrl' in mediaItem ? mediaItem.thumbnailUrl : mediaItem.profilePicture
|
||||
|
||||
const date = 'releaseDate' in mediaItem && mediaItem.releaseDate ? new Date(mediaItem.releaseDate).getFullYear().toString() : 'releaseYear' in mediaItem ? mediaItem.releaseYear : undefined
|
||||
</script>
|
||||
|
||||
<div id="list-item" class="h-16 w-full">
|
||||
<div class="h-full overflow-clip rounded-md">
|
||||
{#if thumbnailUrl}
|
||||
<LazyImage {thumbnailUrl} alt={`${mediaItem.name} thumbnial`} />
|
||||
{:else}
|
||||
<div id="thumbnail-placeholder" class="grid h-full w-full place-items-center bg-lazuli-primary">
|
||||
<i class="fa-solid {mediaItem.type === 'artist' ? 'fa-user' : 'fa-play'} text-2xl" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="line-clamp-1">{mediaItem.name}</div>
|
||||
<span class="line-clamp-1 flex text-neutral-400">
|
||||
{#if 'artists' in mediaItem && mediaItem.artists}
|
||||
{#if mediaItem.artists === 'Various Artists'}
|
||||
<span>Various Artists</span>
|
||||
{:else}
|
||||
{#each mediaItem.artists as artist, index}
|
||||
<a class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
|
||||
{#if index < mediaItem.artists.length - 1}
|
||||
, 
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if 'uploader' in mediaItem && mediaItem.uploader}
|
||||
<span>{mediaItem.uploader.name}</span>
|
||||
{:else if 'createdBy' in mediaItem && mediaItem.createdBy}
|
||||
<span>{mediaItem.createdBy.name}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="justify-self-center text-neutral-400">{date ?? ''}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#list-item {
|
||||
display: grid;
|
||||
column-gap: 1rem;
|
||||
align-items: center;
|
||||
grid-template-columns: 4rem 1fr 1fr 5rem;
|
||||
}
|
||||
#thumbnail-placeholder {
|
||||
background: radial-gradient(circle, rgba(0, 0, 0, 0) 25%, rgba(35, 40, 50, 1) 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
}).then((response) => response.json() as Promise<{ items: Song[] }>)
|
||||
|
||||
const items = itemsResponse.items
|
||||
queueRef.setQueue({ songs: items })
|
||||
queueRef.setQueue(items)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
on:click={() => {
|
||||
switch (mediaItem.type) {
|
||||
case 'song':
|
||||
queueRef.setQueue({ songs: [mediaItem] })
|
||||
queueRef.setQueue([mediaItem])
|
||||
break
|
||||
case 'album':
|
||||
case 'playlist':
|
||||
|
||||
@@ -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 @@
|
||||
</script>
|
||||
|
||||
{#if currentlyPlaying}
|
||||
<div transition:slide class="{expanded ? 'h-full w-full' : 'm-4 h-20 w-[calc(100%_-_32px)] rounded-xl'} absolute bottom-0 z-40 overflow-clip bg-neutral-925 transition-all duration-500">
|
||||
<div id="player-wrapper" transition:slide class="{expanded ? 'h-full w-full' : 'm-4 h-20 w-[calc(100%_-_32px)] rounded-xl'} absolute bottom-0 z-40 overflow-clip bg-neutral-925 transition-all duration-500">
|
||||
{#if !expanded}
|
||||
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="relative grid h-20 w-full grid-cols-[minmax(auto,_20rem)_auto_minmax(auto,_20rem)] gap-4">
|
||||
<section class="flex gap-3">
|
||||
<div class="relative h-full w-20 min-w-20">
|
||||
{#key currentlyPlaying}
|
||||
<div transition:fade={{ duration: 500 }} class="absolute h-full w-full bg-cover bg-center bg-no-repeat" style="background-image: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});" />
|
||||
{/key}
|
||||
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} loadingMethod={'eager'} />
|
||||
</div>
|
||||
<section class="flex flex-col justify-center gap-1">
|
||||
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
|
||||
@@ -168,9 +167,9 @@
|
||||
</section>
|
||||
</main>
|
||||
{:else}
|
||||
<main in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="expanded-player relative h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});">
|
||||
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});">
|
||||
<img class="absolute -z-10 h-full w-full object-cover object-center blur-xl brightness-[25%]" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
|
||||
<section class="song-queue-wrapper h-full px-24 py-20">
|
||||
<section id="song-queue-wrapper" class="h-full px-24 py-20">
|
||||
<section class="relative">
|
||||
{#key currentlyPlaying}
|
||||
<img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
|
||||
@@ -199,7 +198,7 @@
|
||||
</section>
|
||||
</section>
|
||||
<section class="px-8">
|
||||
<div class="progress-bar-expanded mb-8">
|
||||
<div id="progress-bar-expanded" class="mb-8">
|
||||
<span bind:this={expandedCurrentTimeTimestamp} class="text-right" />
|
||||
<Slider
|
||||
bind:this={expandedProgressBar}
|
||||
@@ -215,14 +214,15 @@
|
||||
/>
|
||||
<span bind:this={expandedDurationTimestamp} class="text-left" />
|
||||
</div>
|
||||
<div class="expanded-controls">
|
||||
<div id="expanded-controls">
|
||||
<div class="flex flex-col gap-2 overflow-hidden">
|
||||
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-9 w-full">
|
||||
<strong
|
||||
bind:this={slidingText}
|
||||
bind:clientWidth={slidingTextWidth}
|
||||
on:animationend={() => (scrollDirection *= -1)}
|
||||
class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} scrollingText absolute whitespace-nowrap text-3xl">{currentlyPlaying.name}</strong
|
||||
id="scrollingText"
|
||||
class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} absolute whitespace-nowrap text-3xl">{currentlyPlaying.name}</strong
|
||||
>
|
||||
</div>
|
||||
<div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;">
|
||||
@@ -320,11 +320,14 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.expanded-player {
|
||||
#player-wrapper {
|
||||
filter: drop-shadow(0px 20px 20px #000000);
|
||||
}
|
||||
#expanded-player {
|
||||
display: grid;
|
||||
grid-template-rows: calc(100% - 12rem) 12rem;
|
||||
}
|
||||
.song-queue-wrapper {
|
||||
#song-queue-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 4rem;
|
||||
@@ -333,24 +336,24 @@
|
||||
display: grid;
|
||||
grid-template-columns: 5rem auto min-content;
|
||||
}
|
||||
.progress-bar-expanded {
|
||||
#progress-bar-expanded {
|
||||
display: grid;
|
||||
grid-template-columns: min-content auto min-content;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.expanded-controls {
|
||||
#expanded-controls {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr min-content 1fr !important;
|
||||
}
|
||||
|
||||
.scrollingText {
|
||||
#scrollingText {
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: both;
|
||||
animation-delay: 10s;
|
||||
}
|
||||
.scrollingText:hover {
|
||||
#scrollingText:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.scrollLeft {
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
<script lang="ts" context="module">
|
||||
export interface NavTab {
|
||||
pathname: string
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let disabled = false
|
||||
export let nav: NavTab
|
||||
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
let button: HTMLButtonElement
|
||||
export let icon: string
|
||||
export let label: string
|
||||
export let redirect: string
|
||||
|
||||
export let disabled: boolean
|
||||
</script>
|
||||
|
||||
<button bind:this={button} class="relative grid aspect-square w-full place-items-center transition-colors" {disabled} on:click={() => goto(nav.pathname)}>
|
||||
<span class="pointer-events-none flex flex-col gap-2 text-xs">
|
||||
<i class="{nav.icon} text-xl" />
|
||||
{nav.name}
|
||||
<button {disabled} class="block w-full py-2 text-left" on:click={() => goto(redirect)}>
|
||||
<span class:disabled class="py-1 pl-8 {disabled ? 'text-lazuli-primary' : 'text-neutral-300'}">
|
||||
<i class="{icon} mr-1.5 h-5 w-5" />
|
||||
{label}
|
||||
</span>
|
||||
<div class="absolute left-0 top-1/2 h-0 w-[0.2rem] -translate-x-2 -translate-y-1/2 rounded-lg bg-white transition-all" />
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button:disabled > div {
|
||||
height: 80%;
|
||||
}
|
||||
button:not(:disabled) {
|
||||
color: rgb(163 163, 163);
|
||||
}
|
||||
button:not(:disabled):hover {
|
||||
color: var(--lazuli-primary);
|
||||
}
|
||||
button:not(:disabled):hover > div {
|
||||
height: 40%;
|
||||
span.disabled {
|
||||
border-left: 2px solid var(--lazuli-primary);
|
||||
background: linear-gradient(to right, var(--lazuli-primary) -25%, transparent 25%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts" context="module">
|
||||
export interface PlaylistTab {
|
||||
id: string
|
||||
name: string
|
||||
thumbnail: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let disabled = false
|
||||
export let playlist: PlaylistTab
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let button: HTMLButtonElement
|
||||
|
||||
type ButtonCenter = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const calculateCenter = (button: HTMLButtonElement): ButtonCenter => {
|
||||
const rect = button.getBoundingClientRect()
|
||||
const x = (rect.left + rect.right) / 2
|
||||
const y = (rect.top + rect.bottom) / 2
|
||||
return { x, y }
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
{disabled}
|
||||
bind:this={button}
|
||||
class="relative aspect-square w-full flex-shrink-0 rounded-lg bg-cover bg-center transition-all"
|
||||
style="background-image: url({playlist.thumbnail});"
|
||||
on:mouseenter={() => dispatch('mouseenter', { ...calculateCenter(button), content: playlist.name })}
|
||||
on:mouseleave={() => dispatch('mouseleave')}
|
||||
on:click={() => {
|
||||
dispatch('click')
|
||||
goto(`/library?playlist=${playlist.id}`)
|
||||
}}
|
||||
>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button:not(:disabled):not(:hover) {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<search role="search" bind:this={searchBar} class="relative flex h-12 w-full items-center gap-2.5 justify-self-center rounded-full border-2 border-transparent px-4 py-2" style="background-color: rgba(82, 82, 82, 0.25);">
|
||||
<search
|
||||
role="search"
|
||||
bind:this={searchBar}
|
||||
class="relative flex h-10 w-full min-w-60 max-w-screen-sm items-center gap-2.5 rounded-lg border-2 border-transparent px-4 py-2"
|
||||
style="background-color: rgba(82, 82, 82, 0.25);"
|
||||
>
|
||||
<button
|
||||
class="aspect-square h-6 transition-colors duration-200 hover:text-lazuli-primary"
|
||||
on:click|preventDefault={() => {
|
||||
|
||||
@@ -25,8 +25,8 @@ export class Jellyfin implements Connection {
|
||||
}
|
||||
|
||||
public async getConnectionInfo() {
|
||||
const userEndpoint = `Users/${this.jellyfinUserId}`
|
||||
const systemEndpoint = 'System/Info'
|
||||
const userEndpoint = `/Users/${this.jellyfinUserId}`
|
||||
const systemEndpoint = '/System/Info'
|
||||
|
||||
const getUserData = () =>
|
||||
this.services
|
||||
@@ -42,8 +42,8 @@ export class Jellyfin implements Connection {
|
||||
|
||||
const [userData, systemData] = await Promise.all([getUserData(), getSystemData()])
|
||||
|
||||
if (!userData) console.error(`Fetch to ${userEndpoint.toString()} failed`)
|
||||
if (!systemData) console.error(`Fetch to ${systemEndpoint.toString()} failed`)
|
||||
if (!userData) console.error(`Fetch to ${userEndpoint} failed`)
|
||||
if (!systemData) console.error(`Fetch to ${systemEndpoint} failed`)
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -206,9 +206,11 @@ class JellyfinServices {
|
||||
}
|
||||
}
|
||||
|
||||
private getBestThumbnail = (item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist) => {
|
||||
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder: string): string
|
||||
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined
|
||||
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined {
|
||||
const imageItemId = item.ImageTags?.Primary ? item.Id : 'AlbumPrimaryImageTag' in item && item.AlbumPrimaryImageTag ? item.AlbumId : undefined
|
||||
return imageItemId ? this.serverUrl(`Items/${imageItemId}/Images/Primary`).toString() : jellyfinLogo
|
||||
return imageItemId ? this.serverUrl(`Items/${imageItemId}/Images/Primary`).toString() : placeholder
|
||||
}
|
||||
|
||||
public parseSong = (song: JellyfinAPI.Song): Song => ({
|
||||
@@ -216,8 +218,8 @@ class JellyfinServices {
|
||||
id: song.Id,
|
||||
name: song.Name,
|
||||
type: 'song',
|
||||
duration: Math.floor(song.RunTimeTicks / 10000000),
|
||||
thumbnailUrl: this.getBestThumbnail(song),
|
||||
duration: Math.round(song.RunTimeTicks / 10000000),
|
||||
thumbnailUrl: this.getBestThumbnail(song, jellyfinLogo),
|
||||
releaseDate: song.PremiereDate ? new Date(song.PremiereDate).toISOString() : undefined,
|
||||
artists: song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name })),
|
||||
album: song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined,
|
||||
@@ -229,7 +231,7 @@ class JellyfinServices {
|
||||
id: album.Id,
|
||||
name: album.Name,
|
||||
type: 'album',
|
||||
thumbnailUrl: this.getBestThumbnail(album),
|
||||
thumbnailUrl: this.getBestThumbnail(album, jellyfinLogo),
|
||||
artists: album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists',
|
||||
releaseYear: album.ProductionYear?.toString(),
|
||||
})
|
||||
@@ -247,7 +249,7 @@ class JellyfinServices {
|
||||
id: playlist.Id,
|
||||
name: playlist.Name,
|
||||
type: 'playlist',
|
||||
thumbnailUrl: this.getBestThumbnail(playlist),
|
||||
thumbnailUrl: this.getBestThumbnail(playlist, jellyfinLogo),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -268,6 +270,7 @@ class JellyfinLibraryManager {
|
||||
}
|
||||
|
||||
public async artists(): Promise<Artist[]> {
|
||||
// ? This returns just album artists instead of all artists like in finamp, but I might decide that I want to return all artists instead
|
||||
return this.services
|
||||
.request('/Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true')
|
||||
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Artist[] }>)
|
||||
|
||||
@@ -168,7 +168,7 @@ export class YouTubeMusic implements Connection {
|
||||
const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', {
|
||||
headers: {
|
||||
// 'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', <-- I thought this was necessary but it appears it might not be?
|
||||
authorization: `Bearer ${await this.requestManager.accessToken}`, // * Including the access token is what enables access to premium content
|
||||
authorization: `Bearer ${await this.requestManager.accessToken}`, // * Including the access token is what enables access to premium content for some reason
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -935,7 +935,8 @@ function secondsFromISO8601(duration: string): number {
|
||||
* - https://yt3.googleusercontent.com
|
||||
* - https://yt3.ggpht.com
|
||||
* - https://music.youtube.com
|
||||
* - https://i.ytimg.com
|
||||
* - https://www.gstatic.com - Static images (e.g. a placeholder artist profile picture)
|
||||
* - https://i.ytimg.com - Video Thumbnails
|
||||
*
|
||||
* NOTE:
|
||||
* https://i.ytimg.com corresponds to videos, which follow the mqdefault...maxres resolutions scale. It is generally bad practice to use these as there is no way to scale them with query params, and there is no way to tell if a maxres.jpg exists or not.
|
||||
@@ -953,7 +954,7 @@ function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: numb
|
||||
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('='))
|
||||
case 'https://music.youtube.com':
|
||||
return bestThumbnailURL
|
||||
case 'https://www.gstatic.com': // This url will usually contain static images like a placeholder artist profile picture for example
|
||||
case 'https://www.gstatic.com':
|
||||
case 'https://i.ytimg.com':
|
||||
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('?'))
|
||||
default:
|
||||
|
||||
@@ -8,6 +8,8 @@ export const newestAlert: Writable<[AlertType, string]> = writable()
|
||||
const youtubeMusicBackground: string = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // Default Youtube music background
|
||||
export const backgroundImage: Writable<string> = writable(youtubeMusicBackground)
|
||||
|
||||
export const itemDisplayState: Writable<'list' | 'grid'> = writable('grid')
|
||||
|
||||
function fisherYatesShuffle<T>(items: T[]) {
|
||||
for (let currentIndex = items.length - 1; currentIndex >= 0; currentIndex--) {
|
||||
let randomIndex = Math.floor(Math.random() * (currentIndex + 1))
|
||||
@@ -17,10 +19,6 @@ function fisherYatesShuffle<T>(items: T[]) {
|
||||
return items
|
||||
}
|
||||
|
||||
// ? New idea for how to handle mixing. Keep originalSongs and currentSongs but also add playedSongs. Add the previous song to played songs whenever next() is called.
|
||||
// ? Whenever a song is mixed, set currentSongs = [...playedSongs, currentSongs[currentPosition], ...fisherYatesShuffle(everything else)]. Reorder method would stay the same.
|
||||
// ? IDK it's a thought
|
||||
|
||||
class Queue {
|
||||
private currentPosition: number // -1 means no song is playing
|
||||
private originalSongs: Song[]
|
||||
@@ -36,6 +34,11 @@ class Queue {
|
||||
this.shuffled = false
|
||||
}
|
||||
|
||||
private updateQueue() {
|
||||
writableQueue.set(this)
|
||||
// TODO: Implement Queue Saver
|
||||
}
|
||||
|
||||
get current() {
|
||||
if (this.currentSongs.length === 0) return null
|
||||
|
||||
@@ -52,7 +55,7 @@ class Queue {
|
||||
if (queuePosition >= 0) this.currentPosition = queuePosition
|
||||
}
|
||||
|
||||
writableQueue.set(this)
|
||||
this.updateQueue()
|
||||
}
|
||||
|
||||
get list() {
|
||||
@@ -68,7 +71,7 @@ class Queue {
|
||||
const shuffledSongs = fisherYatesShuffle(this.currentSongs.slice(this.currentPosition + 1))
|
||||
this.currentSongs = this.currentSongs.slice(0, this.currentPosition + 1).concat(shuffledSongs)
|
||||
this.shuffled = true
|
||||
writableQueue.set(this)
|
||||
this.updateQueue()
|
||||
}
|
||||
|
||||
/** Restores the queue to its original ordered state, while maintaining whatever song is currently playing */
|
||||
@@ -77,7 +80,7 @@ class Queue {
|
||||
this.currentSongs = [...this.originalSongs]
|
||||
this.currentPosition = originalPosition
|
||||
this.shuffled = false
|
||||
writableQueue.set(this)
|
||||
this.updateQueue()
|
||||
}
|
||||
|
||||
/** Starts the next song */
|
||||
@@ -85,7 +88,7 @@ class Queue {
|
||||
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return
|
||||
|
||||
this.currentPosition += 1
|
||||
writableQueue.set(this)
|
||||
this.updateQueue()
|
||||
}
|
||||
|
||||
/** Plays the previous song */
|
||||
@@ -93,27 +96,27 @@ class Queue {
|
||||
if (this.currentSongs.length === 0 || this.currentPosition <= 0) return
|
||||
|
||||
this.currentPosition -= 1
|
||||
writableQueue.set(this)
|
||||
this.updateQueue()
|
||||
}
|
||||
|
||||
/** Add songs to the end of the queue */
|
||||
public enqueue(...songs: Song[]) {
|
||||
this.originalSongs.push(...songs)
|
||||
this.currentSongs.push(...songs)
|
||||
writableQueue.set(this)
|
||||
this.updateQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param songs An ordered array of Songs
|
||||
* @param shuffled Whether or not to shuffle the queue before starting playback. False if not specified
|
||||
*/
|
||||
public setQueue(params: { songs: Song[]; shuffled?: boolean }) {
|
||||
if (params.songs.length === 0) return // Should not set a queue with no songs, use clear()
|
||||
this.originalSongs = params.songs
|
||||
this.currentSongs = params.shuffled ? fisherYatesShuffle(params.songs) : params.songs
|
||||
public setQueue(songs: Song[], shuffled?: boolean) {
|
||||
if (songs.length === 0) return // Should not set a queue with no songs, use clear()
|
||||
this.originalSongs = songs
|
||||
this.currentSongs = shuffled ? fisherYatesShuffle(songs) : songs
|
||||
this.currentPosition = 0
|
||||
this.shuffled = params.shuffled ?? false
|
||||
writableQueue.set(this)
|
||||
this.shuffled = shuffled ?? false
|
||||
this.updateQueue()
|
||||
}
|
||||
|
||||
/** Clears all items from the queue */
|
||||
@@ -121,7 +124,7 @@ class Queue {
|
||||
this.currentPosition = -1
|
||||
this.originalSongs = []
|
||||
this.currentSongs = []
|
||||
writableQueue.set(this)
|
||||
this.updateQueue()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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