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

@@ -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 base;
@tailwind components; @tailwind components;

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

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

View File

@@ -15,7 +15,7 @@
}).then((response) => response.json() as Promise<{ items: Song[] }>) }).then((response) => response.json() as Promise<{ items: Song[] }>)
const items = itemsResponse.items const items = itemsResponse.items
queueRef.setQueue({ songs: items }) queueRef.setQueue(items)
} }
</script> </script>
@@ -41,7 +41,7 @@
on:click={() => { on:click={() => {
switch (mediaItem.type) { switch (mediaItem.type) {
case 'song': case 'song':
queueRef.setQueue({ songs: [mediaItem] }) queueRef.setQueue([mediaItem])
break break
case 'album': case 'album':
case 'playlist': case 'playlist':

View File

@@ -5,6 +5,7 @@
// import { FastAverageColor } from 'fast-average-color' // import { FastAverageColor } from 'fast-average-color'
import Slider from '$lib/components/util/slider.svelte' import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.svelte' import Loader from '$lib/components/util/loader.svelte'
import LazyImage from './lazyImage.svelte'
$: currentlyPlaying = $queue.current $: currentlyPlaying = $queue.current
@@ -43,9 +44,9 @@
} }
onMount(() => { onMount(() => {
const storedVolume = localStorage.getItem('volume') const storedVolume = Number(localStorage.getItem('volume'))
if (storedVolume) { if (storedVolume >= 0 && storedVolume <= maxVolume) {
volume = Number(storedVolume) volume = storedVolume
} else { } else {
localStorage.setItem('volume', (maxVolume / 2).toString()) localStorage.setItem('volume', (maxVolume / 2).toString())
volume = maxVolume / 2 volume = maxVolume / 2
@@ -89,14 +90,12 @@
</script> </script>
{#if currentlyPlaying} {#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} {#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"> <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"> <section class="flex gap-3">
<div class="relative h-full w-20 min-w-20"> <div class="relative h-full w-20 min-w-20">
{#key currentlyPlaying} <LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} loadingMethod={'eager'} />
<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}
</div> </div>
<section class="flex flex-col justify-center gap-1"> <section class="flex flex-col justify-center gap-1">
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div> <div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
@@ -168,9 +167,9 @@
</section> </section>
</main> </main>
{:else} {: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="" /> <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"> <section class="relative">
{#key currentlyPlaying} {#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="" /> <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> </section>
<section class="px-8"> <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" /> <span bind:this={expandedCurrentTimeTimestamp} class="text-right" />
<Slider <Slider
bind:this={expandedProgressBar} bind:this={expandedProgressBar}
@@ -215,14 +214,15 @@
/> />
<span bind:this={expandedDurationTimestamp} class="text-left" /> <span bind:this={expandedDurationTimestamp} class="text-left" />
</div> </div>
<div class="expanded-controls"> <div id="expanded-controls">
<div class="flex flex-col gap-2 overflow-hidden"> <div class="flex flex-col gap-2 overflow-hidden">
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-9 w-full"> <div bind:clientWidth={slidingTextWrapperWidth} class="relative h-9 w-full">
<strong <strong
bind:this={slidingText} bind:this={slidingText}
bind:clientWidth={slidingTextWidth} bind:clientWidth={slidingTextWidth}
on:animationend={() => (scrollDirection *= -1)} 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>
<div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;"> <div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;">
@@ -320,11 +320,14 @@
{/if} {/if}
<style> <style>
.expanded-player { #player-wrapper {
filter: drop-shadow(0px 20px 20px #000000);
}
#expanded-player {
display: grid; display: grid;
grid-template-rows: calc(100% - 12rem) 12rem; grid-template-rows: calc(100% - 12rem) 12rem;
} }
.song-queue-wrapper { #song-queue-wrapper {
display: grid; display: grid;
grid-template-columns: 3fr 2fr; grid-template-columns: 3fr 2fr;
gap: 4rem; gap: 4rem;
@@ -333,24 +336,24 @@
display: grid; display: grid;
grid-template-columns: 5rem auto min-content; grid-template-columns: 5rem auto min-content;
} }
.progress-bar-expanded { #progress-bar-expanded {
display: grid; display: grid;
grid-template-columns: min-content auto min-content; grid-template-columns: min-content auto min-content;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
} }
.expanded-controls { #expanded-controls {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
grid-template-columns: 1fr min-content 1fr !important; grid-template-columns: 1fr min-content 1fr !important;
} }
.scrollingText { #scrollingText {
animation-timing-function: linear; animation-timing-function: linear;
animation-fill-mode: both; animation-fill-mode: both;
animation-delay: 10s; animation-delay: 10s;
} }
.scrollingText:hover { #scrollingText:hover {
animation-play-state: paused; animation-play-state: paused;
} }
.scrollLeft { .scrollLeft {

View File

@@ -1,39 +1,23 @@
<script lang="ts" context="module">
export interface NavTab {
pathname: string
name: string
icon: string
}
</script>
<script lang="ts"> <script lang="ts">
export let disabled = false
export let nav: NavTab
import { goto } from '$app/navigation' 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> </script>
<button bind:this={button} class="relative grid aspect-square w-full place-items-center transition-colors" {disabled} on:click={() => goto(nav.pathname)}> <button {disabled} class="block w-full py-2 text-left" on:click={() => goto(redirect)}>
<span class="pointer-events-none flex flex-col gap-2 text-xs"> <span class:disabled class="py-1 pl-8 {disabled ? 'text-lazuli-primary' : 'text-neutral-300'}">
<i class="{nav.icon} text-xl" /> <i class="{icon} mr-1.5 h-5 w-5" />
{nav.name} {label}
</span> </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> </button>
<style> <style>
button:disabled > div { span.disabled {
height: 80%; border-left: 2px solid var(--lazuli-primary);
} background: linear-gradient(to right, var(--lazuli-primary) -25%, transparent 25%);
button:not(:disabled) {
color: rgb(163 163, 163);
}
button:not(:disabled):hover {
color: var(--lazuli-primary);
}
button:not(:disabled):hover > div {
height: 40%;
} }
</style> </style>

View File

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

View File

@@ -9,7 +9,12 @@
} }
</script> </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 <button
class="aspect-square h-6 transition-colors duration-200 hover:text-lazuli-primary" class="aspect-square h-6 transition-colors duration-200 hover:text-lazuli-primary"
on:click|preventDefault={() => { on:click|preventDefault={() => {

View File

@@ -25,8 +25,8 @@ export class Jellyfin implements Connection {
} }
public async getConnectionInfo() { public async getConnectionInfo() {
const userEndpoint = `Users/${this.jellyfinUserId}` const userEndpoint = `/Users/${this.jellyfinUserId}`
const systemEndpoint = 'System/Info' const systemEndpoint = '/System/Info'
const getUserData = () => const getUserData = () =>
this.services this.services
@@ -42,8 +42,8 @@ export class Jellyfin implements Connection {
const [userData, systemData] = await Promise.all([getUserData(), getSystemData()]) const [userData, systemData] = await Promise.all([getUserData(), getSystemData()])
if (!userData) console.error(`Fetch to ${userEndpoint.toString()} failed`) if (!userData) console.error(`Fetch to ${userEndpoint} failed`)
if (!systemData) console.error(`Fetch to ${systemEndpoint.toString()} failed`) if (!systemData) console.error(`Fetch to ${systemEndpoint} failed`)
return { return {
id: this.id, 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 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 => ({ public parseSong = (song: JellyfinAPI.Song): Song => ({
@@ -216,8 +218,8 @@ class JellyfinServices {
id: song.Id, id: song.Id,
name: song.Name, name: song.Name,
type: 'song', type: 'song',
duration: Math.floor(song.RunTimeTicks / 10000000), duration: Math.round(song.RunTimeTicks / 10000000),
thumbnailUrl: this.getBestThumbnail(song), thumbnailUrl: this.getBestThumbnail(song, jellyfinLogo),
releaseDate: song.PremiereDate ? new Date(song.PremiereDate).toISOString() : undefined, releaseDate: song.PremiereDate ? new Date(song.PremiereDate).toISOString() : undefined,
artists: song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name })), artists: song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name })),
album: song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined, album: song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined,
@@ -229,7 +231,7 @@ class JellyfinServices {
id: album.Id, id: album.Id,
name: album.Name, name: album.Name,
type: 'album', type: 'album',
thumbnailUrl: this.getBestThumbnail(album), thumbnailUrl: this.getBestThumbnail(album, jellyfinLogo),
artists: album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists', artists: album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists',
releaseYear: album.ProductionYear?.toString(), releaseYear: album.ProductionYear?.toString(),
}) })
@@ -247,7 +249,7 @@ class JellyfinServices {
id: playlist.Id, id: playlist.Id,
name: playlist.Name, name: playlist.Name,
type: 'playlist', type: 'playlist',
thumbnailUrl: this.getBestThumbnail(playlist), thumbnailUrl: this.getBestThumbnail(playlist, jellyfinLogo),
}) })
} }
@@ -268,6 +270,7 @@ class JellyfinLibraryManager {
} }
public async artists(): Promise<Artist[]> { 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 return this.services
.request('/Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true') .request('/Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true')
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Artist[] }>) .then((response) => response.json() as Promise<{ Items: JellyfinAPI.Artist[] }>)

View File

@@ -168,7 +168,7 @@ export class YouTubeMusic implements Connection {
const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', { const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', {
headers: { 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? // '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', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -935,7 +935,8 @@ function secondsFromISO8601(duration: string): number {
* - https://yt3.googleusercontent.com * - https://yt3.googleusercontent.com
* - https://yt3.ggpht.com * - https://yt3.ggpht.com
* - https://music.youtube.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: * 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. * 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('=')) return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('='))
case 'https://music.youtube.com': case 'https://music.youtube.com':
return bestThumbnailURL 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': case 'https://i.ytimg.com':
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('?')) return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('?'))
default: default:

View File

@@ -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 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 backgroundImage: Writable<string> = writable(youtubeMusicBackground)
export const itemDisplayState: Writable<'list' | 'grid'> = writable('grid')
function fisherYatesShuffle<T>(items: T[]) { function fisherYatesShuffle<T>(items: T[]) {
for (let currentIndex = items.length - 1; currentIndex >= 0; currentIndex--) { for (let currentIndex = items.length - 1; currentIndex >= 0; currentIndex--) {
let randomIndex = Math.floor(Math.random() * (currentIndex + 1)) let randomIndex = Math.floor(Math.random() * (currentIndex + 1))
@@ -17,10 +19,6 @@ function fisherYatesShuffle<T>(items: T[]) {
return items 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 { class Queue {
private currentPosition: number // -1 means no song is playing private currentPosition: number // -1 means no song is playing
private originalSongs: Song[] private originalSongs: Song[]
@@ -36,6 +34,11 @@ class Queue {
this.shuffled = false this.shuffled = false
} }
private updateQueue() {
writableQueue.set(this)
// TODO: Implement Queue Saver
}
get current() { get current() {
if (this.currentSongs.length === 0) return null if (this.currentSongs.length === 0) return null
@@ -52,7 +55,7 @@ class Queue {
if (queuePosition >= 0) this.currentPosition = queuePosition if (queuePosition >= 0) this.currentPosition = queuePosition
} }
writableQueue.set(this) this.updateQueue()
} }
get list() { get list() {
@@ -68,7 +71,7 @@ class Queue {
const shuffledSongs = fisherYatesShuffle(this.currentSongs.slice(this.currentPosition + 1)) const shuffledSongs = fisherYatesShuffle(this.currentSongs.slice(this.currentPosition + 1))
this.currentSongs = this.currentSongs.slice(0, this.currentPosition + 1).concat(shuffledSongs) this.currentSongs = this.currentSongs.slice(0, this.currentPosition + 1).concat(shuffledSongs)
this.shuffled = true this.shuffled = true
writableQueue.set(this) this.updateQueue()
} }
/** Restores the queue to its original ordered state, while maintaining whatever song is currently playing */ /** 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.currentSongs = [...this.originalSongs]
this.currentPosition = originalPosition this.currentPosition = originalPosition
this.shuffled = false this.shuffled = false
writableQueue.set(this) this.updateQueue()
} }
/** Starts the next song */ /** Starts the next song */
@@ -85,7 +88,7 @@ class Queue {
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return
this.currentPosition += 1 this.currentPosition += 1
writableQueue.set(this) this.updateQueue()
} }
/** Plays the previous song */ /** Plays the previous song */
@@ -93,27 +96,27 @@ class Queue {
if (this.currentSongs.length === 0 || this.currentPosition <= 0) return if (this.currentSongs.length === 0 || this.currentPosition <= 0) return
this.currentPosition -= 1 this.currentPosition -= 1
writableQueue.set(this) this.updateQueue()
} }
/** Add songs to the end of the queue */ /** Add songs to the end of the queue */
public enqueue(...songs: Song[]) { public enqueue(...songs: Song[]) {
this.originalSongs.push(...songs) this.originalSongs.push(...songs)
this.currentSongs.push(...songs) this.currentSongs.push(...songs)
writableQueue.set(this) this.updateQueue()
} }
/** /**
* @param songs An ordered array of Songs * @param songs An ordered array of Songs
* @param shuffled Whether or not to shuffle the queue before starting playback. False if not specified * @param shuffled Whether or not to shuffle the queue before starting playback. False if not specified
*/ */
public setQueue(params: { songs: Song[]; shuffled?: boolean }) { public setQueue(songs: Song[], shuffled?: boolean) {
if (params.songs.length === 0) return // Should not set a queue with no songs, use clear() if (songs.length === 0) return // Should not set a queue with no songs, use clear()
this.originalSongs = params.songs this.originalSongs = songs
this.currentSongs = params.shuffled ? fisherYatesShuffle(params.songs) : params.songs this.currentSongs = shuffled ? fisherYatesShuffle(songs) : songs
this.currentPosition = 0 this.currentPosition = 0
this.shuffled = params.shuffled ?? false this.shuffled = shuffled ?? false
writableQueue.set(this) this.updateQueue()
} }
/** Clears all items from the queue */ /** Clears all items from the queue */
@@ -121,7 +124,7 @@ class Queue {
this.currentPosition = -1 this.currentPosition = -1
this.originalSongs = [] this.originalSongs = []
this.currentSongs = [] this.currentSongs = []
writableQueue.set(this) this.updateQueue()
} }
} }

View File

@@ -2,47 +2,59 @@
import SearchBar from '$lib/components/util/searchBar.svelte' import SearchBar from '$lib/components/util/searchBar.svelte'
import type { LayoutData } from './$types' import type { LayoutData } from './$types'
import NavTab from '$lib/components/navbar/navTab.svelte' 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 MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
import { goto } from '$app/navigation'
import IconButton from '$lib/components/util/iconButton.svelte'
export let data: LayoutData export let data: LayoutData
const inPathnameHeirarchy = (pathname: string, rootPathname: string): boolean => { $: currentPathname = data.url.pathname
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`
}
</script> </script>
<div class="h-full overflow-hidden"> <main id="grid-wrapper" class="h-full">
<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"> <nav id="navbar" class="items-center">
<div class="flex flex-col gap-4"> <strong class="pl-6 text-3xl">
{#each data.navTabs as nav} <i class="fa-solid fa-record-vinyl mr-1" />
<NavTab {nav} disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)} /> Lazuli
{/each} </strong>
</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 /> <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> </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 /> <slot />
</section> </section>
<MediaPlayer /> <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' import type { RequestHandler } from '@sveltejs/kit'
// This endpoint exists to act as a proxy for images, bypassing any CORS or other issues const MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE = 16383
// that could arise from using images from another origin
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 fetchImage = async (): Promise<Response> => { // 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 MAX_TRIES = 3 const jellyfinImagePathnames = [/^\/Items\/([0-9a-f]{32})\/Images\/(Primary|Art|Backdrop|Banner|Logo|Thumb|Disc|Box|Screenshot|Menu|Chapter|BoxRear|Profile)$/]
let tries = 0
while (tries < MAX_TRIES) { function modifyImageURL(imageURL: URL, options?: { maxWidth?: number; maxHeight?: number }): string | null {
++tries const maxWidth = options?.maxWidth
const response = await fetch(imageUrl).catch(() => null) 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 imageUrlString = url.searchParams.get('url')
if (!imageUrlString || !URL.canParse(imageUrlString)) return new Response('Missing or invalid url parameter', { status: 400 })
const maxWidthInput = Number(url.searchParams.get('maxWidth'))
const maxHeightInput = Number(url.searchParams.get('maxHeight'))
const maxWidth = !Number.isNaN(maxWidthInput) && maxWidthInput > 0 ? Math.ceil(maxWidthInput) : undefined
const maxHeight = !Number.isNaN(maxHeightInput) && maxHeightInput > 0 ? Math.ceil(maxHeightInput) : undefined
const imageURL = modifyImageURL(new URL(imageUrlString), { maxWidth, maxHeight })
if (!imageURL) return new Response('Unrecognized external image url format: ' + imageUrlString, { status: 400 })
for (let tries = 0; tries < 3; ++tries) {
const response = await fetch(imageURL).catch(() => null)
if (!response || !response.ok) continue if (!response || !response.ok) continue
const contentType = response.headers.get('content-type') const contentType = response.headers.get('content-type')
if (!contentType || !contentType.startsWith('image')) throw Error(`Url ${imageUrl} does not link to an image`) if (!contentType || !contentType.startsWith('image')) return new Response(`Url ${imageUrlString} does not link to an image`, { status: 400 })
return response return response
} }
throw Error(`Failed to fetch image at ${url} Exceed Max Retires`) return new Response(`Failed to fetch image at ${imageURL}: Exceed Max Retires`, { status: 502 })
}
return await fetchImage()
} }