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

```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`**

```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**

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

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

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

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

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

```ts
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**

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

```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`**

```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**

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

```ts
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`**

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

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

Replace with:

```svelte
{#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**

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

```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`**

```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**

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

```svelte
{#if player.current}
  <div class="col-span-2">
    <PlayerBar />
  </div>
{/if}
```

Full script block updated:

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

```svelte
<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**

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

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

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

```svelte
<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**

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

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

```bash
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 1–2
- `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 2–7.
- `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.
