feat(frontend): rework audio player and shared UI components

Expand the player store, split player controls (transport, seek, volume, track info), add Cover/PlaylistArt/NowPlaying UI primitives and a cover image proxy route.
This commit is contained in:
2026-07-14 04:54:51 +02:00
parent 8c87d84f80
commit 6cc41d1d6f
10 changed files with 430 additions and 166 deletions
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { gradientForHue } from '$lib/theme/color';
interface Props {
trackId: string | number;
hue: number;
size?: 'small' | 'medium' | 'large';
alt?: string;
class?: string;
children?: Snippet;
}
let { trackId, hue, size = 'medium', alt = '', class: klass = '', children }: Props = $props();
let failed = $state(false);
let loaded = $state(false);
</script>
<div
class="relative overflow-hidden {klass}"
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
>
{#if !failed}
<img
src="/api/tracks/{trackId}/cover?size={size}"
{alt}
loading="lazy"
onload={() => (loaded = true)}
onerror={() => (failed = true)}
class="h-full w-full object-cover"
/>
{/if}
{@render children?.()}
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
interface Props {
paused?: boolean;
}
let { paused = false }: Props = $props();
const bars = [
{ delay: 0, dur: 0.9 },
{ delay: 0.25, dur: 0.7 },
{ delay: 0.1, dur: 1.05 },
{ delay: 0.4, dur: 0.8 }
];
</script>
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-black/45 pb-2">
{#each bars as bar, i (i)}
<span
class="w-[3px] origin-bottom rounded-full bg-white"
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { gradientForHue, HUES } from '$lib/theme/color';
interface Props {
trackIds: (string | number)[];
hue: number;
class?: string;
}
let { trackIds, hue, class: klass = '' }: Props = $props();
function hueFor(id: string | number) {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
</script>
{#if mosaic}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each trackIds.slice(0, 4) as id (id)}
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
{/each}
</div>
{:else if single !== null}
<Cover trackId={single} hue={hueFor(single)} size="medium" class={klass} />
{:else}
<div class={klass} style="background:{gradientForHue(hue)}"></div>
{/if}