7c19fe8a6a
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>
1699 lines
52 KiB
Markdown
1699 lines
52 KiB
Markdown
# 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 1–2
|
||
- `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 2–7.
|
||
- `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.
|