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>
21 KiB
Web UI Player — Design Spec
Date: 2026-04-24 Status: Design approved Follows: Web UI Library Views — 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/sessionstables 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.srcfollowsplayer.current?.stream_urlaudioEl.volumefollowsplayer.volumeaudioEl.play()/pause()followsplayer.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
// 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
// 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 < 3AND_index > 0:_index--, position=0, play. - Else (position ≥ 3, OR at index 0): seek to 0 of current track.
- Disabled visually when
_index === 0AND_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 asoffhere (explicit user skip overrides one-track repeat).
- Disabled when
_index === _queue.length - 1AND_repeat === 'off'.
Auto-advance on ended event (reportStateFromAudio('ended')):
repeat === 'one'→_position = 0,_state='loading'; layout sees state change,currentTimeresets (viaseekTo(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 toplayer.positionwithoninput → seekTo(newValue). seekTowrites_position = secANDaudioEl.currentTime = secviaregisterAudioEl-stored reference.
Volume.
- UI:
<input type="range" min=0 max=1 step=0.01>. setVolume(v)clamps to[0, 1], writes_volume = v, persists vialocalStorage.setItem('minstrel.volume', String(v)).- Layout's
$effectonplayer.volumepushes toaudioEl.volume. - Module-load-time helper
readStoredVolume(): numberreadslocalStorage.getItem('minstrel.volume')with1.0fallback 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/endedbehavior, which at the moment IS "walk through queue in order" so no difference. (Simpler than tracking an original order.)
- Enabling: Fisher-Yates shuffle
- 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.
TrackRowonclick→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
<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
// 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_COVERfrom library plan. - Center (flex-1, stacked):
- Top row:
{elapsedTime} {seek slider (flex-1)} {totalDuration}— single row, elapsed/total usetabular-numsand a fixedchwidth so the slider doesn't jitter aspositionticks. - Bottom row:
⏮ ⏯ ⏭centered under the slider. Play/pause glyph swaps based onisPlaying. Duringloading/waiting, swap play icon for a small spinner.
- Top row:
- 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 callsplayQueue(_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.skipNextmid-queue: index++, position=0.skipNextat end withrepeat='off': state='paused', index=queue.length-1, position=duration.skipNextat end withrepeat='all': index=0.skipNextat end withrepeat='one': same as 'off' (explicit skip overrides one-track repeat).skipPrevwith position < 3 AND index > 0: index--.skipPrevwith position >= 3: index unchanged; position=0.skipPrevat index 0: position=0 regardless.reportStateFromAudio('ended')withrepeat='one': position=0, index unchanged, state='loading'.reportStateFromAudio('ended')withrepeat='all': wraps_index = (_index + 1) % _queue.length.reportStateFromAudio('ended')withrepeat='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; ifregisterAudioElhas been called with a mock element, that element'scurrentTime === 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.currentis set,navigator.mediaSession.metadatahas title/artist/album/artwork matching the current track. - Metadata cleared to
nullwhenplayer.currentbecomes undefined. - All four action handlers registered with callables that dispatch to the corresponding store action.
playbackStateupdates whenisPlayingflips.setPositionStatecalled 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 reflectsplayer.shuffle. - Repeat button click calls
cycleRepeat; three visual states render distinctly (assert onaria-labelvariants). - State='loading' or 'waiting': play icon replaced by spinner (assert on a
data-testidor SVG class). - State='error': transport region replaced by retry card; clicking retry calls
playQueuewithplayer.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):
- Sign in, navigate to an album, click track 3 → bar appears, track plays.
- OS-level check: lock-screen / media notification shows the track metadata and allows play/pause.
- Press media keys on keyboard → play/pause toggles.
- Drag seek slider mid-track → audio jumps to new position.
- Click skip-next through end of album → stops.
- Turn on repeat=all → skip-next past end wraps to track 1.
- Turn on repeat=one → track loops at end.
- Turn on shuffle → future skips land on random remaining tracks.
- Adjust volume; refresh the page; navigate to a new track — volume restored from localStorage.
- Disconnect network mid-track → error card appears; click "Try again" → recovers when network returns.
- 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 testinweb/). - No backend changes — the plan is pure frontend consumption of the existing
/api/tracks/:id/stream.