New Miniplaer + ScrollingText && ArtistList components

This commit is contained in:
Eclypsed
2024-06-11 03:55:34 -04:00
parent 9dab826e53
commit ca80a6476f
9 changed files with 272 additions and 222 deletions

View File

@@ -0,0 +1,42 @@
<!--
@component
A component to easily display the artists of a track or album, or the user associated with either a Song, Album, or Playlist
object. Formatting of the text such as font size, weight, and line clamps can be specified in a wrapper element.
@param mediaItem Either a Song, Album, or Playlist object.
@param linked Boolean. If true artists will be linked with anchor tags. Defaults to true.
-->
<script lang="ts">
export let mediaItem: Song | Album | Playlist
export let linked = true
</script>
<div class="break-keep">
{#if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists === 'string'}
{mediaItem.artists}
{:else if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists !== 'string' && mediaItem.artists.length > 0}
{#each mediaItem.artists as artist, index}
{#if linked}
<a class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
{:else}
<span>{artist.name}</span>
{/if}
{#if index < mediaItem.artists.length - 1}
<span style="margin-left: -0.25em; margin-right: 0.25em">&#44;</span>
{/if}
{/each}
{:else if 'uploader' in mediaItem && mediaItem.uploader}
{#if linked}
<a class="hover:underline focus:underline" href="/details/user?id={mediaItem.uploader.id}&connection={mediaItem.connection.id}">{mediaItem.uploader.name}</a>
{:else}
<span>{mediaItem.uploader.name}</span>
{/if}
{:else if 'createdBy' in mediaItem && mediaItem.createdBy}
{#if linked}
<a class="hover:underline focus:underline" href="/details/user?id={mediaItem.createdBy.id}&connection={mediaItem.connection.id}">{mediaItem.createdBy.name}</a>
{:else}
<span>{mediaItem.createdBy.name}</span>
{/if}
{/if}
</div>

View File

@@ -7,6 +7,8 @@
@param thumbnailUrl A string of a URL that points to the desired image. @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 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. @param loadingMethod Optional. Either the string 'lazy' or 'eager', defaults to lazy. The method by which to load the image.
@param objectFit One of the following fill, contain, cover, none, scale-down. Specifies the object-fit styling of the image
@param objectPosistion Optional. Specifies the object-position styling of the image. Defaults to 'center'
--> -->
<script lang="ts"> <script lang="ts">
@@ -15,17 +17,11 @@
export let thumbnailUrl: string export let thumbnailUrl: string
export let alt: string export let alt: string
export let loadingMethod: 'lazy' | 'eager' = 'lazy' export let loadingMethod: 'lazy' | 'eager' = 'lazy'
export let objectFit: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
export let objectPosition: string = 'center'
let imageContainer: HTMLDivElement 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) { function updateImage(newThumbnailURL: string) {
if (!imageContainer) return if (!imageContainer) return
@@ -41,10 +37,19 @@
newImage.style.width = '100%' newImage.style.width = '100%'
newImage.style.height = '100%' newImage.style.height = '100%'
newImage.style.objectFit = 'cover' newImage.style.objectFit = objectFit
newImage.style.objectPosition = objectPosition
newImage.style.opacity = '0' newImage.style.opacity = '0'
newImage.style.position = 'absolute' newImage.style.position = 'absolute'
function removeOldImage() {
if (imageContainer.childElementCount > 1) {
const oldImage = imageContainer.firstChild! as HTMLImageElement
oldImage.style.opacity = '0'
setTimeout(() => imageContainer.removeChild(oldImage), 500)
}
}
newImage.onload = () => { newImage.onload = () => {
removeOldImage() removeOldImage()
newImage.style.transition = 'opacity 500ms ease' newImage.style.transition = 'opacity 500ms ease'

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import LazyImage from './lazyImage.svelte' import LazyImage from './lazyImage.svelte'
import ArtistList from './artistList.svelte'
export let mediaItem: Song | Album | Artist | Playlist export let mediaItem: Song | Album | Artist | Playlist
@@ -11,7 +12,7 @@
<div id="list-item" class="h-16 w-full"> <div id="list-item" class="h-16 w-full">
<div class="h-full overflow-clip rounded-md"> <div class="h-full overflow-clip rounded-md">
{#if thumbnailUrl} {#if thumbnailUrl}
<LazyImage {thumbnailUrl} alt={`${mediaItem.name} thumbnial`} /> <LazyImage {thumbnailUrl} alt={`${mediaItem.name} thumbnial`} objectFit={'cover'} />
{:else} {:else}
<div id="thumbnail-placeholder" class="grid h-full w-full place-items-center bg-lazuli-primary"> <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" /> <i class="fa-solid {mediaItem.type === 'artist' ? 'fa-user' : 'fa-play'} text-2xl" />
@@ -19,22 +20,9 @@
{/if} {/if}
</div> </div>
<div class="line-clamp-1">{mediaItem.name}</div> <div class="line-clamp-1">{mediaItem.name}</div>
<span class="line-clamp-1 flex text-neutral-400"> <span class="line-clamp-1 text-neutral-400">
{#if 'artists' in mediaItem && mediaItem.artists} {#if mediaItem.type !== 'artist'}
{#if mediaItem.artists === 'Various Artists'} <ArtistList {mediaItem} />
<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} {/if}
</span> </span>
<div class="justify-self-center text-neutral-400">{date ?? ''}</div> <div class="justify-self-center text-neutral-400">{date ?? ''}</div>

View File

@@ -5,8 +5,6 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { queue } from '$lib/stores' import { queue } from '$lib/stores'
let queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly
let image: HTMLImageElement, captionText: HTMLDivElement let image: HTMLImageElement, captionText: HTMLDivElement
async function setQueueItems(mediaItem: Album | Playlist) { async function setQueueItems(mediaItem: Album | Playlist) {
@@ -15,7 +13,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(items) $queue.setQueue(items)
} }
</script> </script>
@@ -41,7 +39,7 @@
on:click={() => { on:click={() => {
switch (mediaItem.type) { switch (mediaItem.type) {
case 'song': case 'song':
queueRef.setQueue([mediaItem]) $queue.setQueue([mediaItem])
break break
case 'album': case 'album':
case 'playlist': case 'playlist':

View File

@@ -6,6 +6,9 @@
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' import LazyImage from './lazyImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from './artistList.svelte'
$: currentlyPlaying = $queue.current $: currentlyPlaying = $queue.current
@@ -31,17 +34,29 @@
return hours > 0 ? `${hours}:`.concat(durationString) : durationString return hours > 0 ? `${hours}:`.concat(durationString) : durationString
} }
$: if (currentlyPlaying) updateMediaSession(currentlyPlaying) $: updateMediaSession(currentlyPlaying)
function updateMediaSession(media: Song) { function updateMediaSession(media: Song | null) {
if ('mediaSession' in navigator) { if (!('mediaSession' in navigator)) return
if (!media) {
navigator.mediaSession.metadata = null
return
}
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: media.name, title: media.name,
artist: media.artists?.map((artist) => artist.name).join(', ') || media.uploader?.name, artist: media.artists?.map((artist) => artist.name).join(', ') || media.uploader?.name,
album: media.album?.name, album: media.album?.name,
artwork: [{ src: `/api/remoteImage?url=${media.thumbnailUrl}`, sizes: '256x256', type: 'image/png' }], artwork: [
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512', type: 'image/png' },
],
}) })
} }
}
onMount(() => { onMount(() => {
const storedVolume = Number(localStorage.getItem('volume')) const storedVolume = Number(localStorage.getItem('volume'))
@@ -80,53 +95,51 @@
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime }) $: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration) $: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
let slidingText: HTMLElement
let slidingTextWidth: number, slidingTextWrapperWidth: number
let scrollDirection: 1 | -1 = 1
$: scrollDistance = slidingTextWidth - slidingTextWrapperWidth
$: if (slidingText && scrollDistance > 0) slidingText.style.animationDuration = `${scrollDistance / 50}s`
let audioElement: HTMLAudioElement let audioElement: HTMLAudioElement
</script> </script>
{#if currentlyPlaying} {#if currentlyPlaying}
<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"> <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="flex h-20 w-full gap-10 pr-8">
<section class="flex gap-3"> <section class="flex w-80 gap-3">
<div class="relative h-full w-20 min-w-20"> <div class="relative h-full w-20 min-w-20">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} loadingMethod={'eager'} /> <LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} />
</div>
<section class="flex flex-grow flex-col justify-center gap-1">
<div class="h-6">
<ScrollingText>
<div slot="text" class="line-clamp-1 font-medium">{currentlyPlaying.name}</div>
</ScrollingText>
</div>
<div class="line-clamp-1 text-xs font-extralight">
<ArtistList mediaItem={currentlyPlaying} />
</div> </div>
<section class="flex flex-col justify-center gap-1">
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div>
</section> </section>
</section> </section>
<section class="flex min-w-max flex-col items-center justify-center gap-1"> <section class="flex flex-grow items-center gap-1 py-4">
<div class="flex items-center gap-3 text-lg"> <IconButton on:click={() => $queue.previous()}>
<button on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())} class="aspect-square h-8"> <i slot="icon" class="fa-solid fa-backward-step text-xl" />
<i class="fa-solid {shuffled ? 'fa-shuffle' : 'fa-right-left'}" /> </IconButton>
</button> <div class="aspect-square h-full rounded-full border border-neutral-700">
<button class="aspect-square h-8" on:click={() => $queue.previous()}> <IconButton on:click={() => (paused = !paused)}>
<i class="fa-solid fa-backward-step" /> <div slot="icon">
</button>
<button on:click={() => (paused = !paused)} class="relative grid aspect-square h-8 place-items-center rounded-full bg-white text-black">
{#if waiting} {#if waiting}
<Loader size={1} /> <Loader size={1.5} />
{:else} {:else}
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" /> <i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
{/if} {/if}
</button>
<button class="aspect-square h-8" on:click={() => $queue.next()}>
<i class="fa-solid fa-forward-step" />
</button>
<button on:click={() => (loop = !loop)} class="aspect-square h-8">
<i class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</button>
</div> </div>
<div class="flex items-center justify-items-center gap-2"> </IconButton>
</div>
<IconButton on:click={() => $queue.clear()}>
<i slot="icon" class="fa-solid fa-stop text-xl" />
</IconButton>
<IconButton on:click={() => $queue.next()}>
<i slot="icon" class="fa-solid fa-forward-step text-xl" />
</IconButton>
<div class="flex flex-grow items-center justify-items-center gap-3 font-light">
<span bind:this={currentTimeTimestamp} class="w-16 text-right" /> <span bind:this={currentTimeTimestamp} class="w-16 text-right" />
<div class="w-72">
<Slider <Slider
bind:this={progressBar} bind:this={progressBar}
max={duration} max={duration}
@@ -139,16 +152,11 @@
seeking = false seeking = false
}} }}
/> />
</div>
<span bind:this={durationTimestamp} class="w-16 text-left" /> <span bind:this={durationTimestamp} class="w-16 text-left" />
</div> </div>
</section> </section>
<section class="flex items-center justify-end gap-2 pr-2 text-lg"> <section class="flex items-center justify-end gap-2.5 py-6 text-lg">
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2"> <div id="volume-slider" class="mx-4 flex h-10 w-44 flex-row-reverse items-center gap-3">
<button on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))} class="aspect-square h-8">
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
</button>
<div id="slider-wrapper" class="w-24 transition-all duration-500">
<Slider <Slider
bind:value={volume} bind:value={volume}
max={maxVolume} max={maxVolume}
@@ -156,24 +164,29 @@
if (volume > 0) localStorage.setItem('volume', volume.toString()) if (volume > 0) localStorage.setItem('volume', volume.toString())
}} }}
/> />
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</IconButton>
</div> </div>
</div> <IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<button class="aspect-square h-8" on:click={() => (expanded = true)}> <i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
<i class="fa-solid fa-expand" /> </IconButton>
</button> <IconButton on:click={() => (loop = !loop)}>
<button class="aspect-square h-8" on:click={() => $queue.clear()}> <i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
<i class="fa-solid fa-xmark" /> </IconButton>
</button> <IconButton on:click={() => (expanded = true)}>
<i slot="icon" class="fa-solid fa-chevron-up" />
</IconButton>
</section> </section>
</main> </main>
{:else} {:else}
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="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">
<img class="absolute -z-10 h-full w-full object-cover object-center blur-xl brightness-[25%]" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" /> <div class="absolute -z-10 h-full w-full blur-xl brightness-[25%]">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={''} objectFit={'cover'} />
</div>
<section id="song-queue-wrapper" class="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} <LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} />
<img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
{/key}
</section> </section>
<section class="no-scrollbar flex max-h-full flex-col gap-3 overflow-y-scroll"> <section class="no-scrollbar flex max-h-full flex-col gap-3 overflow-y-scroll">
<strong class="ml-2 text-2xl">UP NEXT</strong> <strong class="ml-2 text-2xl">UP NEXT</strong>
@@ -181,13 +194,15 @@
{@const isCurrent = item === currentlyPlaying} {@const isCurrent = item === currentlyPlaying}
<button <button
on:click={() => { on:click={() => {
if (!isCurrent) $queue.current = item if (!isCurrent) $queue.setCurrent(item)
}} }}
class="queue-item h-20 w-full shrink-0 items-center gap-3 overflow-clip rounded-lg bg-neutral-900 {isCurrent class="queue-item h-20 w-full shrink-0 items-center gap-3 overflow-clip rounded-lg bg-neutral-900 {isCurrent
? 'pointer-events-none border-[1px] border-neutral-300' ? 'pointer-events-none border-[1px] border-neutral-300'
: 'hover:bg-neutral-800'}" : 'hover:bg-neutral-800'}"
> >
<div class="h-20 w-20 bg-cover bg-center" style="background-image: url('/api/remoteImage?url={item.thumbnailUrl}');" /> <div class="h-20 w-20">
<LazyImage thumbnailUrl={item.thumbnailUrl} alt={`${item.name} jacket`} objectFit={'cover'} />
</div>
<div class="justify-items-left text-left"> <div class="justify-items-left text-left">
<div class="line-clamp-1">{item.name}</div> <div class="line-clamp-1">{item.name}</div>
<div class="mt-[.15rem] line-clamp-1 text-neutral-400">{item.artists?.map((artist) => artist.name).join(', ') || item.uploader?.name}</div> <div class="mt-[.15rem] line-clamp-1 text-neutral-400">{item.artists?.map((artist) => artist.name).join(', ') || item.uploader?.name}</div>
@@ -198,7 +213,7 @@
</section> </section>
</section> </section>
<section class="px-8"> <section class="px-8">
<div id="progress-bar-expanded" class="mb-8"> <div id="progress-bar-expanded" class="mb-7">
<span bind:this={expandedCurrentTimeTimestamp} class="text-right" /> <span bind:this={expandedCurrentTimeTimestamp} class="text-right" />
<Slider <Slider
bind:this={expandedProgressBar} bind:this={expandedProgressBar}
@@ -215,46 +230,30 @@
<span bind:this={expandedDurationTimestamp} class="text-left" /> <span bind:this={expandedDurationTimestamp} class="text-left" />
</div> </div>
<div id="expanded-controls"> <div id="expanded-controls">
<div class="flex flex-col gap-2 overflow-hidden"> <div class="flex flex-col gap-1.5 overflow-hidden">
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-9 w-full"> <div class="h-9">
<strong <ScrollingText>
bind:this={slidingText} <strong slot="text" class="text-3xl">{currentlyPlaying.name}</strong>
bind:clientWidth={slidingTextWidth} </ScrollingText>
on:animationend={() => (scrollDirection *= -1)} </div>
id="scrollingText" {#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader}
class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} absolute whitespace-nowrap text-3xl">{currentlyPlaying.name}</strong <div class="line-clamp-1 flex flex-nowrap items-center font-extralight">
> <i class="fa-solid fa-user mr-3 text-sm" />
<ArtistList mediaItem={currentlyPlaying} />
</div> </div>
<div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;">
{#if currentlyPlaying.artists && currentlyPlaying.artists.length > 0}
{#each currentlyPlaying.artists as artist, index}
<a
on:click={() => (expanded = false)}
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
href="/details/artist?id={artist.id}&connection={currentlyPlaying.connection.id}">{artist.name}</a
>
{#if index < currentlyPlaying.artists.length - 1}
<span class="mr-1 text-lg">,</span>
{/if}
{/each}
{:else if currentlyPlaying.uploader}
<a
on:click={() => (expanded = false)}
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
href="/details/user?id={currentlyPlaying.uploader.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.uploader.name}</a
>
{/if} {/if}
{#if currentlyPlaying.album} {#if currentlyPlaying.album}
<span class="mx-1.5 text-lg">-</span> <div class="flex flex-nowrap items-center font-extralight">
<i class="fa-solid fa-compact-disc mr-3 text-sm" />
<a <a
on:click={() => (expanded = false)} on:click={() => (expanded = false)}
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline" class="line-clamp-1 flex-shrink-0 hover:underline focus:underline"
href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a
> >
</div>
{/if} {/if}
</div> </div>
</div> <div class="flex h-min w-full items-center justify-center gap-2 text-2xl">
<div class="flex w-full items-center justify-center gap-2 text-2xl">
<button on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())} class="aspect-square h-16"> <button on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())} class="aspect-square h-16">
<i class="fa-solid {shuffled ? 'fa-shuffle' : 'fa-right-left'}" /> <i class="fa-solid {shuffled ? 'fa-shuffle' : 'fa-right-left'}" />
</button> </button>
@@ -275,7 +274,7 @@
<i class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" /> <i class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</button> </button>
</div> </div>
<section class="flex items-center justify-end gap-2 text-xl"> <section class="flex h-min items-center justify-end gap-2 text-xl">
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2"> <div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
<button on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))} class="aspect-square h-8"> <button on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))} class="aspect-square h-8">
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" /> <i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
@@ -347,41 +346,4 @@
gap: 1rem; gap: 1rem;
grid-template-columns: 1fr min-content 1fr !important; grid-template-columns: 1fr min-content 1fr !important;
} }
#scrollingText {
animation-timing-function: linear;
animation-fill-mode: both;
animation-delay: 10s;
}
#scrollingText:hover {
animation-play-state: paused;
}
.scrollLeft {
animation-name: scrollLeft;
}
.scrollRight {
animation-name: scrollRight;
}
@keyframes scrollLeft {
0% {
left: 0%;
transform: translateX(0%);
}
100% {
left: 100%;
transform: translateX(-100%);
}
}
@keyframes scrollRight {
0% {
left: 100%;
transform: translateX(-100%);
}
100% {
left: 0%;
transform: translateX(0%);
}
}
</style> </style>

View File

@@ -0,0 +1,69 @@
<!--
@component
A component that can be injected with a text to display it in a single line that clips if overflowing and intermittently scrolls
from one end to the other. The scrolling text area is set to take up the maximum width and height that it can. Constrain the available
scrolling area with a wrapper element.
```tsx
<slot name="text" /> // An HTML element to wrap and style the text you want to scroll (e.g. div, spans, strongs)
```
-->
<script lang="ts">
let slidingText: HTMLElement
let slidingTextWidth: number, slidingTextWrapperWidth: number
let scrollDirection: 1 | -1 = 1
$: scrollDistance = slidingTextWidth - slidingTextWrapperWidth
$: if (slidingText && scrollDistance > 0) slidingText.style.animationDuration = `${scrollDistance / 40}s`
</script>
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-full w-full overflow-clip">
<span
bind:this={slidingText}
bind:clientWidth={slidingTextWidth}
on:animationend={() => (scrollDirection *= -1)}
id="scrollingText"
class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} absolute whitespace-nowrap"
>
<slot name="text" />
</span>
</div>
<style>
#scrollingText {
animation-timing-function: linear;
animation-fill-mode: both;
animation-delay: 10s;
}
#scrollingText:hover {
animation-play-state: paused;
}
.scrollLeft {
animation-name: scrollLeft;
}
.scrollRight {
animation-name: scrollRight;
}
@keyframes scrollLeft {
0% {
left: 0%;
transform: translateX(0%);
}
100% {
left: 100%;
transform: translateX(-100%);
}
}
@keyframes scrollRight {
0% {
left: 100%;
transform: translateX(-100%);
}
100% {
left: 0%;
transform: translateX(0%);
}
}
</style>

View File

@@ -46,18 +46,6 @@ class Queue {
return this.currentSongs[this.currentPosition] return this.currentSongs[this.currentPosition]
} }
/** Sets the currently playing song to the song provided as long as it is in the current playlist */
set current(newSong: Song | null) {
if (newSong === null) {
this.currentPosition = -1
} else {
const queuePosition = this.currentSongs.findIndex((song) => song === newSong)
if (queuePosition >= 0) this.currentPosition = queuePosition
}
this.updateQueue()
}
get list() { get list() {
return this.currentSongs return this.currentSongs
} }
@@ -66,6 +54,14 @@ class Queue {
return this.shuffled return this.shuffled
} }
/** Sets the currently playing song to the song provided as long as it is in the current playlist */
public setCurrent(newSong: Song) {
const queuePosition = this.currentSongs.findIndex((song) => song === newSong)
if (queuePosition < 0) return
this.currentPosition = queuePosition
this.updateQueue()
}
/** Shuffles all songs in the queue after the currently playing song */ /** Shuffles all songs in the queue after the currently playing song */
public shuffle() { public shuffle() {
const shuffledSongs = fisherYatesShuffle(this.currentSongs.slice(this.currentPosition + 1)) const shuffledSongs = fisherYatesShuffle(this.currentSongs.slice(this.currentPosition + 1))

View File

@@ -2,7 +2,8 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { itemDisplayState } from '$lib/stores' import { itemDisplayState } from '$lib/stores'
import type { LayoutData } from './$types.js' import type { LayoutData } from './$types.js'
import { fly, fade } from 'svelte/transition' import { fade } from 'svelte/transition'
import IconButton from '$lib/components/util/iconButton.svelte'
export let data: LayoutData export let data: LayoutData
@@ -17,13 +18,13 @@
<button disabled={/^\/library\/artists.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/artists')}>Artists</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> <button disabled={/^\/library\/collection.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/collection')}>My Collection</button>
</section> </section>
<section class="h-full justify-self-end"> <section class="flex h-full justify-self-end">
<button disabled={$itemDisplayState === 'list'} class="view-toggle aspect-square h-full" on:click={() => ($itemDisplayState = 'list')}> <IconButton disabled={$itemDisplayState === 'list'} on:click={() => ($itemDisplayState = 'list')}>
<i class="fa-solid fa-list" /> <i slot="icon" class="fa-solid fa-list {$itemDisplayState === 'list' ? 'text-lazuli-primary' : 'text-white'}" />
</button> </IconButton>
<button disabled={$itemDisplayState === 'grid'} class="view-toggle aspect-square h-full" on:click={() => ($itemDisplayState = 'grid')}> <IconButton disabled={$itemDisplayState === 'grid'} on:click={() => ($itemDisplayState = 'grid')}>
<i class="fa-solid fa-grip" /> <i slot="icon" class="fa-solid fa-grip {$itemDisplayState === 'grid' ? 'text-lazuli-primary' : 'text-white'}" />
</button> </IconButton>
</section> </section>
</nav> </nav>
{#key currentPathname} {#key currentPathname}
@@ -34,9 +35,6 @@
</main> </main>
<style> <style>
button.view-toggle[disabled] {
color: var(--lazuli-primary);
}
button.library-tab[disabled] { button.library-tab[disabled] {
color: var(--lazuli-primary); color: var(--lazuli-primary);
border-top: 2px solid var(--lazuli-primary); border-top: 2px solid var(--lazuli-primary);

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import LazyImage from '$lib/components/media/lazyImage.svelte' import LazyImage from '$lib/components/media/lazyImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import ArtistList from '$lib/components/media/artistList.svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores' import { queue, newestAlert } from '$lib/stores'
@@ -26,7 +27,7 @@
<div class="p-3"> <div class="p-3">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded-lg"> <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}`)}> <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`} /> <LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} />
</button> </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"> <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}> <IconButton halo={true} on:click={playAlbum}>
@@ -36,17 +37,8 @@
</div> </div>
<div class="py-2 text-center text-sm"> <div class="py-2 text-center text-sm">
<div class="line-clamp-2">{album.name}</div> <div class="line-clamp-2">{album.name}</div>
<div class="line-clamp-2 flex justify-center text-neutral-400"> <div class="line-clamp-2 text-neutral-400">
{#if album.artists === 'Various Artists'} <ArtistList mediaItem={album} />
<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> </div>
</div> </div>