Files
minstrel/docs/superpowers/specs/2026-04-24-web-ui-player-design.md
T
bvandeusen d6ea734152 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>
2026-04-24 08:50:58 -04:00

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 / 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 currentTimetimeupdate 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 < 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 onclickplayQueue(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_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.