Files
minstrel/docs/superpowers/plans/2026-04-24-web-ui-player.md
T
bvandeusen 7c19fe8a6a docs(plan): add web UI player implementation plan
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>
2026-04-24 08:57:52 -04:00

52 KiB
Raw Blame History

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:

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
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
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:

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):

// --- 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:

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:

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:

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
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:

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
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
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:

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
<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:

{#each album.tracks as track (track.id)}
  <TrackRow {track} />
{/each}

Replace with:

{#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
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:

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
<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
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:
{#if player.current}
  <div class="col-span-2">
    <PlayerBar />
  </div>
{/if}

Full script block updated:

<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:

<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
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:

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:

  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):

<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
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:

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:

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 12
  • 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 27.
  • 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.