# 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`.
