Files
minstrel/web/src/lib/components/PlayerBar.svelte
T
bvandeusen de5320a7b4 feat(web): wire LikeButton into TrackRow, AlbumCard, ArtistRow, PlayerBar
Heart appears on every entity rendering surface. ArtistRow restructured
to a <div> with the link as a positioned overlay so the heart button
can nest without invalid <button>-in-<a> markup. Shell nav grows a
'Liked' entry pointing at /library/liked.
2026-04-26 16:56:07 -04:00

160 lines
5.1 KiB
Svelte

<script lang="ts">
import {
player,
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat, playQueue
} from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER } from '$lib/media/covers';
import LikeButton from './LikeButton.svelte';
const current = $derived(player.current);
const skipPrevDisabled = $derived(player.index === 0 && player.position < 3);
const skipNextDisabled = $derived(
player.index === player.queue.length - 1 && player.repeat === 'off'
);
const repeatLabel = $derived(
player.repeat === 'off' ? 'Repeat off' :
player.repeat === 'all' ? 'Repeat all' : 'Repeat one'
);
function onSeekInput(e: Event) {
const v = Number((e.currentTarget as HTMLInputElement).value);
seekTo(v);
}
function onVolumeInput(e: Event) {
const v = Number((e.currentTarget as HTMLInputElement).value);
setVolume(v);
}
function onCoverError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
</script>
{#if current}
<div class="flex items-center gap-4 border-t border-border bg-surface px-4 py-3">
<!-- Left: cover + title + artist -->
<div class="flex w-60 min-w-0 items-center gap-3">
<a
href={`/albums/${current.album_id}`}
aria-label="Open album"
class="shrink-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<img
src={`/api/albums/${current.album_id}/cover`}
alt=""
class="h-12 w-12 rounded object-cover"
onerror={onCoverError}
/>
</a>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{current.title}</div>
<a
href={`/artists/${current.artist_id}`}
class="truncate text-xs text-text-secondary hover:text-text-primary"
>
{current.artist_name}
</a>
</div>
<LikeButton entityType="track" entityId={current.id} />
</div>
<!-- Center: seek row + transport row -->
{#if player.state === 'error'}
<div role="alert" class="flex flex-1 items-center justify-center gap-3 text-sm text-text-primary">
<span>{player.error ?? 'Playback failed.'}</span>
<button
type="button"
class="rounded bg-accent px-3 py-1 text-background"
onclick={() => playQueue(player.queue, player.index)}
>
Try again
</button>
</div>
{:else}
<div class="flex flex-1 flex-col items-stretch gap-2">
<div class="flex items-center gap-3">
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
{formatDuration(player.position)}
</span>
<input
type="range"
aria-label="Seek"
min="0"
max={player.duration || 0}
step="0.1"
value={player.position}
oninput={onSeekInput}
class="flex-1 accent-accent"
/>
<span class="w-12 shrink-0 text-xs tabular-nums text-text-secondary">
{formatDuration(player.duration)}
</span>
</div>
<div class="flex items-center justify-center gap-4">
<button
type="button"
aria-label="Previous"
disabled={skipPrevDisabled}
onclick={skipPrev}
class="rounded p-1 disabled:opacity-40"
></button>
<button
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="rounded px-3 py-1 bg-surface-hover"
>
{#if player.state === 'loading' || player.state === 'idle'}
<span data-testid="play-spinner" class="inline-block animate-spin"></span>
{:else if player.isPlaying}
{:else}
{/if}
</button>
<button
type="button"
aria-label="Next"
disabled={skipNextDisabled}
onclick={skipNext}
class="rounded p-1 disabled:opacity-40"
></button>
</div>
</div>
{/if}
<!-- Right: shuffle + repeat + volume -->
<div class="flex w-56 items-center justify-end gap-3">
<button
type="button"
aria-label="Shuffle"
aria-pressed={player.shuffle}
onclick={toggleShuffle}
class="rounded p-1 {player.shuffle ? 'text-accent' : 'text-text-secondary'}"
>🔀</button>
<button
type="button"
aria-label={repeatLabel}
onclick={cycleRepeat}
class="rounded p-1 {player.repeat !== 'off' ? 'text-accent' : 'text-text-secondary'}"
>🔁{player.repeat === 'one' ? '¹' : ''}</button>
<label class="flex items-center gap-2">
<span class="sr-only">Volume</span>
<input
type="range"
aria-label="Volume"
min="0"
max="1"
step="0.01"
value={player.volume}
oninput={onVolumeInput}
class="w-28 accent-accent"
/>
</label>
</div>
</div>
{/if}