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

@@ -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[] }>)
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':

View File

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

View File

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

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>
<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={() => {

View File

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

View File

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

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