de5320a7b4
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.
160 lines
5.1 KiB
Svelte
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}
|