Files
minstrel/docs/superpowers/plans/2026-04-24-web-ui-player.md
T
bvandeusen 7c19fe8a6a docs(plan): add web UI player implementation plan
8 TDD tasks: player rune store (2 tasks covering basic actions then
shuffle/repeat/audio-reporting), MediaSession glue, click-to-play
wiring in TrackRow + album page, PlayerBar component, Shell 3rd-row
integration, audio element wiring in root layout, and a final
verification + branch-finish step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:57:52 -04:00

1699 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Web UI Player Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship queue-based audio playback in the SPA — click a track on an album, the rest of the album queues, the bottom bar shows progress + controls, MediaSession populates OS media UI, and shuffle/repeat/volume all work.
**Architecture:** A single Svelte 5 rune store in `lib/player/store.svelte.ts` holds all player state. A single `<audio>` element in `+layout.svelte` is driven FROM the store via `$effect` bindings (src, play/pause, volume) and reports INTO the store via DOM event handlers (`timeupdate`, `ended`, `error`, etc.). `PlayerBar.svelte` renders in a new 3rd grid row on `Shell`, mounted only when a track is current.
**Tech Stack:** SvelteKit 2 + Svelte 5 (runes), TypeScript, HTMLAudioElement, MediaSession API, Vitest + jsdom + Testing Library, Tailwind.
**Reference:** design spec at `docs/superpowers/specs/2026-04-24-web-ui-player-design.md`.
---
## File Structure
**New files under `web/`:**
| File | Responsibility |
|---|---|
| `src/lib/player/store.svelte.ts` | All rune state + action functions + `registerAudioEl` helper |
| `src/lib/player/store.test.ts` | Pure state-machine tests |
| `src/lib/player/mediaSession.svelte.ts` | `navigator.mediaSession` glue (metadata, action handlers, positionState, playbackState) |
| `src/lib/player/mediaSession.test.ts` | Spy-based MediaSession tests |
| `src/lib/components/PlayerBar.svelte` | Bottom-bar UI — cover, title, seek row, transport, shuffle/repeat/volume |
| `src/lib/components/PlayerBar.test.ts` | Control wiring, disabled states, loading/error variants |
**Modified files:**
| File | Change |
|---|---|
| `src/lib/components/TrackRow.svelte` | Becomes a `<button>` that calls `playQueue(tracks, index)`; new props `tracks: TrackRef[]`, `index: number` |
| `src/lib/components/TrackRow.test.ts` | Adds click → `playQueue` assertion |
| `src/lib/components/Shell.svelte` | Grid grows a 3rd row (`auto_1fr_auto`); `<PlayerBar>` slot spans both columns, mounted when `player.current` is defined |
| `src/lib/components/Shell.test.ts` | No structural change expected — existing tests still pass |
| `src/routes/+layout.svelte` | Mounts `<audio>`, wires it to the store via `$effect`, calls `useMediaSession()` once |
| `src/routes/albums/[id]/+page.svelte` | Passes `tracks={album.tracks}` and `index={i}` to each `<TrackRow>` |
---
## Task 1: Core player store — state + basic actions
**Files:**
- Create: `web/src/lib/player/store.svelte.ts`
- Create: `web/src/lib/player/store.test.ts`
This task ships the rune state, the read-only `player` accessor, and the user-facing actions that don't need repeat/shuffle or audio-event reporting: `playQueue`, `togglePlay`, linear `skipNext`, `skipPrev` (3-second rule), `seekTo`, `setVolume`, `registerAudioEl`, `reportTimeUpdate`, `reportDuration`, plus the `readStoredVolume` helper. Tasks 2+3 extend this file.
- [ ] **Step 1: Write the failing tests**
Create `web/src/lib/player/store.test.ts`:
```ts
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
player,
playQueue,
togglePlay,
skipNext,
skipPrev,
seekTo,
setVolume,
registerAudioEl,
reportTimeUpdate,
reportDuration,
readStoredVolume
} from './store.svelte';
import type { TrackRef } from '$lib/api/types';
function track(id: string, dur = 180): TrackRef {
return {
id, title: `T ${id}`,
album_id: 'a1', album_title: 'A',
artist_id: 'ar1', artist_name: 'Ar',
track_number: Number(id), disc_number: 1,
duration_sec: dur,
stream_url: `/api/tracks/${id}/stream`
};
}
beforeEach(() => {
localStorage.clear();
// Reset state by starting with an empty queue (playQueue([]) leaves queue=[], index=0).
playQueue([]);
setVolume(1);
registerAudioEl(null);
});
describe('player store — basic actions', () => {
test('playQueue sets queue, index, state="loading", position=0', () => {
playQueue([track('1'), track('2'), track('3')], 1);
expect(player.queue.length).toBe(3);
expect(player.index).toBe(1);
expect(player.state).toBe('loading');
expect(player.position).toBe(0);
expect(player.current?.id).toBe('2');
});
test('playQueue clamps startIndex to [0, length-1]', () => {
playQueue([track('1'), track('2')], 99);
expect(player.index).toBe(1);
playQueue([track('1'), track('2')], -5);
expect(player.index).toBe(0);
});
test('togglePlay flips paused<->playing; no-op on idle', () => {
expect(player.state).toBe('idle');
togglePlay();
expect(player.state).toBe('idle'); // still idle with empty queue
playQueue([track('1')]);
// After playQueue we're in 'loading'; togglePlay should not advance without an audio event.
// But togglePlay from 'loading' should set the intent to play anyway — tested via report later.
// Here, simulate the paused state:
reportTimeUpdate(0);
// manually put us in paused state by calling togglePlay twice isn't clean; set via the state-report API in Task 2.
// Instead, test the simpler case: playQueue then togglePlay = still 'loading' (intent is to play).
togglePlay(); // from 'loading' — inverts intent; store moves to 'paused' intent
expect(player.state).toBe('paused');
togglePlay(); // from 'paused' — moves to 'loading' (re-play intent)
expect(player.state).toBe('loading');
});
test('skipNext mid-queue: index++, position=0', () => {
playQueue([track('1'), track('2'), track('3')], 0);
reportTimeUpdate(50);
skipNext();
expect(player.index).toBe(1);
expect(player.position).toBe(0);
});
test('skipNext at end with no repeat: index stays, state=paused, position=duration', () => {
playQueue([track('1'), track('2')], 1);
reportDuration(180);
skipNext();
expect(player.index).toBe(1);
expect(player.state).toBe('paused');
expect(player.position).toBe(180);
});
test('skipPrev with position<3 AND index>0: decrements index', () => {
playQueue([track('1'), track('2'), track('3')], 2);
reportTimeUpdate(2);
skipPrev();
expect(player.index).toBe(1);
expect(player.position).toBe(0);
});
test('skipPrev with position>=3: seeks to 0, index unchanged', () => {
playQueue([track('1'), track('2')], 1);
reportTimeUpdate(10);
skipPrev();
expect(player.index).toBe(1);
expect(player.position).toBe(0);
});
test('skipPrev at index=0: position=0, index stays', () => {
playQueue([track('1'), track('2')], 0);
reportTimeUpdate(2);
skipPrev();
expect(player.index).toBe(0);
expect(player.position).toBe(0);
});
test('setVolume clamps to [0,1] and persists to localStorage', () => {
setVolume(0.5);
expect(player.volume).toBe(0.5);
expect(localStorage.getItem('minstrel.volume')).toBe('0.5');
setVolume(-1);
expect(player.volume).toBe(0);
setVolume(2);
expect(player.volume).toBe(1);
});
test('readStoredVolume returns 1.0 when missing or invalid', () => {
localStorage.clear();
expect(readStoredVolume()).toBe(1);
localStorage.setItem('minstrel.volume', 'not-a-number');
expect(readStoredVolume()).toBe(1);
localStorage.setItem('minstrel.volume', '-10');
expect(readStoredVolume()).toBe(1); // out of range → fallback
localStorage.setItem('minstrel.volume', '0.7');
expect(readStoredVolume()).toBe(0.7);
});
test('seekTo writes position and, when registered, audioEl.currentTime', () => {
const el = { currentTime: 0 } as unknown as HTMLAudioElement;
registerAudioEl(el);
playQueue([track('1')]);
seekTo(42);
expect(player.position).toBe(42);
expect(el.currentTime).toBe(42);
// After unregister, seekTo still updates store but doesn't throw.
registerAudioEl(null);
seekTo(10);
expect(player.position).toBe(10);
});
test('reportTimeUpdate and reportDuration mutate position and duration', () => {
playQueue([track('1')]);
reportTimeUpdate(15);
reportDuration(240);
expect(player.position).toBe(15);
expect(player.duration).toBe(240);
});
});
```
- [ ] **Step 2: Run tests, confirm they fail**
Run: `cd web && npm test -- src/lib/player/store.test.ts`
Expected: FAIL — module `./store.svelte` does not exist.
- [ ] **Step 3: Implement `web/src/lib/player/store.svelte.ts`**
```ts
import type { TrackRef } from '$lib/api/types';
export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
export type RepeatMode = 'off' | 'all' | 'one';
const VOLUME_KEY = 'minstrel.volume';
export function readStoredVolume(): number {
try {
const raw = localStorage.getItem(VOLUME_KEY);
if (raw == null) return 1;
const n = Number(raw);
if (Number.isFinite(n) && n >= 0 && n <= 1) return n;
return 1;
} catch {
return 1;
}
}
let _queue = $state<TrackRef[]>([]);
let _index = $state(0);
let _state = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume = $state(readStoredVolume());
let _shuffle = $state(false);
let _repeat = $state<RepeatMode>('off');
let _error = $state<string | null>(null);
let _audioEl: HTMLAudioElement | null = null;
export const player = {
get queue() { return _queue; },
get index() { return _index; },
get current() { return _queue[_index] as TrackRef | undefined; },
get state() { return _state; },
get isPlaying() { return _state === 'playing'; },
get position() { return _position; },
get duration() { return _duration; },
get volume() { return _volume; },
get shuffle() { return _shuffle; },
get repeat() { return _repeat; },
get error() { return _error; }
};
export function registerAudioEl(el: HTMLAudioElement | null): void {
_audioEl = el;
}
export function playQueue(tracks: TrackRef[], startIndex = 0): void {
_queue = tracks;
if (tracks.length === 0) {
_index = 0;
_state = 'idle';
} else {
_index = Math.max(0, Math.min(startIndex, tracks.length - 1));
_state = 'loading';
}
_position = 0;
_duration = 0;
_error = null;
}
export function togglePlay(): void {
if (_queue.length === 0) return;
// "Intent to play" (loading + playing) → paused. Anything else → loading (user
// wants to play; audio will catch up).
if (_state === 'playing' || _state === 'loading') {
_state = 'paused';
} else {
_state = 'loading';
}
}
export function skipNext(): void {
if (_queue.length === 0) return;
if (_index + 1 < _queue.length) {
_index++;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
// At end: no-repeat-aware logic yet (Task 2 extends this).
_state = 'paused';
_position = _duration;
}
}
export function skipPrev(): void {
if (_queue.length === 0) return;
if (_position < 3 && _index > 0) {
_index--;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
// Seek to 0 of the current track.
_position = 0;
if (_audioEl) _audioEl.currentTime = 0;
}
}
export function seekTo(sec: number): void {
_position = sec;
if (_audioEl) _audioEl.currentTime = sec;
}
export function setVolume(v: number): void {
const clamped = Math.max(0, Math.min(1, v));
_volume = clamped;
try { localStorage.setItem(VOLUME_KEY, String(clamped)); } catch { /* ignore */ }
}
export function reportTimeUpdate(sec: number): void {
_position = sec;
}
export function reportDuration(sec: number): void {
_duration = Number.isFinite(sec) ? sec : 0;
}
```
Note on `togglePlay`: `playQueue` sets state='loading'. The test then calls `togglePlay` twice: first call (loading → paused), second call (paused → loading). That matches the "intent to play ↔ paused" semantics above.
- [ ] **Step 4: Run tests, confirm they pass**
Run: `cd web && npm test -- src/lib/player/store.test.ts`
Expected: PASS — 11 tests.
- [ ] **Step 5: Type-check**
Run: `cd web && npm run check`
Expected: `0 ERRORS 0 WARNINGS`.
- [ ] **Step 6: Commit**
```bash
git add web/src/lib/player/store.svelte.ts web/src/lib/player/store.test.ts
git commit -m "feat(web): add player rune store with basic actions
Core state (queue, index, state, position, duration, volume, shuffle,
repeat, error) plus actions: playQueue, togglePlay, linear skipNext,
skipPrev 3-second rule, seekTo, setVolume (localStorage-persisted),
registerAudioEl, reportTimeUpdate, reportDuration. Shuffle/repeat
logic and audio-event reporting land in follow-up commits."
```
---
## Task 2: Shuffle, repeat, and audio-event reporting
**Files:**
- Modify: `web/src/lib/player/store.svelte.ts`
- Modify: `web/src/lib/player/store.test.ts`
Adds: `toggleShuffle` (with injectable RNG), `cycleRepeat`, `reportStateFromAudio` (all five event kinds), plus repeat-aware behavior inside the existing `skipNext` and the `ended` branch of `reportStateFromAudio`.
- [ ] **Step 1: Add failing tests**
Append to `web/src/lib/player/store.test.ts`:
```ts
import {
toggleShuffle,
cycleRepeat,
reportStateFromAudio,
__setShuffleRng
} from './store.svelte';
describe('player store — shuffle + repeat + audio reports', () => {
test('cycleRepeat: off → all → one → off', () => {
playQueue([track('1')]);
expect(player.repeat).toBe('off');
cycleRepeat();
expect(player.repeat).toBe('all');
cycleRepeat();
expect(player.repeat).toBe('one');
cycleRepeat();
expect(player.repeat).toBe('off');
});
test('toggleShuffle randomizes queue[index+1..end], leaves current and earlier alone', () => {
// Deterministic RNG: always returns 0 so Fisher-Yates swaps each i with i itself → no change.
// Flip to always 0.999 to force swaps.
const seq = [0.999, 0.999, 0.999, 0.999, 0.999];
let i = 0;
__setShuffleRng(() => seq[i++ % seq.length]);
const tracks = [track('1'), track('2'), track('3'), track('4'), track('5')];
playQueue(tracks, 1);
toggleShuffle();
expect(player.shuffle).toBe(true);
// Index 0 and 1 preserved:
expect(player.queue[0].id).toBe('1');
expect(player.queue[1].id).toBe('2');
// Index 2..4 reordered: because RNG = 0.999 always, Math.floor(rng() * (j+1)) === j for each,
// so swaps are with self — queue is unchanged. So validate that the IDs 3/4/5 are still the
// tail set, in any order, to tolerate future RNG semantics.
const tailIds = [player.queue[2].id, player.queue[3].id, player.queue[4].id].sort();
expect(tailIds).toEqual(['3', '4', '5']);
__setShuffleRng(Math.random);
});
test('skipNext at end with repeat=all: wraps to 0', () => {
playQueue([track('1'), track('2')], 1);
cycleRepeat(); // repeat=all
skipNext();
expect(player.index).toBe(0);
expect(player.state).toBe('loading');
});
test('skipNext at end with repeat=one: still ends (explicit skip overrides)', () => {
playQueue([track('1')], 0);
cycleRepeat(); // all
cycleRepeat(); // one
reportDuration(120);
skipNext();
expect(player.state).toBe('paused');
expect(player.position).toBe(120);
});
test('reportStateFromAudio("ended") with repeat="one": position=0, index unchanged, state=loading', () => {
playQueue([track('1'), track('2')], 0);
reportTimeUpdate(120);
cycleRepeat(); cycleRepeat(); // repeat=one
reportStateFromAudio('ended');
expect(player.index).toBe(0);
expect(player.position).toBe(0);
expect(player.state).toBe('loading');
});
test('reportStateFromAudio("ended") with repeat="all": wraps when at end', () => {
playQueue([track('1'), track('2')], 1);
cycleRepeat(); // repeat=all
reportStateFromAudio('ended');
expect(player.index).toBe(0);
expect(player.state).toBe('loading');
});
test('reportStateFromAudio("ended") with repeat="all" mid-queue: advances', () => {
playQueue([track('1'), track('2'), track('3')], 0);
cycleRepeat(); // repeat=all
reportStateFromAudio('ended');
expect(player.index).toBe(1);
});
test('reportStateFromAudio("ended") with repeat="off" at end: paused', () => {
playQueue([track('1'), track('2')], 1);
reportDuration(180);
reportStateFromAudio('ended');
expect(player.state).toBe('paused');
expect(player.position).toBe(180);
});
test('reportStateFromAudio("playing") sets state=playing, clears error', () => {
playQueue([track('1')]);
reportStateFromAudio('error', 'boom');
expect(player.error).toBe('boom');
reportStateFromAudio('playing');
expect(player.state).toBe('playing');
expect(player.error).toBeNull();
});
test('reportStateFromAudio("paused") sets state=paused', () => {
playQueue([track('1')]);
reportStateFromAudio('playing');
reportStateFromAudio('paused');
expect(player.state).toBe('paused');
});
test('reportStateFromAudio("waiting") sets state=loading', () => {
playQueue([track('1')]);
reportStateFromAudio('playing');
reportStateFromAudio('waiting');
expect(player.state).toBe('loading');
});
test('reportStateFromAudio("error", msg) sets state=error, error=msg', () => {
playQueue([track('1')]);
reportStateFromAudio('error', 'network lost');
expect(player.state).toBe('error');
expect(player.error).toBe('network lost');
});
});
```
- [ ] **Step 2: Run tests, confirm the new ones fail**
Run: `cd web && npm test -- src/lib/player/store.test.ts`
Expected: FAIL — the new functions aren't exported yet. The Task-1 tests still pass.
- [ ] **Step 3: Extend `web/src/lib/player/store.svelte.ts`**
Append at the bottom (keeping all of Task 1's code):
```ts
// --- Shuffle (with injectable RNG for tests) ---
let _rng: () => number = Math.random;
export function __setShuffleRng(fn: () => number): void { _rng = fn; }
export function toggleShuffle(): void {
_shuffle = !_shuffle;
if (_shuffle && _index + 1 < _queue.length) {
// Fisher-Yates shuffle over _queue[_index+1 .. end].
const arr = _queue.slice();
for (let j = arr.length - 1; j > _index; j--) {
const r = Math.floor(_rng() * (j - _index)) + _index + 1;
[arr[j], arr[r]] = [arr[r], arr[j]];
}
_queue = arr;
}
}
// --- Repeat ---
export function cycleRepeat(): void {
_repeat = _repeat === 'off' ? 'all' : _repeat === 'all' ? 'one' : 'off';
}
```
Now modify the EXISTING `skipNext` to be repeat-aware. Replace:
```ts
export function skipNext(): void {
if (_queue.length === 0) return;
if (_index + 1 < _queue.length) {
_index++;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
_state = 'paused';
_position = _duration;
}
}
```
with:
```ts
export function skipNext(): void {
if (_queue.length === 0) return;
if (_index + 1 < _queue.length) {
_index++;
_position = 0;
_duration = 0;
_state = 'loading';
return;
}
// At end of queue.
if (_repeat === 'all') {
_index = 0;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
// 'off' or 'one' both pause at end on EXPLICIT skip.
_state = 'paused';
_position = _duration;
}
}
```
Append `reportStateFromAudio`:
```ts
export function reportStateFromAudio(
event: 'playing' | 'paused' | 'waiting' | 'ended' | 'error',
detail?: string
): void {
switch (event) {
case 'playing':
_state = 'playing';
_error = null;
return;
case 'paused':
_state = 'paused';
return;
case 'waiting':
_state = 'loading';
return;
case 'error':
_state = 'error';
_error = detail ?? 'Playback failed.';
return;
case 'ended':
if (_repeat === 'one') {
_position = 0;
_state = 'loading';
if (_audioEl) _audioEl.currentTime = 0;
return;
}
if (_index + 1 < _queue.length) {
_index++;
_position = 0;
_duration = 0;
_state = 'loading';
return;
}
if (_repeat === 'all') {
_index = 0;
_position = 0;
_duration = 0;
_state = 'loading';
} else {
_state = 'paused';
_position = _duration;
}
return;
}
}
```
- [ ] **Step 4: Run tests, confirm all pass**
Run: `cd web && npm test -- src/lib/player/store.test.ts`
Expected: PASS — 22 tests (11 from Task 1 + 11 new).
- [ ] **Step 5: Type-check**
Run: `cd web && npm run check`
Expected: `0 ERRORS 0 WARNINGS`.
- [ ] **Step 6: Commit**
```bash
git add web/src/lib/player/store.svelte.ts web/src/lib/player/store.test.ts
git commit -m "feat(web): add shuffle, repeat, and audio-event reporting to player store
toggleShuffle (Fisher-Yates over queue[index+1..end] with injectable
RNG for test determinism), cycleRepeat (off → all → one → off),
reportStateFromAudio with full repeat-mode semantics on 'ended',
plus simple state mirroring for playing/paused/waiting/error. Extends
skipNext to wrap when repeat='all'."
```
---
## Task 3: MediaSession glue
**Files:**
- Create: `web/src/lib/player/mediaSession.svelte.ts`
- Create: `web/src/lib/player/mediaSession.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `web/src/lib/player/mediaSession.test.ts`:
```ts
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { flushSync } from 'svelte';
import type { TrackRef } from '$lib/api/types';
function track(id: string): TrackRef {
return {
id, title: `T ${id}`,
album_id: 'a1', album_title: 'A',
artist_id: 'ar1', artist_name: 'Ar',
track_number: Number(id), disc_number: 1,
duration_sec: 180, stream_url: `/api/tracks/${id}/stream`
};
}
type Handler = (details: MediaSessionActionDetails) => void;
// Spy MediaSession replaces navigator.mediaSession for the duration of each test.
function makeSpyMediaSession() {
const handlers: Record<string, Handler> = {};
const spy = {
metadata: null as MediaMetadata | null,
playbackState: 'none' as MediaSessionPlaybackState,
setActionHandler: vi.fn((action: string, h: Handler | null) => {
if (h) handlers[action] = h;
else delete handlers[action];
}),
setPositionState: vi.fn(),
handlers
};
return spy;
}
describe('useMediaSession', () => {
let spy: ReturnType<typeof makeSpyMediaSession>;
let originalMS: unknown;
beforeEach(async () => {
spy = makeSpyMediaSession();
originalMS = (navigator as unknown as { mediaSession?: unknown }).mediaSession;
Object.defineProperty(navigator, 'mediaSession', {
configurable: true,
value: spy
});
// Also stub MediaMetadata so we can construct it in jsdom.
(globalThis as unknown as { MediaMetadata: typeof Object }).MediaMetadata =
class { constructor(public init: unknown) {} } as unknown as typeof Object;
const store = await import('./store.svelte');
store.playQueue([]);
});
afterEach(() => {
if (originalMS === undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (navigator as any).mediaSession;
} else {
Object.defineProperty(navigator, 'mediaSession', {
configurable: true,
value: originalMS
});
}
vi.restoreAllMocks();
});
test('sets metadata when current track changes; clears when queue empties', async () => {
const store = await import('./store.svelte');
const { useMediaSession } = await import('./mediaSession.svelte');
const cleanup = $effect.root(() => useMediaSession());
store.playQueue([track('1')]);
flushSync();
expect(spy.metadata).not.toBeNull();
store.playQueue([]);
flushSync();
expect(spy.metadata).toBeNull();
cleanup();
});
test('registers play/pause/previoustrack/nexttrack/seekto handlers', async () => {
const { useMediaSession } = await import('./mediaSession.svelte');
const cleanup = $effect.root(() => useMediaSession());
flushSync();
expect(spy.handlers['play']).toBeTypeOf('function');
expect(spy.handlers['pause']).toBeTypeOf('function');
expect(spy.handlers['previoustrack']).toBeTypeOf('function');
expect(spy.handlers['nexttrack']).toBeTypeOf('function');
expect(spy.handlers['seekto']).toBeTypeOf('function');
cleanup();
});
test('playbackState mirrors player.isPlaying', async () => {
const store = await import('./store.svelte');
const { useMediaSession } = await import('./mediaSession.svelte');
const cleanup = $effect.root(() => useMediaSession());
store.playQueue([track('1')]);
store.reportStateFromAudio('playing');
flushSync();
expect(spy.playbackState).toBe('playing');
store.reportStateFromAudio('paused');
flushSync();
expect(spy.playbackState).toBe('paused');
cleanup();
});
test('setPositionState called when duration > 0', async () => {
const store = await import('./store.svelte');
const { useMediaSession } = await import('./mediaSession.svelte');
const cleanup = $effect.root(() => useMediaSession());
store.playQueue([track('1')]);
store.reportDuration(240);
store.reportTimeUpdate(30);
flushSync();
expect(spy.setPositionState).toHaveBeenCalled();
const call = spy.setPositionState.mock.calls.at(-1)?.[0];
expect(call?.duration).toBe(240);
expect(call?.position).toBeLessThanOrEqual(240);
cleanup();
});
});
```
- [ ] **Step 2: Run tests, confirm they fail**
Run: `cd web && npm test -- src/lib/player/mediaSession.test.ts`
Expected: FAIL — `./mediaSession.svelte` does not exist.
- [ ] **Step 3: Implement `web/src/lib/player/mediaSession.svelte.ts`**
```ts
import {
player,
togglePlay,
skipNext,
skipPrev,
seekTo
} from './store.svelte';
export function useMediaSession(): void {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
const ms = navigator.mediaSession;
$effect(() => {
const t = player.current;
if (!t) {
ms.metadata = null;
return;
}
ms.metadata = new MediaMetadata({
title: t.title,
artist: t.artist_name,
album: t.album_title,
artwork: [{
src: `/api/albums/${t.album_id}/cover`,
sizes: '512x512',
type: 'image/jpeg'
}]
});
});
ms.setActionHandler('play', () => togglePlay());
ms.setActionHandler('pause', () => togglePlay());
ms.setActionHandler('previoustrack', () => skipPrev());
ms.setActionHandler('nexttrack', () => skipNext());
ms.setActionHandler('seekto', (e) => {
if (typeof e.seekTime === 'number') seekTo(e.seekTime);
});
$effect(() => {
if (player.duration > 0 && 'setPositionState' in ms) {
try {
ms.setPositionState({
duration: player.duration,
position: Math.min(player.position, player.duration),
playbackRate: 1
});
} catch {
// setPositionState can throw on NaN/Infinity briefly between tracks.
}
}
});
$effect(() => {
ms.playbackState = player.isPlaying ? 'playing' : 'paused';
});
}
```
- [ ] **Step 4: Run tests, confirm they pass**
Run: `cd web && npm test -- src/lib/player/mediaSession.test.ts`
Expected: PASS — 4 tests.
- [ ] **Step 5: Type-check**
Run: `cd web && npm run check`
Expected: `0 ERRORS 0 WARNINGS`.
- [ ] **Step 6: Commit**
```bash
git add web/src/lib/player/mediaSession.svelte.ts web/src/lib/player/mediaSession.test.ts
git commit -m "feat(web): add MediaSession glue for OS media controls
useMediaSession() wires navigator.mediaSession metadata + action
handlers (play/pause/previous/next/seekto) + positionState to the
player store via \$effect. Exports nothing stateful — callers just
invoke it once at root-layout mount."
```
---
## Task 4: Click-to-play — modify `TrackRow` + album page
**Files:**
- Modify: `web/src/lib/components/TrackRow.svelte`
- Modify: `web/src/lib/components/TrackRow.test.ts`
- Modify: `web/src/routes/albums/[id]/+page.svelte`
- [ ] **Step 1: Rewrite `TrackRow.test.ts`**
Replace the file contents:
```ts
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn()
}));
import TrackRow from './TrackRow.svelte';
import { playQueue } from '$lib/player/store.svelte';
const tracks: TrackRef[] = [
{
id: 't1', title: 'So What',
album_id: 'xyz', album_title: 'Kind of Blue',
artist_id: 'm-davis', artist_name: 'Miles Davis',
track_number: 1, disc_number: 1,
duration_sec: 545, stream_url: '/api/tracks/t1/stream'
},
{
id: 't2', title: 'Freddie Freeloader',
album_id: 'xyz', album_title: 'Kind of Blue',
artist_id: 'm-davis', artist_name: 'Miles Davis',
track_number: 2, disc_number: 1,
duration_sec: 565, stream_url: '/api/tracks/t2/stream'
}
];
afterEach(() => vi.clearAllMocks());
describe('TrackRow', () => {
test('renders track number, title, formatted duration', () => {
render(TrackRow, { props: { tracks, index: 0 } });
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('So What')).toBeInTheDocument();
expect(screen.getByText('9:05')).toBeInTheDocument();
});
test('track number falls back to dash when missing', () => {
const noNum = [{ ...tracks[0], track_number: undefined }];
render(TrackRow, { props: { tracks: noNum, index: 0 } });
expect(screen.getByText('—')).toBeInTheDocument();
});
test('clicking the row calls playQueue(tracks, index)', async () => {
render(TrackRow, { props: { tracks, index: 1 } });
await fireEvent.click(screen.getByRole('button'));
expect(playQueue).toHaveBeenCalledWith(tracks, 1);
});
});
```
- [ ] **Step 2: Run tests, confirm they fail**
Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts`
Expected: FAIL — existing TrackRow only takes `{ track }`; the new test passes `{ tracks, index }` and expects a `<button>` role.
- [ ] **Step 3: Replace `TrackRow.svelte`**
```svelte
<script lang="ts">
import type { TrackRef } from '$lib/api/types';
import { formatDuration } from '$lib/media/duration';
import { playQueue } from '$lib/player/store.svelte';
let { tracks, index }: { tracks: TrackRef[]; index: number } = $props();
const track = $derived(tracks[index]);
function onClick() {
playQueue(tracks, index);
}
</script>
<button
type="button"
onclick={onClick}
class="grid w-full grid-cols-[32px_1fr_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<span class="text-right tabular-nums text-text-secondary">
{track.track_number ?? '—'}
</span>
<span class="truncate">{track.title}</span>
<span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
</button>
```
- [ ] **Step 4: Update the album page**
Open `web/src/routes/albums/[id]/+page.svelte` and change the each block. Find:
```svelte
{#each album.tracks as track (track.id)}
<TrackRow {track} />
{/each}
```
Replace with:
```svelte
{#each album.tracks as track, i (track.id)}
<TrackRow tracks={album.tracks} index={i} />
{/each}
```
- [ ] **Step 5: Run tests**
Run: `cd web && npm test -- src/lib/components/TrackRow.test.ts src/routes/albums/[id]/album.test.ts`
Expected: PASS — all 3 TrackRow tests + 5 album-page tests.
- [ ] **Step 6: Type-check**
Run: `cd web && npm run check`
Expected: `0 ERRORS 0 WARNINGS`.
- [ ] **Step 7: Commit**
```bash
git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts web/src/routes/albums/[id]/+page.svelte
git commit -m "feat(web): make TrackRow click-to-play; pass tracks+index from album page
TrackRow becomes a <button> that dispatches playQueue(tracks, index).
Album page supplies the full track list + each index so the rest of
the album enqueues automatically when the user clicks a track."
```
---
## Task 5: `PlayerBar` component
**Files:**
- Create: `web/src/lib/components/PlayerBar.svelte`
- Create: `web/src/lib/components/PlayerBar.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `web/src/lib/components/PlayerBar.test.ts`:
```ts
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
// Mutable state the mocked store reads from.
const state = vi.hoisted(() => ({
current: undefined as TrackRef | undefined,
queue: [] as TrackRef[],
index: 0,
state: 'idle' as 'idle' | 'loading' | 'playing' | 'paused' | 'error',
position: 0,
duration: 0,
volume: 1,
shuffle: false,
repeat: 'off' as 'off' | 'all' | 'one',
error: null as string | null
}));
vi.mock('$lib/player/store.svelte', () => ({
player: {
get queue() { return state.queue; },
get index() { return state.index; },
get current() { return state.current; },
get state() { return state.state; },
get isPlaying() { return state.state === 'playing'; },
get position() { return state.position; },
get duration() { return state.duration; },
get volume() { return state.volume; },
get shuffle() { return state.shuffle; },
get repeat() { return state.repeat; },
get error() { return state.error; }
},
togglePlay: vi.fn(),
skipNext: vi.fn(),
skipPrev: vi.fn(),
seekTo: vi.fn(),
setVolume: vi.fn(),
toggleShuffle: vi.fn(),
cycleRepeat: vi.fn(),
playQueue: vi.fn()
}));
import PlayerBar from './PlayerBar.svelte';
import {
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat, playQueue
} from '$lib/player/store.svelte';
function track(): TrackRef {
return {
id: 't1', title: 'So What',
album_id: 'xyz', album_title: 'Kind of Blue',
artist_id: 'md', artist_name: 'Miles Davis',
track_number: 1, disc_number: 1,
duration_sec: 545, stream_url: '/api/tracks/t1/stream'
};
}
beforeEach(() => {
state.queue = [track()];
state.current = state.queue[0];
state.index = 0;
state.state = 'paused';
state.position = 42;
state.duration = 545;
state.volume = 0.8;
state.shuffle = false;
state.repeat = 'off';
state.error = null;
});
afterEach(() => vi.clearAllMocks());
describe('PlayerBar', () => {
test('renders title, linked artist, linked cover', () => {
render(PlayerBar);
expect(screen.getByText('So What')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md');
const cover = screen.getByRole('link', { name: /open album/i });
expect(cover).toHaveAttribute('href', '/albums/xyz');
});
test('play button click calls togglePlay', async () => {
render(PlayerBar);
await fireEvent.click(screen.getByRole('button', { name: /play|pause/i }));
expect(togglePlay).toHaveBeenCalledTimes(1);
});
test('skip-next click calls skipNext; skip-prev click calls skipPrev', async () => {
render(PlayerBar);
await fireEvent.click(screen.getByRole('button', { name: /next/i }));
await fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(skipNext).toHaveBeenCalledTimes(1);
expect(skipPrev).toHaveBeenCalledTimes(1);
});
test('skip-prev disabled when index=0 AND position<3', () => {
state.index = 0;
state.position = 1;
render(PlayerBar);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
});
test('skip-next disabled at end with repeat=off', () => {
state.queue = [track()];
state.index = 0;
state.repeat = 'off';
render(PlayerBar);
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
test('seek slider input calls seekTo(value)', async () => {
render(PlayerBar);
const slider = screen.getByLabelText(/seek/i) as HTMLInputElement;
await fireEvent.input(slider, { target: { value: '120' } });
expect(seekTo).toHaveBeenCalledWith(120);
});
test('volume slider input calls setVolume(value)', async () => {
render(PlayerBar);
const slider = screen.getByLabelText(/volume/i) as HTMLInputElement;
await fireEvent.input(slider, { target: { value: '0.3' } });
expect(setVolume).toHaveBeenCalledWith(0.3);
});
test('shuffle button click calls toggleShuffle; active class reflects shuffle', () => {
state.shuffle = true;
render(PlayerBar);
const btn = screen.getByRole('button', { name: /shuffle/i });
expect(btn.getAttribute('aria-pressed')).toBe('true');
});
test('repeat button aria-label reflects current mode', () => {
state.repeat = 'one';
render(PlayerBar);
expect(screen.getByRole('button', { name: /repeat one/i })).toBeInTheDocument();
});
test('loading state shows spinner in place of play icon', () => {
state.state = 'loading';
render(PlayerBar);
expect(screen.getByTestId('play-spinner')).toBeInTheDocument();
});
test('error state renders retry card; retry click calls playQueue(queue, index)', async () => {
state.state = 'error';
state.error = 'Playback failed.';
render(PlayerBar);
expect(screen.getByText(/playback failed/i)).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(playQueue).toHaveBeenCalledWith(state.queue, state.index);
});
test('elapsed and total render formatted duration', () => {
state.position = 65;
state.duration = 245;
render(PlayerBar);
expect(screen.getByText('1:05')).toBeInTheDocument();
expect(screen.getByText('4:05')).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run tests, confirm they fail**
Run: `cd web && npm test -- src/lib/components/PlayerBar.test.ts`
Expected: FAIL — component doesn't exist.
- [ ] **Step 3: Implement `web/src/lib/components/PlayerBar.svelte`**
```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';
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">
<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>
</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}
```
- [ ] **Step 4: Run tests, confirm they pass**
Run: `cd web && npm test -- src/lib/components/PlayerBar.test.ts`
Expected: PASS — 12 tests.
- [ ] **Step 5: Type-check**
Run: `cd web && npm run check`
Expected: `0 ERRORS 0 WARNINGS`.
- [ ] **Step 6: Commit**
```bash
git add web/src/lib/components/PlayerBar.svelte web/src/lib/components/PlayerBar.test.ts
git commit -m "feat(web): add PlayerBar component
Bottom-bar UI with left: cover+title+linked artist, center: stacked
seek row (elapsed / slider / duration) + transport (prev/play/next),
right: shuffle/repeat/volume. Loading shows spinner; error replaces
transport with retry card."
```
---
## Task 6: Modify `Shell` — 3rd grid row for PlayerBar
**Files:**
- Modify: `web/src/lib/components/Shell.svelte`
- [ ] **Step 1: Read the current Shell.svelte**
Run: `cat web/src/lib/components/Shell.svelte`
Locate the outer `<div>` with classes `grid h-screen grid-cols-[auto_1fr] grid-rows-[auto_1fr]`.
- [ ] **Step 2: Modify `Shell.svelte`**
Three changes:
1. Add a script import: `import PlayerBar from './PlayerBar.svelte';` and `import { player } from '$lib/player/store.svelte';`.
2. Change `grid-rows-[auto_1fr]``grid-rows-[auto_1fr_auto]`.
3. Add, immediately before the closing `</div>` of the grid wrapper:
```svelte
{#if player.current}
<div class="col-span-2">
<PlayerBar />
</div>
{/if}
```
Full script block updated:
```svelte
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { user, logout } from '$lib/auth/store.svelte';
import PlayerBar from './PlayerBar.svelte';
import { player } from '$lib/player/store.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
let menuOpen = $state(false);
async function handleLogout() {
menuOpen = false;
await logout();
goto('/login', { replaceState: true });
}
function isActive(href: string): boolean {
if (href === '/') return page.url.pathname === '/';
return page.url.pathname.startsWith(href);
}
const navItems = [
{ href: '/', label: 'Library' },
{ href: '/search', label: 'Search' },
{ href: '/playlists', label: 'Playlists' }
];
</script>
```
And the outer grid div:
```svelte
<div class="grid h-screen grid-cols-[auto_1fr] grid-rows-[auto_1fr_auto] bg-background text-text-primary">
<!-- ...existing header, nav, main... -->
{#if player.current}
<div class="col-span-2">
<PlayerBar />
</div>
{/if}
</div>
```
- [ ] **Step 3: Run the Shell tests**
Run: `cd web && npm test -- src/lib/components/Shell.test.ts`
Expected: PASS — existing 3 tests still pass (PlayerBar conditionally absent when `player.current` is undefined, which is the default in those tests).
- [ ] **Step 4: Type-check**
Run: `cd web && npm run check`
Expected: `0 ERRORS 0 WARNINGS`.
- [ ] **Step 5: Commit**
```bash
git add web/src/lib/components/Shell.svelte
git commit -m "feat(web): add PlayerBar slot to Shell
Grid grows to 3 rows (auto / 1fr / auto); PlayerBar spans both columns
in the last row, rendering only when player.current is defined so the
row collapses before first play."
```
---
## Task 7: Wire `<audio>` element in `+layout.svelte`
**Files:**
- Modify: `web/src/routes/+layout.svelte`
- [ ] **Step 1: Inspect the current file**
Run: `cat web/src/routes/+layout.svelte`
It currently has: a script importing auth/query/shell pieces, a `$effect` guard, and a `<QueryClientProvider>` wrapping conditional Shell/slot.
- [ ] **Step 2: Add audio wiring**
Extend the script block with these imports:
```ts
import {
registerAudioEl,
reportTimeUpdate,
reportDuration,
reportStateFromAudio,
player
} from '$lib/player/store.svelte';
import { useMediaSession } from '$lib/player/mediaSession.svelte';
```
Add a rune state var and $effects after the existing guard `$effect`:
```ts
let audioEl: HTMLAudioElement | undefined = $state();
$effect(() => {
if (audioEl) registerAudioEl(audioEl);
return () => registerAudioEl(null);
});
$effect(() => {
if (!audioEl) return;
const src = player.current?.stream_url;
if (src) {
const absolute = new URL(src, window.location.origin).href;
if (audioEl.src !== absolute) audioEl.src = src;
} else {
audioEl.removeAttribute('src');
audioEl.load();
}
});
$effect(() => {
if (audioEl) audioEl.volume = player.volume;
});
$effect(() => {
if (!audioEl) return;
if (player.isPlaying) {
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
} else {
audioEl.pause();
}
});
useMediaSession();
```
Add the hidden `<audio>` element at the top level of the template (outside `QueryClientProvider` — the audio should survive navigation regardless of auth state):
```svelte
<audio
bind:this={audioEl}
preload="metadata"
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
onloadedmetadata={() => audioEl && reportDuration(audioEl.duration)}
onplaying={() => reportStateFromAudio('playing')}
onpause={() => reportStateFromAudio('paused')}
onwaiting={() => reportStateFromAudio('waiting')}
onended={() => reportStateFromAudio('ended')}
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
></audio>
<QueryClientProvider client={queryClient}>
<!-- ...existing content... -->
</QueryClientProvider>
```
- [ ] **Step 3: Type-check**
Run: `cd web && npm run check`
Expected: `0 ERRORS 0 WARNINGS`.
- [ ] **Step 4: Full test suite**
Run: `cd web && npm test`
Expected: all test files pass.
- [ ] **Step 5: Build**
Run: `cd web && npm run build`
Expected: build succeeds. Then:
Run: `git checkout -- web/build/index.html`
Expected: committed placeholder restored.
- [ ] **Step 6: Commit**
```bash
git add web/src/routes/+layout.svelte
git commit -m "feat(web): mount <audio> at root layout and wire it to the player store
Single hidden <audio> survives navigation. Effects drive it from the
store (src, volume, play/pause) and DOM events report back (timeupdate,
loadedmetadata, playing, pause, waiting, ended, error). useMediaSession
is called once here so OS media controls light up as soon as the user
signs in."
```
---
## Task 8: Final verification + branch finish
**Files:** none (verification only).
- [ ] **Step 1: Full Go suite (sanity)**
Run: `go test -short -race ./...`
Expected: PASS.
- [ ] **Step 2: Go linter**
Run: `golangci-lint run ./...`
Expected: no issues.
- [ ] **Step 3: Web check + full tests + build**
Run: `cd web && npm run check && npm test && npm run build`
Expected: check 0 errors / 0 warnings; all vitest files pass; build emits `web/build/`.
Run: `git checkout -- web/build/index.html`
Expected: committed placeholder restored.
- [ ] **Step 4: Docker build smoke**
Run: `docker build -t minstrel:player-smoke .`
Expected: image builds.
Run: `docker run --rm --entrypoint /bin/sh minstrel:player-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'`
Expected: `ok`.
- [ ] **Step 5: Local end-to-end manual check**
Bring up the stack:
```bash
docker compose up --build -d
```
In a browser:
1. Sign in; drill into an album with playable tracks. Click track 3 → bar appears at the bottom; audio plays.
2. Play/pause button toggles; seek slider drag works; elapsed/duration update.
3. Skip-next walks through the album; reaches end → pauses (repeat=off).
4. Toggle repeat to "all"; skip-next past end → wraps to track 1.
5. Toggle repeat to "one"; let a track end → it replays.
6. Toggle shuffle on; skip-next → lands on a random later-in-album track.
7. Lock screen or check OS media notification — metadata visible; media-key play/pause works.
8. Adjust volume; refresh — volume persists.
9. Pull network cable mid-playback → error card appears; click "Try again" after reconnecting → recovers.
10. Navigate between pages while playing — audio continues, bar stays.
Tear down:
```bash
docker compose down
```
- [ ] **Step 6: Finish the branch**
Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green.
---
## Self-Review Notes
**Spec coverage:**
- Player store (state + actions) → Tasks 12
- `readStoredVolume` + volume localStorage persistence → Task 1
- Shuffle + repeat + auto-advance-on-ended → Task 2
- Audio-event reporting → Task 2
- MediaSession metadata + action handlers + playbackState + positionState → Task 3
- Click-to-play semantics → Task 4
- PlayerBar layout + loading/error states + disabled controls → Task 5
- Shell 3rd-row integration → Task 6
- `<audio>` element wiring at root layout → Task 7
- Final verification + branch finish → Task 8
**Type consistency:**
- `TrackRef`, `PlayerState`, `RepeatMode` — defined in Task 1, used in 27.
- `player` accessor — defined in Task 1, consumed identically everywhere.
- `playQueue`, `togglePlay`, `skipNext`, `skipPrev`, `seekTo`, `setVolume`, `toggleShuffle`, `cycleRepeat`, `reportTimeUpdate`, `reportDuration`, `reportStateFromAudio`, `registerAudioEl` — signatures stable between Task 1/2 (definition) and 3/4/5/7 (consumption).
- `__setShuffleRng` is a Task-2-only helper for tests; not consumed by production code.
- Volume localStorage key `'minstrel.volume'` appears only in Task 1's module; no other file references the key directly.
**Filename hazards:** no `+`-prefixed test files under routes. All route-colocated page tests (from prior plans) already use non-`+` names (`login.test.ts`, `artist.test.ts`, `album.test.ts`). This plan only adds `.test.ts` files under `lib/`, which is outside the route walker.
**Placeholder scan:** no TBD/TODO/later markers. All code blocks are complete; all commands show expected output.