UI changes (now responsive) && fixed YT recommendations method

This commit is contained in:
Eclypsed
2024-07-18 22:52:08 -04:00
parent 8453e51d3f
commit f10a184284
25 changed files with 1602 additions and 605 deletions

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import LazyImage from '$lib/components/media/lazyImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import ArtistList from '$lib/components/media/artistList.svelte'
import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
export let album: Album
async function playAlbum() {
const itemsResponse = await fetch(`/api/connections/${album.connection.id}/album/${album.id}/items`, {
credentials: 'include',
}).catch(() => null)
if (!itemsResponse || !itemsResponse.ok) {
$newestAlert = ['warning', 'Failed to play album']
return
}
const data = (await itemsResponse.json()) as { items: Song[] }
$queue.setQueue(data.items)
}
</script>
<div class="overflow-hidden">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
<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`} objectFit={'cover'} />
</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 transition-opacity">
<IconButton halo={true} on:click={playAlbum}>
<i slot="icon" class="fa-solid fa-play text-2xl" />
</IconButton>
</div>
<div id="connection-type-icon" class="absolute left-2 top-2 h-9 w-9 opacity-0 transition-opacity">
<ServiceLogo type={album.connection.type} />
</div>
</div>
<div class="py-2 text-center text-sm">
<div class="line-clamp-2">{album.name}</div>
<div class="line-clamp-2 text-neutral-400">
<ArtistList mediaItem={album} />
</div>
</div>
</div>
<style>
#thumbnail-wrapper:hover > #thumbnail {
filter: brightness(35%);
}
#thumbnail-wrapper:hover > #play-button {
opacity: 1;
}
/* #connection-type-icon {
filter: grayscale();
} */
#thumbnail-wrapper:hover > #connection-type-icon {
opacity: 1;
}
#thumbnail {
transition: filter 150ms ease;
}
</style>

View File

@@ -4,7 +4,7 @@
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.
@param linked Boolean. If true, artists will be linked with anchor tags. Defaults to true.
-->
<script lang="ts">

View File

@@ -1,341 +1,191 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade, slide, fly } from 'svelte/transition'
import { queue } from '$lib/stores'
import Services from '$lib/services.json'
// import { FastAverageColor } from 'fast-average-color'
import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from '$lib/components/media/artistList.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.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'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
import { onMount, createEventDispatcher } from 'svelte'
import { slide } from 'svelte/transition'
// NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer.
// That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own
// dedicated sidebar like in spotify.
const dispatch = createEventDispatcher()
$: currentlyPlaying = $queue.current
export let mediaItem: Song,
shuffled: boolean,
mediaSession: MediaSession | null = null
let expanded = false
let loop = false,
favorite = false
let paused = true,
loop = false
const MAX_VOLUME = 0.5
let volume: number, paused: boolean, waiting: boolean
$: shuffled = $queue.isShuffled
onMount(() => {
volume = getStoredVolume()
const maxVolume = 0.5
let volume: number
if (mediaSession) {
mediaSession.setActionHandler('play', () => (paused = false))
mediaSession.setActionHandler('pause', () => (paused = true))
mediaSession.setActionHandler('stop', () => dispatch('stop'))
mediaSession.setActionHandler('nexttrack', () => dispatch('next'))
mediaSession.setActionHandler('previoustrack', () => dispatch('previous'))
}
})
let waiting: boolean
function getStoredVolume(): number {
const storedVolume = Number.parseFloat(localStorage.getItem('volume') ?? '-1')
if (storedVolume >= 0 && storedVolume <= MAX_VOLUME) return storedVolume
const defaultVolume = MAX_VOLUME / 2
localStorage.setItem('volume', defaultVolume.toString())
return defaultVolume
}
$: if (mediaSession) updateMediaSession(mediaItem, mediaSession)
function updateMediaSession(media: Song, mediaSession: MediaSession) {
const mediaImage = (size: number): MediaImage => ({ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=${size}`, sizes: `${size}x${size}` })
const title = media.name
const artist = media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name
const album = media.album?.name
const artwork: MediaImage[] = [mediaImage(96), mediaImage(128), mediaImage(192), mediaImage(256), mediaImage(384), mediaImage(512)]
mediaSession.metadata = new MediaMetadata({ title, artist, album, artwork })
}
$: paused && mediaSession ? (mediaSession.playbackState = 'paused') : mediaSession ? (mediaSession.playbackState = 'playing') : null
function formatTime(seconds: number) {
seconds = Math.round(seconds)
const hours = Math.floor(seconds / 3600)
seconds = seconds - hours * 3600
seconds -= hours * 3600
const minutes = Math.floor(seconds / 60)
seconds = seconds - minutes * 60
seconds -= minutes * 60
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
}
$: updateMediaSession(currentlyPlaying)
function updateMediaSession(media: Song | null) {
if (!('mediaSession' in navigator)) return
let seeking = false,
currentTime: number = 0,
duration: number = 0
if (!media) {
navigator.mediaSession.metadata = null
return
}
let audioElement: HTMLAudioElement, currentTimestamp: string, durationTimestamp: string, progressBarValue: number, progressBar: Slider
navigator.mediaSession.metadata = new MediaMetadata({
title: media.name,
artist: media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name,
album: media.album?.name,
artwork: [
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512' },
],
})
}
onMount(() => {
const storedVolume = Number(localStorage.getItem('volume'))
if (storedVolume >= 0 && storedVolume <= maxVolume) {
volume = storedVolume
} else {
localStorage.setItem('volume', (maxVolume / 2).toString())
volume = maxVolume / 2
}
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => (paused = false))
navigator.mediaSession.setActionHandler('pause', () => (paused = true))
navigator.mediaSession.setActionHandler('stop', () => $queue.clear())
navigator.mediaSession.setActionHandler('nexttrack', () => $queue.next())
navigator.mediaSession.setActionHandler('previoustrack', () => $queue.previous())
}
})
let currentTime: number = 0
let duration: number = 0
let currentTimeTimestamp: HTMLSpanElement
let progressBar: Slider
let durationTimestamp: HTMLSpanElement
let expandedCurrentTimeTimestamp: HTMLSpanElement
let expandedProgressBar: Slider
let expandedDurationTimestamp: HTMLSpanElement
let seeking: boolean = false
$: if (!seeking && currentTimeTimestamp) currentTimeTimestamp.innerText = formatTime(currentTime)
$: currentTimestamp = formatTime(seeking ? progressBarValue : currentTime)
$: durationTimestamp = formatTime(duration)
$: if (!seeking && progressBar) progressBar.$set({ value: currentTime })
$: if (!seeking && durationTimestamp) durationTimestamp.innerText = formatTime(duration)
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
let audioElement: HTMLAudioElement
let playerWidth: number
</script>
{#if currentlyPlaying}
<div
id="player-wrapper"
transition:slide
class="{expanded ? 'h-full w-full' : 'm-3 h-20 w-[calc(100%_-_24px)] rounded-xl'} absolute bottom-0 z-40 overflow-clip bg-neutral-925 transition-all ease-in-out"
style="transition-duration: 400ms;"
>
{#if !expanded}
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10">
<section class="flex w-96 min-w-64 gap-3">
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl">
<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>
</section>
</section>
<section class="flex flex-grow items-center gap-1 py-4">
<IconButton on:click={() => $queue.previous()}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</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 min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
<span bind:this={currentTimeTimestamp} class="w-16 text-right" />
<Slider
bind:this={progressBar}
max={duration}
on:seeking={(event) => {
currentTimeTimestamp.innerText = formatTime(event.detail.value)
seeking = true
}}
on:seeked={(event) => {
currentTime = event.detail.value
seeking = false
}}
/>
<span bind:this={durationTimestamp} class="w-16 text-left" />
</div>
</section>
<section class="flex items-center justify-end gap-2.5 py-6 pr-8 text-lg">
<div class="mx-4 flex h-10 w-40 items-center gap-3">
<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>
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => (loop = !loop)}>
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => (expanded = true)}>
<i slot="icon" class="fa-solid fa-chevron-up" />
</IconButton>
</section>
</main>
{:else}
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full">
<div class="absolute -z-10 h-full w-full blur-2xl brightness-[25%]">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={''} objectFit={'cover'} />
</div>
<section class="relative grid h-full grid-rows-[1fr_4fr] gap-4 px-24 py-16">
<div class="grid grid-cols-[2fr_1fr]">
<div class="flex h-14 flex-row items-center gap-5">
<ServiceLogo type={currentlyPlaying.connection.type} />
<div>
<h1 class="text-neutral-400">STREAMING FROM</h1>
<strong class="text-2xl text-neutral-300">{Services[currentlyPlaying.connection.type].displayName}</strong>
</div>
</div>
<section>
{#if $queue.upNext}
{@const next = $queue.upNext}
<strong transition:fade class="ml-2 text-2xl">UP NEXT</strong>
<div transition:fly={{ x: 300 }} class="mt-3 flex h-20 w-full items-center gap-3 rounded-lg border border-neutral-300 bg-neutral-900 pr-3">
<div class="aspect-square h-full">
<LazyImage thumbnailUrl={next.thumbnailUrl} alt={`${next.name} jacket`} objectFit={'cover'} />
</div>
<div>
<div class="mb-0.5 line-clamp-1 font-medium">{next.name}</div>
<div class="line-clamp-1 text-sm font-light text-neutral-300">
<ArtistList mediaItem={next} linked={false} />
</div>
</div>
</div>
{/if}
</section>
</div>
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} />
</section>
<section class="self-center px-16">
<div class="mb-7 flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
<span bind:this={expandedCurrentTimeTimestamp} />
<Slider
bind:this={expandedProgressBar}
max={duration}
on:seeking={(event) => {
expandedCurrentTimeTimestamp.innerText = formatTime(event.detail.value)
seeking = true
}}
on:seeked={(event) => {
currentTime = event.detail.value
seeking = false
}}
/>
<span bind:this={expandedDurationTimestamp} />
</div>
<div id="expanded-controls">
<div class="flex min-w-56 flex-col gap-1.5 overflow-hidden">
<div class="h-10">
<ScrollingText>
<strong slot="text" class="text-4xl">{currentlyPlaying.name}</strong>
</ScrollingText>
</div>
<div class="flex gap-3 text-lg font-medium text-neutral-300">
{#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader}
<ArtistList mediaItem={currentlyPlaying} />
{/if}
{#if currentlyPlaying.album}
<strong>&bullet;</strong>
<a
on:click={() => (expanded = false)}
class="line-clamp-1 hover:underline focus:underline"
href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a
>
{/if}
</div>
</div>
<div class="flex h-16 w-full items-center justify-center gap-2 text-2xl">
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => $queue.previous()}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="relative aspect-square h-full rounded-full bg-white text-black">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</div>
<IconButton on:click={() => $queue.clear()}>
<i slot="icon" class="fa-solid fa-stop" />
</IconButton>
<IconButton on:click={() => $queue.next()}>
<i slot="icon" class="fa-solid fa-forward-step" />
</IconButton>
<IconButton on:click={() => (loop = !loop)}>
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
</div>
<section class="flex h-min items-center justify-end gap-2 text-xl">
<div class="mx-4 flex h-10 w-40 items-center gap-3">
<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>
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
<IconButton on:click={() => (expanded = false)}>
<i slot="icon" class="fa-solid fa-chevron-down" />
</IconButton>
</section>
</div>
</section>
</main>
{/if}
<audio
bind:this={audioElement}
autoplay
bind:paused
bind:volume
bind:currentTime
bind:duration
on:canplay={() => (waiting = false)}
on:loadstart={() => (waiting = true)}
on:waiting={() => (waiting = true)}
on:ended={() => $queue.next()}
on:error={() => setTimeout(() => audioElement.load(), 5000)}
src="/api/v1/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
{loop}
/>
<div
bind:clientWidth={playerWidth}
transition:slide
id="player"
class="fixed {playerWidth > 800 ? 'bottom-0 left-0 right-0' : 'bottom-3 left-3 right-3 rounded-lg'} flex h-20 items-center gap-4 overflow-clip bg-neutral-925 px-2 text-neutral-400 transition-all"
>
<div id="details" class="flex h-full items-center gap-3 overflow-clip py-2" style="backgrounds: linear-gradient(to right, red, blue); flex-grow: 1; flex-basis: 18rem">
<div class="relative aspect-square h-full">
<img src="/api/remoteImage?url={mediaItem.thumbnailUrl}&maxHeight=96" alt="jacket" class="h-full w-full rounded object-cover" />
<div id="jacket-play-button" class:hidden={playerWidth > 650} class="absolute bottom-0 left-0 right-0 top-0 backdrop-brightness-50">
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-2xl text-neutral-200" />
</IconButton>
</div>
</div>
<div class="flex flex-col justify-center gap-1">
<ScrollingText>
<span slot="text" class="line-clamp-1 text-sm font-semibold text-neutral-200">{mediaItem.name}</span>
</ScrollingText>
<div class="line-clamp-1 text-xs">
<ArtistList {mediaItem} />
</div>
</div>
<div class="h-8">
<IconButton color={'#ec4899'} on:click={() => (favorite = !favorite)}>
<i slot="icon" class={favorite ? 'fa-solid fa-heart text-pink-500' : 'fa-regular fa-heart'} />
</IconButton>
</div>
<span class:hidden={playerWidth > 700 || playerWidth < 400} class="ml-auto whitespace-nowrap text-xs">{currentTimestamp} / {durationTimestamp}</span>
</div>
{/if}
<style>
#player-wrapper {
filter: drop-shadow(0px 20px 20px #000000);
}
#expanded-player {
display: grid;
grid-template-rows: 4fr 1fr;
}
#expanded-controls {
display: grid;
gap: 3rem;
align-items: center;
grid-template-columns: 1fr min-content 1fr !important;
}
</style>
{#if playerWidth > 700}
<!-- Change the flex-grow value to adjust the difference in rate of expansion between the details and controls -->
<div id="controls" class="flex h-full items-center gap-1 py-4 pr-8 text-neutral-200" style="backgrounds: linear-gradient(to right, green, yellow); flex-grow: 10;">
<IconButton on:click={() => dispatch('previous')}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</div>
<IconButton on:click={() => dispatch('stop')}>
<i slot="icon" class="fa-solid fa-stop text-xl" />
</IconButton>
<IconButton on:click={() => dispatch('next')}>
<i slot="icon" class="fa-solid fa-forward-step text-xl" />
</IconButton>
{#if playerWidth > 800}
<div class="flex flex-grow items-center justify-items-center gap-3 text-xs font-light">
<span>{currentTimestamp}</span>
<Slider
bind:this={progressBar}
bind:value={progressBarValue}
max={duration}
on:seeking={() => (seeking = true)}
on:seeked={() => {
currentTime = progressBarValue
seeking = false
}}
/>
<span>{durationTimestamp}</span>
</div>
{:else}
<span class="whitespace-nowrap text-xs font-light text-neutral-400">{currentTimestamp} / {durationTimestamp}</span>
{/if}
</div>
{/if}
{#if playerWidth > 450}
<div id="tools" class="flex h-full justify-end gap-0.5 py-6" style="backgrounds: linear-gradient(to right, purple, orange);">
{#if playerWidth > 1100}
<IconButton on:click={() => dispatch('toggleShuffle')}>
<i slot="icon" class:text-lazuli-primary={shuffled} class="fa-solid fa-shuffle" />
</IconButton>
<IconButton on:click={() => (loop = !loop)}>
<i slot="icon" class:text-lazuli-primary={loop} class="fa-solid fa-repeat" />
</IconButton>
<div class="flex h-full items-center gap-1">
<IconButton on:click={() => (volume = volume > 0 ? 0 : getStoredVolume())}>
<i slot="icon" class="fa-solid {volume > MAX_VOLUME / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</IconButton>
<div class="mr-2 w-20">
<Slider bind:value={volume} max={MAX_VOLUME} on:seeked={() => (volume > 0 ? localStorage.setItem('volume', volume.toString()) : null)} />
</div>
</div>
<IconButton>
<i slot="icon" class="fa-solid fa-up-right-and-down-left-from-center" />
</IconButton>
{/if}
<IconButton>
<i slot="icon" class="fa-solid fa-ellipsis-vertical" />
</IconButton>
</div>
{/if}
<audio
{loop}
autoplay
src="/api/v1/audio?connection={mediaItem.connection.id}&id={mediaItem.id}"
bind:paused
bind:volume
bind:duration
bind:currentTime
bind:this={audioElement}
on:ended={() => dispatch('next')}
on:waiting={() => (waiting = true)}
on:canplay={() => (waiting = false)}
on:loadstart={() => (waiting = true)}
on:error={() => setTimeout(() => audioElement.load(), 5000)}
/>
</div>

View File

@@ -0,0 +1,333 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade, slide, fly } from 'svelte/transition'
import { queue } from '$lib/stores'
import Services from '$lib/services.json'
// 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'
import IconButton from '$lib/components/util/iconButton.svelte'
import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from './artistList.svelte'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
// NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer.
// That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own
// dedicated sidebar like in spotify.
$: currentlyPlaying = $queue.current
let expanded = false
let paused = true,
loop = false
$: shuffled = $queue.isShuffled
const maxVolume = 0.5
let volume: number
let waiting: boolean
function formatTime(seconds: number) {
seconds = Math.round(seconds)
const hours = Math.floor(seconds / 3600)
seconds = seconds - hours * 3600
const minutes = Math.floor(seconds / 60)
seconds = seconds - minutes * 60
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
}
$: updateMediaSession(currentlyPlaying)
function updateMediaSession(media: Song | null) {
if (!('mediaSession' in navigator)) return
if (!media) {
navigator.mediaSession.metadata = null
return
}
navigator.mediaSession.metadata = new MediaMetadata({
title: media.name,
artist: media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name,
album: media.album?.name,
artwork: [
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512' },
],
})
}
onMount(() => {
const storedVolume = Number(localStorage.getItem('volume'))
if (storedVolume >= 0 && storedVolume <= maxVolume) {
volume = storedVolume
} else {
localStorage.setItem('volume', (maxVolume / 2).toString())
volume = maxVolume / 2
}
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => (paused = false))
navigator.mediaSession.setActionHandler('pause', () => (paused = true))
navigator.mediaSession.setActionHandler('stop', () => $queue.clear())
navigator.mediaSession.setActionHandler('nexttrack', () => $queue.next())
navigator.mediaSession.setActionHandler('previoustrack', () => $queue.previous())
}
})
let currentTime: number = 0
let duration: number = 0
let currentTimeTimestamp: HTMLSpanElement
let progressBar: Slider
let durationTimestamp: HTMLSpanElement
let expandedCurrentTimeTimestamp: HTMLSpanElement
let expandedProgressBar: Slider
let expandedDurationTimestamp: HTMLSpanElement
let seeking: boolean = false
$: if (!seeking && currentTimeTimestamp) currentTimeTimestamp.innerText = formatTime(currentTime)
$: if (!seeking && progressBar) progressBar.$set({ value: currentTime })
$: if (!seeking && durationTimestamp) durationTimestamp.innerText = formatTime(duration)
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
let audioElement: HTMLAudioElement
</script>
{#if currentlyPlaying}
<div id="player-wrapper" transition:slide class="{expanded ? 'h-full' : 'h-20'} absolute bottom-0 z-40 w-full overflow-clip bg-neutral-925 transition-all ease-in-out" style="transition-duration: 400ms;">
{#if !expanded}
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10">
<section class="flex w-96 min-w-64 gap-2">
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl p-2">
<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>
</section>
</section>
<section class="flex flex-grow items-center gap-1 py-4">
<IconButton on:click={() => $queue.previous()}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</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 min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
<span bind:this={currentTimeTimestamp} class="w-16 text-right" />
<Slider
bind:this={progressBar}
max={duration}
on:seeking={(event) => {
currentTimeTimestamp.innerText = formatTime(event.detail.value)
seeking = true
}}
on:seeked={(event) => {
currentTime = event.detail.value
seeking = false
}}
/>
<span bind:this={durationTimestamp} class="w-16 text-left" />
</div>
</section>
<section class="flex items-center justify-end gap-2.5 py-6 pr-8 text-lg">
<div class="mx-4 flex h-10 w-40 items-center gap-3">
<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>
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => (loop = !loop)}>
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => (expanded = true)}>
<i slot="icon" class="fa-solid fa-chevron-up" />
</IconButton>
</section>
</main>
{:else}
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full">
<div class="absolute -z-10 h-full w-full blur-2xl brightness-[25%]">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={''} objectFit={'cover'} />
</div>
<section class="relative grid h-full grid-rows-[1fr_4fr] gap-4 px-24 py-16">
<div class="grid grid-cols-[2fr_1fr]">
<div class="flex h-14 flex-row items-center gap-5">
<ServiceLogo type={currentlyPlaying.connection.type} />
<div>
<h1 class="text-neutral-400">STREAMING FROM</h1>
<strong class="text-2xl text-neutral-300">{Services[currentlyPlaying.connection.type].displayName}</strong>
</div>
</div>
<section>
{#if $queue.upNext}
{@const next = $queue.upNext}
<strong transition:fade class="ml-2 text-2xl">UP NEXT</strong>
<div transition:fly={{ x: 300 }} class="mt-3 flex h-20 w-full items-center gap-3 overflow-clip rounded-lg border border-neutral-300 bg-neutral-900 pr-3">
<div class="aspect-square h-full">
<LazyImage thumbnailUrl={next.thumbnailUrl} alt={`${next.name} jacket`} objectFit={'cover'} />
</div>
<div>
<div class="mb-0.5 line-clamp-1 font-medium">{next.name}</div>
<div class="line-clamp-1 text-sm font-light text-neutral-300">
<ArtistList mediaItem={next} linked={false} />
</div>
</div>
</div>
{/if}
</section>
</div>
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} />
</section>
<section class="self-center px-16">
<div class="mb-7 flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
<span bind:this={expandedCurrentTimeTimestamp} />
<Slider
bind:this={expandedProgressBar}
max={duration}
on:seeking={(event) => {
expandedCurrentTimeTimestamp.innerText = formatTime(event.detail.value)
seeking = true
}}
on:seeked={(event) => {
currentTime = event.detail.value
seeking = false
}}
/>
<span bind:this={expandedDurationTimestamp} />
</div>
<div id="expanded-controls">
<div class="flex min-w-56 flex-col gap-1.5 overflow-hidden">
<div class="h-10">
<ScrollingText>
<strong slot="text" class="text-4xl">{currentlyPlaying.name}</strong>
</ScrollingText>
</div>
<div class="flex gap-3 text-lg font-medium text-neutral-300">
{#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader}
<ArtistList mediaItem={currentlyPlaying} />
{/if}
{#if currentlyPlaying.album}
<strong>&bullet;</strong>
<a
on:click={() => (expanded = false)}
class="line-clamp-1 hover:underline focus:underline"
href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a
>
{/if}
</div>
</div>
<div class="flex h-16 w-full items-center justify-center gap-2 text-2xl">
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => $queue.previous()}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="relative aspect-square h-full rounded-full bg-white text-black">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</div>
<IconButton on:click={() => $queue.clear()}>
<i slot="icon" class="fa-solid fa-stop" />
</IconButton>
<IconButton on:click={() => $queue.next()}>
<i slot="icon" class="fa-solid fa-forward-step" />
</IconButton>
<IconButton on:click={() => (loop = !loop)}>
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
</div>
<section class="flex h-min items-center justify-end gap-2 text-xl">
<div class="mx-4 flex h-10 w-40 items-center gap-3">
<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>
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
<IconButton on:click={() => (expanded = false)}>
<i slot="icon" class="fa-solid fa-chevron-down" />
</IconButton>
</section>
</div>
</section>
</main>
{/if}
<audio
bind:this={audioElement}
autoplay
bind:paused
bind:volume
bind:currentTime
bind:duration
on:canplay={() => (waiting = false)}
on:loadstart={() => (waiting = true)}
on:waiting={() => (waiting = true)}
on:ended={() => $queue.next()}
on:error={() => setTimeout(() => audioElement.load(), 5000)}
src="/api/v1/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
{loop}
/>
</div>
{/if}
<style>
#expanded-player {
display: grid;
grid-template-rows: 4fr 1fr;
}
#expanded-controls {
display: grid;
gap: 3rem;
align-items: center;
grid-template-columns: 1fr min-content 1fr !important;
}
</style>