51afc992d4
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>
388 lines
16 KiB
Svelte
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}
|