New Miniplaer + ScrollingText && ArtistList components
This commit is contained in:
42
src/lib/components/media/artistList.svelte
Normal file
42
src/lib/components/media/artistList.svelte
Normal 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">,</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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}
|
|
||||||
, 
|
|
||||||
{/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>
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
69
src/lib/components/util/scrollingText.svelte
Normal file
69
src/lib/components/util/scrollingText.svelte
Normal 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>
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
|
||||||
, 
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user