docs(spec): add web UI player design
Queue-based playback on top of HTMLAudioElement: rune store + pure action functions, single <audio> mounted in the root layout driven via $effect, MediaSession metadata/action handlers, and a persistent bottom bar with shuffle/repeat/volume. Non-goals: server-side listen history (awaiting /api/events), cross-refresh persistence, keyboard shortcuts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
# Web UI Player — Design Spec
|
||||
|
||||
**Date:** 2026-04-24
|
||||
**Status:** Design approved
|
||||
**Follows:** [Web UI Library Views](2026-04-23-web-ui-library-views-design.md) — tracks surfaced by the library plan finally become playable.
|
||||
|
||||
## Goal
|
||||
|
||||
Let an authenticated user click any track on an album page and hear it — plus skip/seek/shuffle/repeat/volume, a persistent bottom-bar UI, and OS-level media controls via the MediaSession API. After this lands, the web client is a functional music player for sequential playback through the library.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Explicit YAGNI — kept out of this plan so scope stays tight:
|
||||
|
||||
- **No server-side listen-history recording.** The spec's `play_events` / `sessions` tables exist in the server design but no `/api/*` endpoints are implemented yet. Once those land, a follow-up "session reporting" sub-plan wires the player to POST events.
|
||||
- **No scrobbling to ListenBrainz.** Dependent on server event endpoints + scrobble queue — later plan.
|
||||
- **No contextual likes or "like this vibe".** Out of scope until session reporting exists.
|
||||
- **No cross-refresh persistence.** Reload drops playback state (queue, position, current track). Volume is the only thing that persists.
|
||||
- **No queue UI / "up next" panel.** The user sees the current track in the bar; they don't get a separate list view of what's coming. Queue visibility is a natural follow-up.
|
||||
- **No keyboard shortcuts.** Space/arrow keys don't control playback in this plan — a separate "keyboard shortcuts" sub-plan will add them.
|
||||
- **No crossfade, gapless playback, replay-gain, equalizer.** Baseline HTMLAudio only.
|
||||
- **No cast / AirPlay / Chromecast.** Browser-local playback only.
|
||||
- **No keyboard-driven seeking beyond the native `<input type="range">` behavior.**
|
||||
|
||||
## Architecture
|
||||
|
||||
**Single global player store** as a Svelte 5 rune module — imported by `+layout.svelte`, `Shell`, `PlayerBar`, and `TrackRow`. The store is pure state + action functions; it never touches the `<audio>` element directly.
|
||||
|
||||
The `<audio>` element lives once in `+layout.svelte` (survives navigation because the root layout never unmounts). The layout drives the element FROM the store via `$effect`:
|
||||
|
||||
- `audioEl.src` follows `player.current?.stream_url`
|
||||
- `audioEl.volume` follows `player.volume`
|
||||
- `audioEl.play()` / `pause()` follows `player.isPlaying`
|
||||
|
||||
And reports back INTO the store via DOM event handlers: `timeupdate`, `loadedmetadata`, `playing`, `pause`, `waiting`, `ended`, `error`. This inversion makes the store pure-logic testable — unit tests don't need jsdom to implement `HTMLMediaElement`.
|
||||
|
||||
**Seek writes go directly** via a small `registerAudioEl(el)` helper the layout calls once at mount. `seekTo(sec)` then writes `audioEl.currentTime = sec` without going through a `$effect` on `position` (avoids a write-loop where audio `timeupdate` → store `position` → `$effect` → audio `currentTime` → `timeupdate` again).
|
||||
|
||||
**MediaSession integration** (`useMediaSession()`) is a thin `$effect`-based module imported once from the root layout. It reads the store and writes `navigator.mediaSession.metadata`, registers action handlers (play/pause/previous/next/seekto), and keeps `playbackState` + `setPositionState` in sync.
|
||||
|
||||
**Click-to-play lifecycle:**
|
||||
|
||||
```
|
||||
User clicks <TrackRow> for album tracks[5]
|
||||
→ store.playQueue(album.tracks, 5)
|
||||
→ _queue=tracks, _index=5, _state='loading', _position=0
|
||||
→ Shell mounts <PlayerBar> (current !== undefined)
|
||||
→ layout's src $effect → audio.src = tracks[5].stream_url
|
||||
→ layout's isPlaying $effect → audio.play()
|
||||
→ 'loadedmetadata' → reportDuration
|
||||
→ 'playing' → reportStateFromAudio('playing') → _state='playing'
|
||||
→ PlayerBar updates
|
||||
```
|
||||
|
||||
## File structure
|
||||
|
||||
**New files under `web/src/`:**
|
||||
|
||||
```
|
||||
lib/
|
||||
├── player/
|
||||
│ ├── store.svelte.ts # Rune state + action functions
|
||||
│ ├── store.test.ts # Pure state-machine tests
|
||||
│ ├── mediaSession.svelte.ts # navigator.mediaSession glue
|
||||
│ └── mediaSession.test.ts # MediaSession spy-based tests
|
||||
└── components/
|
||||
├── PlayerBar.svelte # Bottom bar UI
|
||||
└── PlayerBar.test.ts # Controls wired correctly; disabled states
|
||||
```
|
||||
|
||||
**Modified files:**
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `lib/components/Shell.svelte` | Grid grows a 3rd row. `<PlayerBar>` slot spans both columns. Mounted only when `player.current !== undefined`. |
|
||||
| `lib/components/TrackRow.svelte` | Becomes a `<button>` that calls `playQueue(tracks, index)`. New props `tracks: TrackRef[]` and `index: number`. |
|
||||
| `lib/components/TrackRow.test.ts` | Updated — clicks fire `playQueue` (mocked). |
|
||||
| `routes/albums/[id]/+page.svelte` | Passes `tracks={album.tracks}` and `index={i}` to each `<TrackRow>`. |
|
||||
| `routes/+layout.svelte` | Adds `<audio>` element, `$effect` bindings that drive it from the store, and a `useMediaSession()` call. |
|
||||
|
||||
## State shape
|
||||
|
||||
```ts
|
||||
// lib/player/store.svelte.ts
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
||||
export type RepeatMode = 'off' | 'all' | 'one';
|
||||
|
||||
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()); // initial from localStorage
|
||||
let _shuffle = $state(false);
|
||||
let _repeat = $state<RepeatMode>('off');
|
||||
let _error = $state<string | 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; },
|
||||
};
|
||||
```
|
||||
|
||||
## Action contract
|
||||
|
||||
```ts
|
||||
// From outside the module (UI components and the layout call these).
|
||||
export function playQueue(tracks: TrackRef[], startIndex = 0): void;
|
||||
export function togglePlay(): void;
|
||||
export function skipNext(): void;
|
||||
export function skipPrev(): void;
|
||||
export function seekTo(sec: number): void;
|
||||
export function setVolume(v: number): void;
|
||||
export function toggleShuffle(): void;
|
||||
export function cycleRepeat(): void;
|
||||
|
||||
// Audio-event reports FROM the layout's <audio> element into the store.
|
||||
export function reportTimeUpdate(sec: number): void;
|
||||
export function reportDuration(sec: number): void;
|
||||
export function reportStateFromAudio(
|
||||
event: 'playing' | 'paused' | 'waiting' | 'ended' | 'error',
|
||||
detail?: string
|
||||
): void;
|
||||
|
||||
// Wiring for seek writes (avoids $effect loop on position).
|
||||
export function registerAudioEl(el: HTMLAudioElement | null): void;
|
||||
```
|
||||
|
||||
## Per-control semantics
|
||||
|
||||
**Skip-previous (3-second rule).** Skip-prev never wraps to the end of the queue — repeat modes only affect auto-advance and skip-next.
|
||||
- If `_position < 3` AND `_index > 0`: `_index--`, position=0, play.
|
||||
- Else (position ≥ 3, OR at index 0): seek to 0 of current track.
|
||||
- Disabled visually when `_index === 0` AND `_position < 3` — nothing useful left to do.
|
||||
|
||||
**Skip-next.**
|
||||
- `_index + 1 < _queue.length` → `_index++`, position=0.
|
||||
- At end: consult `_repeat`:
|
||||
- `off` → `_state='paused'`, `_index = queue.length - 1`, `_position = _duration`.
|
||||
- `all` → `_index = 0`, keep playing.
|
||||
- `one` → treated as `off` here (explicit user skip overrides one-track repeat).
|
||||
- Disabled when `_index === _queue.length - 1` AND `_repeat === 'off'`.
|
||||
|
||||
**Auto-advance on `ended` event** (`reportStateFromAudio('ended')`):
|
||||
- `repeat === 'one'` → `_position = 0`, `_state='loading'`; layout sees state change, `currentTime` resets (via `seekTo(0)` internally), play continues.
|
||||
- `repeat === 'all'` → `_index = (_index + 1) % _queue.length`, `_position = 0`.
|
||||
- `repeat === 'off'` → same as skip-next past end: `_state='paused'`.
|
||||
|
||||
**Seek.**
|
||||
- UI: `<input type="range" min=0 max={duration}>` bound to `player.position` with `oninput → seekTo(newValue)`.
|
||||
- `seekTo` writes `_position = sec` AND `audioEl.currentTime = sec` via `registerAudioEl`-stored reference.
|
||||
|
||||
**Volume.**
|
||||
- UI: `<input type="range" min=0 max=1 step=0.01>`.
|
||||
- `setVolume(v)` clamps to `[0, 1]`, writes `_volume = v`, persists via `localStorage.setItem('minstrel.volume', String(v))`.
|
||||
- Layout's `$effect` on `player.volume` pushes to `audioEl.volume`.
|
||||
- Module-load-time helper `readStoredVolume(): number` reads `localStorage.getItem('minstrel.volume')` with `1.0` fallback and numeric validation.
|
||||
|
||||
**Shuffle.**
|
||||
- `toggleShuffle()`:
|
||||
- Enabling: Fisher-Yates shuffle `_queue[_index+1..end]` in place. The played-and-current tracks stay in position; only what's ahead is randomized.
|
||||
- Disabling: flag flips. Queue is not unshuffled — shuffle is a one-shot randomization; toggling back off only matters for new `skipNext`/`ended` behavior, which at the moment IS "walk through queue in order" so no difference. (Simpler than tracking an original order.)
|
||||
- Test-determinism: the shuffle function takes an injectable RNG; default is `Math.random`. Tests override via a module-level setter.
|
||||
|
||||
**Repeat.**
|
||||
- `cycleRepeat()`: `off → all → one → off`.
|
||||
- UI renders three distinct glyphs.
|
||||
|
||||
**Click-to-play.**
|
||||
- `TrackRow` `onclick` → `playQueue(tracks, index)`.
|
||||
- Clicking a track currently playing restarts it.
|
||||
- Clicking a track on a DIFFERENT album replaces queue with that album's tracks, `index=N`.
|
||||
|
||||
## Audio wiring in `+layout.svelte`
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
/* existing imports unchanged */
|
||||
import {
|
||||
player,
|
||||
registerAudioEl,
|
||||
reportTimeUpdate,
|
||||
reportDuration,
|
||||
reportStateFromAudio,
|
||||
seekTo
|
||||
} from '$lib/player/store.svelte';
|
||||
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
||||
|
||||
let audioEl: HTMLAudioElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (audioEl) registerAudioEl(audioEl);
|
||||
return () => registerAudioEl(null);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!audioEl) return;
|
||||
const src = player.current?.stream_url ?? '';
|
||||
if (audioEl.src !== new URL(src, window.location.origin).href && src) {
|
||||
audioEl.src = src;
|
||||
} else if (!src) {
|
||||
audioEl.removeAttribute('src');
|
||||
audioEl.load();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (audioEl) audioEl.volume = player.volume;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!audioEl) return;
|
||||
if (player.isPlaying) audioEl.play().catch(() => {/* play() rejected (autoplay / interrupted) — store will receive a 'pause' event */});
|
||||
else audioEl.pause();
|
||||
});
|
||||
|
||||
useMediaSession();
|
||||
</script>
|
||||
|
||||
<!-- existing layout markup -->
|
||||
<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>
|
||||
```
|
||||
|
||||
## MediaSession glue
|
||||
|
||||
```ts
|
||||
// lib/player/mediaSession.svelte.ts
|
||||
import { player, togglePlay, skipNext, skipPrev, seekTo } from './store.svelte';
|
||||
|
||||
export function useMediaSession(): void {
|
||||
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
|
||||
|
||||
$effect(() => {
|
||||
const t = player.current;
|
||||
if (!t) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
return;
|
||||
}
|
||||
navigator.mediaSession.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'
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', () => togglePlay());
|
||||
navigator.mediaSession.setActionHandler('pause', () => togglePlay());
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => skipPrev());
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => skipNext());
|
||||
navigator.mediaSession.setActionHandler('seekto', (e) => {
|
||||
if (typeof e.seekTime === 'number') seekTo(e.seekTime);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (player.duration > 0 && 'setPositionState' in navigator.mediaSession) {
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: player.duration,
|
||||
position: Math.min(player.position, player.duration),
|
||||
playbackRate: 1
|
||||
});
|
||||
} catch { /* setPositionState throws if numbers are NaN/Infinity briefly between tracks */ }
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
navigator.mediaSession.playbackState = player.isPlaying ? 'playing' : 'paused';
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Bottom-bar layout
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ [cover] Title 2:14 [════════▓═══════] 4:02 🔀 🔁 🔊 │
|
||||
│ Artist ⏮ ⏯ ⏭ │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Left** (fixed 240px, collapses below `sm:`): 48px cover thumbnail (click → `/albums/:id`), title, artist name linked to `/artists/:id`. Broken cover → `FALLBACK_COVER` from library plan.
|
||||
- **Center** (flex-1, stacked):
|
||||
- Top row: `{elapsedTime} {seek slider (flex-1)} {totalDuration}` — single row, elapsed/total use `tabular-nums` and a fixed `ch` width so the slider doesn't jitter as `position` ticks.
|
||||
- Bottom row: `⏮ ⏯ ⏭` centered under the slider. Play/pause glyph swaps based on `isPlaying`. During `loading`/`waiting`, swap play icon for a small spinner.
|
||||
- **Right** (~220px): shuffle button, repeat button (badge for 'one' state), volume slider (150px wide).
|
||||
- Total bar height ~80px. Grid spans both columns (sidebar + main).
|
||||
|
||||
Error variant: the center column's content is replaced with an inline error card — "Playback failed." + "Try again" button that calls `playQueue(_queue, _index)`. Transport controls disabled.
|
||||
|
||||
## Shell modifications
|
||||
|
||||
`Shell.svelte` grid grows a 3rd row:
|
||||
|
||||
```
|
||||
grid grid-cols-[auto_1fr] grid-rows-[auto_1fr_auto]
|
||||
```
|
||||
|
||||
The last row spans both columns. PlayerBar renders there only when `player.current` is defined. When nothing has played yet, the row collapses (zero height).
|
||||
|
||||
## Loading / error / empty states
|
||||
|
||||
- `state === 'loading' | 'waiting'` — play/pause button shows spinner glyph. Other controls unchanged.
|
||||
- `state === 'error'` — error card replaces transport controls; retry button calls `playQueue(_queue, _index)`.
|
||||
- `current === undefined` — PlayerBar doesn't render. Shell's 3rd grid row collapses.
|
||||
|
||||
401 mid-playback: already handled globally by `apiFetch`'s interceptor → `auth.logout({silent: true})` → auth store clears → root layout's guard redirects to `/login`. The audio element errors out; the PlayerBar unmounts because Shell is gone. No player-specific handling needed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit tests — `store.test.ts`:**
|
||||
|
||||
State-machine semantics, no audio element. All tests seed a fresh store via `vi.resetModules()` + dynamic `import('./store.svelte')` in `beforeEach` so `_*` state isn't shared across tests. Explicit cases:
|
||||
|
||||
- `playQueue([...], 2)` sets queue, index=2, state='loading', position=0.
|
||||
- `togglePlay`: idle → no-op. paused → playing. playing → paused.
|
||||
- `skipNext` mid-queue: index++, position=0.
|
||||
- `skipNext` at end with `repeat='off'`: state='paused', index=queue.length-1, position=duration.
|
||||
- `skipNext` at end with `repeat='all'`: index=0.
|
||||
- `skipNext` at end with `repeat='one'`: same as 'off' (explicit skip overrides one-track repeat).
|
||||
- `skipPrev` with position < 3 AND index > 0: index--.
|
||||
- `skipPrev` with position >= 3: index unchanged; position=0.
|
||||
- `skipPrev` at index 0: position=0 regardless.
|
||||
- `reportStateFromAudio('ended')` with `repeat='one'`: position=0, index unchanged, state='loading'.
|
||||
- `reportStateFromAudio('ended')` with `repeat='all'`: wraps `_index = (_index + 1) % _queue.length`.
|
||||
- `reportStateFromAudio('ended')` with `repeat='off'`: state='paused'.
|
||||
- `toggleShuffle` (with injected seeded RNG): queue[index+1..end] is reordered; queue[0..index] unchanged.
|
||||
- `cycleRepeat`: off → all → one → off (3 consecutive calls).
|
||||
- `setVolume(0.5)`: volume=0.5; localStorage key 'minstrel.volume' equals '0.5'.
|
||||
- `setVolume(-1)` clamps to 0; `setVolume(2)` clamps to 1.
|
||||
- Module load with `localStorage['minstrel.volume']='0.3'`: initial `_volume === 0.3`.
|
||||
- Module load with absent / invalid localStorage value: initial `_volume === 1.0`.
|
||||
- `reportStateFromAudio('error', 'foo')`: state='error', error='foo'.
|
||||
- `reportStateFromAudio('playing')`: state='playing'; clears `_error`.
|
||||
- `seekTo(30)`: position=30; if `registerAudioEl` has been called with a mock element, that element's `currentTime === 30`.
|
||||
|
||||
**Unit tests — `mediaSession.test.ts`:**
|
||||
|
||||
Fakes `navigator.mediaSession` with a spy object containing `setActionHandler`, `setPositionState`, `metadata`, `playbackState`. Calls `useMediaSession()` inside `$effect.root(() => {...})`.
|
||||
|
||||
- Metadata: when `player.current` is set, `navigator.mediaSession.metadata` has title/artist/album/artwork matching the current track.
|
||||
- Metadata cleared to `null` when `player.current` becomes undefined.
|
||||
- All four action handlers registered with callables that dispatch to the corresponding store action.
|
||||
- `playbackState` updates when `isPlaying` flips.
|
||||
- `setPositionState` called with duration/position whenever duration>0 changes.
|
||||
|
||||
**Unit tests — `PlayerBar.test.ts`:**
|
||||
|
||||
Mocks `$lib/player/store.svelte` to control state and spy on actions.
|
||||
|
||||
- Renders nothing when `player.current === undefined` (or Shell doesn't mount it — tests render PlayerBar directly with a current track).
|
||||
- Renders cover, title, artist link (`href='/artists/:id'`), cover link (`href='/albums/:id'`).
|
||||
- Play button click calls `togglePlay`.
|
||||
- Skip-next/prev click call `skipNext`/`skipPrev`.
|
||||
- Skip-prev disabled when index=0 AND position < 3.
|
||||
- Skip-next disabled when index=length-1, repeat='off'.
|
||||
- Seek slider input calls `seekTo(value)`.
|
||||
- Volume slider input calls `setVolume(value)`.
|
||||
- Shuffle button click calls `toggleShuffle`; active state reflects `player.shuffle`.
|
||||
- Repeat button click calls `cycleRepeat`; three visual states render distinctly (assert on `aria-label` variants).
|
||||
- State='loading' or 'waiting': play icon replaced by spinner (assert on a `data-testid` or SVG class).
|
||||
- State='error': transport region replaced by retry card; clicking retry calls `playQueue` with `player.queue` + `player.index`.
|
||||
- Elapsed + total use `formatDuration` (reuse from library plan).
|
||||
|
||||
**Unit tests — `TrackRow.test.ts` (updated):**
|
||||
|
||||
Keep existing tests; add:
|
||||
- Clicking the row calls `playQueue(tracksProp, indexProp)` — mock the store module.
|
||||
- Row renders as a `<button>` (or `<div role="button" tabindex="0">` — spec requires a keyboard-activatable element; `<button>` is simpler).
|
||||
|
||||
**Manual integration (Plan Task N verification):**
|
||||
|
||||
1. Sign in, navigate to an album, click track 3 → bar appears, track plays.
|
||||
2. OS-level check: lock-screen / media notification shows the track metadata and allows play/pause.
|
||||
3. Press media keys on keyboard → play/pause toggles.
|
||||
4. Drag seek slider mid-track → audio jumps to new position.
|
||||
5. Click skip-next through end of album → stops.
|
||||
6. Turn on repeat=all → skip-next past end wraps to track 1.
|
||||
7. Turn on repeat=one → track loops at end.
|
||||
8. Turn on shuffle → future skips land on random remaining tracks.
|
||||
9. Adjust volume; refresh the page; navigate to a new track — volume restored from localStorage.
|
||||
10. Disconnect network mid-track → error card appears; click "Try again" → recovers when network returns.
|
||||
11. Navigate between artists / albums / the library root while a track is playing — playback continues, bar stays put.
|
||||
|
||||
## Dependencies added
|
||||
|
||||
None. Everything uses the existing stack (SvelteKit 2, Svelte 5 runes, Vitest, Testing Library, jsdom). The MediaSession API is native to browsers; no polyfill.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Clicking any track starts playback with the rest of the album queued.
|
||||
- All controls (play/pause, skip ±1, seek, volume, shuffle, repeat) behave per Section 3 of this spec.
|
||||
- MediaSession metadata and action handlers are populated; OS media controls work.
|
||||
- Playback survives navigation within the SPA.
|
||||
- Volume persists across page loads.
|
||||
- Broken streams show the error card; retry recovers.
|
||||
- All new unit + page tests pass (`npm test` in `web/`).
|
||||
- No backend changes — the plan is pure frontend consumption of the existing `/api/tracks/:id/stream`.
|
||||
Reference in New Issue
Block a user