Files
minstrel/web/src/lib/components/PlayerBar.svelte
T
bvandeusen 51afc992d4 fix(web): apply data-testid attrs to PlayerBar that were dropped from a8e6e1c
The previous commit message said I added data-testid="player-bar-compact"
+ "player-bar-desktop" to PlayerBar.svelte but the edits actually
dropped silently — only the test file changes landed. Without the
testids, the test file's within(getByTestId(...)) calls all fail.

This adds the actual attributes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:07:52 -04:00

388 lines
16 KiB
Svelte

<script lang="ts">
import {
SkipBack, SkipForward, Play, Pause, Loader2,
Shuffle, Repeat, Repeat1,
Volume2, Volume1, VolumeX,
ListMusic
} from 'lucide-svelte';
import {
player,
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat, playQueue,
toggleQueueDrawer
} from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.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'
);
const VolumeIcon = $derived(
player.volume === 0 ? VolumeX :
player.volume < 0.5 ? Volume1 : Volume2
);
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}
<!--
COMPACT view (below md). Layout per operator design:
┌─────────┬───────────┬───────────────────┐
│ art │ ♥ ⋮ │ 🔀 🔁 ☰ │ ← short sub-row
│ title │ │ │
│ artist │ ⏮ ⏯ ⏭ │ volume slider │ ← play row at full size
├─────────┴───────────┴───────────────────┤
│ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━━ 3:21 │
└─────────────────────────────────────────┘
Play controls maintain 40/48/40 size; like+kebab and column-3 sub-rows
are shorter so column 2's bottom edge aligns with column 3's bottom.
-->
<div
data-testid="player-bar-compact"
class="md:hidden flex flex-col gap-1.5 border-t border-border bg-surface px-3 py-2"
>
{#if player.state === 'error'}
<div role="alert" class="flex 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.5 text-sm text-text-primary"
onclick={() => playQueue(player.queue, player.index)}
>
Try again
</button>
</div>
{:else}
<!-- Top row: 3 columns -->
<div class="flex items-stretch gap-3">
<!-- Column 1: art + title + artist -->
<a
href={`/albums/${current.album_id}`}
aria-label="Open album"
class="flex min-w-0 items-center gap-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style="flex: 1 1 35%;"
>
<img
src={coverUrl(current.album_id)}
alt=""
class="h-12 w-12 shrink-0 rounded-md object-cover"
onerror={onCoverError}
/>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-text-primary">{current.title}</div>
<div class="truncate text-xs text-text-secondary">{current.artist_name}</div>
</div>
</a>
<!-- Column 2: like+kebab (top), play controls (bottom) -->
<div class="flex flex-col items-center justify-between gap-1 shrink-0">
<div class="flex items-center gap-0.5">
<LikeButton entityType="track" entityId={current.id} />
<TrackMenu track={current} direction="up" hideQueueActions />
</div>
<div class="flex items-center gap-1.5">
<button
type="button"
aria-label="Previous"
disabled={skipPrevDisabled}
onclick={skipPrev}
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<SkipBack size={18} strokeWidth={1.5} fill="currentColor" />
</button>
<button
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.state === 'loading' || player.state === 'idle'}
<Loader2 data-testid="play-spinner" size={22} strokeWidth={1.5} class="animate-spin" />
{:else if player.isPlaying}
<Pause size={22} strokeWidth={1.5} fill="currentColor" />
{:else}
<Play size={22} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
{/if}
</button>
<button
type="button"
aria-label="Next"
disabled={skipNextDisabled}
onclick={skipNext}
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<SkipForward size={18} strokeWidth={1.5} fill="currentColor" />
</button>
</div>
</div>
<!-- Column 3: shuffle/repeat/queue (top), volume slider (bottom) -->
<div class="flex min-w-0 flex-col items-stretch justify-between gap-1" style="flex: 1 1 30%;">
<div class="flex items-center justify-end gap-0.5">
<button
type="button"
aria-label="Shuffle"
aria-pressed={player.shuffle}
onclick={toggleShuffle}
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.shuffle
? 'bg-accent-tint text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<Shuffle size={16} strokeWidth={1.5} />
</button>
<button
type="button"
aria-label={repeatLabel}
onclick={cycleRepeat}
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.repeat !== 'off'
? 'bg-accent-tint text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
{#if player.repeat === 'one'}
<Repeat1 size={16} strokeWidth={1.5} />
{:else}
<Repeat size={16} strokeWidth={1.5} />
{/if}
</button>
<button
type="button"
onclick={() => toggleQueueDrawer()}
aria-label={player.queueDrawerOpen ? 'Close queue' : 'Open queue'}
aria-pressed={player.queueDrawerOpen}
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {player.queueDrawerOpen
? 'bg-accent-tint text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<ListMusic size={16} strokeWidth={1.5} />
</button>
</div>
<label class="flex items-center gap-1">
<span class="sr-only">Volume</span>
<VolumeIcon size={14} strokeWidth={1.5} class="shrink-0 text-text-secondary" />
<input
type="range"
aria-label="Volume"
min="0"
max="1"
step="0.01"
value={player.volume}
oninput={onVolumeInput}
class="min-w-0 flex-1 accent-accent"
/>
</label>
</div>
</div>
<!-- Bottom row: full-width seek slider + time labels -->
<div class="flex items-center gap-2">
<span class="w-9 shrink-0 text-right text-[10px] 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-9 shrink-0 text-[10px] tabular-nums text-text-secondary">
{formatDuration(player.duration)}
</span>
</div>
{/if}
</div>
<!--
DESKTOP view (md+). Original 3-column layout — unchanged except that
the redundant PlayerOverflowMenu kebab is no longer mounted (volume,
shuffle, repeat, queue all live inline in the right cluster).
-->
<div
data-testid="player-bar-desktop"
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
>
<!-- Left: cover + title + artist + like + menu -->
<div class="flex w-72 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={coverUrl(current.album_id)}
alt=""
class="h-24 w-24 rounded-md object-cover"
onerror={onCoverError}
/>
</a>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">{current.title}</div>
<a
href={`/artists/${current.artist_id}`}
class="block truncate text-xs text-text-secondary hover:text-text-primary"
>
{current.artist_name}
</a>
{#if player.queue.length > player.index + 1}
{@const nextTrack = player.queue[player.index + 1]}
<span class="block truncate text-xs text-text-secondary">
Next · {nextTrack.title}{nextTrack.artist_name}
</span>
{/if}
</div>
<LikeButton entityType="track" entityId={current.id} />
<TrackMenu track={current} direction="up" hideQueueActions />
</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.5 text-sm text-text-primary"
onclick={() => playQueue(player.queue, player.index)}
>
Try again
</button>
</div>
{:else}
<div class="flex flex-1 flex-col items-stretch gap-2">
<!-- Transport row -->
<div class="flex items-center justify-center gap-3">
<button
type="button"
aria-label="Previous"
disabled={skipPrevDisabled}
onclick={skipPrev}
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<SkipBack size={20} strokeWidth={1.5} fill="currentColor" />
</button>
<button
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.state === 'loading' || player.state === 'idle'}
<Loader2 size={24} strokeWidth={1.5} class="animate-spin" />
{:else if player.isPlaying}
<Pause size={24} strokeWidth={1.5} fill="currentColor" />
{:else}
<Play size={24} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
{/if}
</button>
<button
type="button"
aria-label="Next"
disabled={skipNextDisabled}
onclick={skipNext}
class="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<SkipForward size={20} strokeWidth={1.5} fill="currentColor" />
</button>
</div>
<!-- Seek row -->
<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>
{/if}
<!-- Right: shuffle + repeat + queue + volume -->
<div class="flex w-60 items-center justify-end gap-2">
<button
type="button"
aria-label="Shuffle"
aria-pressed={player.shuffle}
onclick={toggleShuffle}
class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.shuffle
? 'bg-accent-tint text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<Shuffle size={18} strokeWidth={1.5} />
</button>
<button
type="button"
aria-label={repeatLabel}
onclick={cycleRepeat}
class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.repeat !== 'off'
? 'bg-accent-tint text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
{#if player.repeat === 'one'}
<Repeat1 size={18} strokeWidth={1.5} />
{:else}
<Repeat size={18} strokeWidth={1.5} />
{/if}
</button>
<button
type="button"
onclick={() => toggleQueueDrawer()}
aria-label={player.queueDrawerOpen ? 'Close queue' : 'Open queue'}
aria-pressed={player.queueDrawerOpen}
class="flex h-9 w-9 items-center justify-center rounded-full transition-colors {player.queueDrawerOpen
? 'bg-accent-tint text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'}"
>
<ListMusic size={18} strokeWidth={1.5} />
</button>
<label class="flex items-center gap-1.5">
<span class="sr-only">Volume</span>
<VolumeIcon size={18} strokeWidth={1.5} class="shrink-0 text-text-secondary" />
<input
type="range"
aria-label="Volume"
min="0"
max="1"
step="0.01"
value={player.volume}
oninput={onVolumeInput}
class="w-20 accent-accent"
/>
</label>
</div>
</div>
{/if}