# AudioPlayer (v6)

WebView-safe React audio player. One component, two layouts, waveform-as-progress.
Tuned for 60 fps inside WKWebView (Wails / Electron) and Tauri WebView2.

Architecture, ADRs and the visual direction live at
`packages/ui-tools/@dev/@refactoring6-audioplayer/`.

## Quick start

```tsx
import { LazyAudioPlayer } from '@djangocfg/ui-tools/audio-player';

<LazyAudioPlayer
  src="/audio/track.mp3"
  title="Track"
  artist="Artist"
  cover="/cover.jpg"
/>
```

For non-lazy use (e.g. above-the-fold):

```tsx
import { Player } from '@djangocfg/ui-tools/tools/AudioPlayer';

<Player src="/audio/track.mp3" />
```

## Why v6

The previous Hybrid* surface (now `AudioPlayer_old/`) used a per-frame
`AnalyserNode` redraw, multiple effect components and a single Context that
re-rendered every consumer at 60 Hz. v6 fixes the structural issues:

- Static peaks decoded **once**, painted **twice** (background + foreground),
  playhead animated via CSS `clip-path` + a single CSS variable. Zero canvas
  paints during steady-state playback.
- Three split contexts (State / Controls / Levels) — controls memoised once,
  levels exposed as an imperative store; React doesn't re-render on level
  updates at all.
- One module-level `AudioContext`, `MediaElementSourceNode` cached in a
  `WeakMap` per `<audio>` — Safari context-quota safe and StrictMode safe.

## Props

| Prop | Type | Default | Notes |
|---|---|---|---|
| `src` | `string` | required | |
| `peaks` | `Float32Array` | — | Pre-computed peaks (skips client decode). |
| `title` / `artist` / `album` / `cover` | `string` | — | Drive `<MediaSession>` too. |
| `variant` | `'auto' \| 'default' \| 'compact'` | `'auto'` | `auto` → compact on phones / narrow containers. |
| `waveform.mode` | `'peaks' \| 'live' \| 'bars' \| 'progress' \| 'none'` | `'peaks'` | `progress` is a plain scrubber, no animation. `peaks` falls back to `progress` on decode failure. |
| `waveform.height` | `number` | 40 (default) / 24 (compact) | |
| `reactiveCover` | `false \| 'subtle'` | `false` | Compositor-only scale pulse. |
| `exclusive` | `boolean` | `true` | Pauses sibling players via `activePlayerBus` (cross-tab via `BroadcastChannel`). |
| `seekStartsPlayback` | `boolean` | `true` | Click on the waveform also starts playback when paused. |
| `autoplay` / `loop` / `muted` / `initialVolume` / `preload` | — | — | Standard HTML media. |
| `onPrev` / `onNext` | `() => void` | — | Render skip buttons + `MediaSession` handlers. |
| `onPlay` / `onPause` / `onEnded` / `onError` | callbacks | — | |
| `enableKeyboardShortcuts` | `boolean` | `true` | Active only when focus is inside the player. |
| `ariaLabel` / `className` | `string` | — | |

## Controls

- **Mouse / touch** — click the waveform to seek (and start playback if
  paused); drag to scrub. Hover shows a time tooltip (desktop only).
- **Keyboard** — Space/K play-pause, ←/→ seek 5 s, ↑/↓ volume, M mute, L loop.
- **MediaSession** — wires play / pause / next / prev / seek to OS-level Now
  Playing controls (works in Wails through WKWebView).

## Mobile

- `variant: 'auto'` resolves to `compact` on phone viewports (< 640 px).
- Volume popover opens by tap on touch (no hover); hover affordances are
  hidden via `@media (hover: none)`.
- iOS Safari: the volume slider is hidden (the OS controls hardware volume
  there — `audio.volume` is read-only); mute toggle stays.
- Tap targets ≥ 40 px on coarse pointers.

## Persistent preferences

`volume` and `muted` are stored in `localStorage` (key
`djangocfg-audioplayer:prefs`) and synced across all uncontrolled players in
the page and across tabs (`storage` event). Pass `initialVolume` / `muted` to
opt a specific player out — it then ignores the store and never writes to it.

## Active-player coordination

`exclusive` (default `true`) registers each player with a small in-memory bus.
Only one player plays at a time per page; `BroadcastChannel` extends the same
rule across tabs. Hooks for the bus state:

```tsx
import {
  useActivePlayer,        // currently playing id (or null)
  useLastActivePlayer,    // most recently active id (sticky)
  useIsActivePlayer,      // boolean for a specific id
} from '@djangocfg/ui-tools/audio-player';
```

## Custom layouts (slot composition)

Drop `<PlayerProvider>` and arrange the parts you want yourself. Every part
reads from the player context — pass nothing, just compose.

```tsx
import {
  PlayerProvider,
  Cover, Title, Artist, TimeDisplay,
  PlayButton, VolumeControl, LoopButton,
  Waveform,
} from '@djangocfg/ui-tools/audio-player';

<PlayerProvider src="/track.mp3" title="…" artist="…" cover="/cover.jpg">
  <div className="grid grid-cols-[96px_1fr_auto] gap-4 rounded-lg border bg-card p-4">
    <div className="row-span-2"><Cover size={96} /></div>
    <div className="min-w-0"><Title /><Artist /></div>
    <TimeDisplay />
    <div className="col-span-2 space-y-3">
      <Waveform height={48} />
      <div className="flex items-center justify-between">
        <PlayButton />
        <div className="flex items-center gap-1">
          <VolumeControl />
          <LoopButton />
        </div>
      </div>
    </div>
  </div>
</PlayerProvider>
```

> **Note:** the slot-composed players use Radix `Tooltip` for control labels.
> The default `<Player>` ships its own `TooltipProvider`, but slot composition
> bypasses it — wrap your custom layout in
> `<TooltipProvider>` (from `@djangocfg/ui-core`) once at the app root, or
> tooltips will throw `"Tooltip must be used within TooltipProvider"`.

Re-exported parts: `Cover`, `CoverPlaceholder`, `ReactivePulse`, `Title`,
`Artist`, `TimeDisplay`, `PlayButton`, `SkipButton`, `VolumeControl`,
`LoopButton`, `ControlsRow`, `IconButton`, `Waveform`, `PeaksWaveform`,
`LiveWaveform`, `BarsWaveform`, `ProgressBar`, `WaveformSkeleton`,
`ErrorState`, `DefaultLayout`, `CompactLayout`.

## Selector hooks

For custom toolbars — read state without re-implementing the controls UI:

```tsx
import {
  usePlayerState,        // discriminated union: idle | loading | playing | paused | …
  usePlayerControls,     // play / pause / toggle / seek / setVolume / …
  usePlayerLevels,       // imperative store; for live-mode canvases
  usePlayerMeta,         // src / title / artist / cover
  usePlayerPaused,       // boolean shortcut
  usePlayerDuration,     // number shortcut
  usePlayerPreferences,  // { volume, muted } from the persistent store
} from '@djangocfg/ui-tools/audio-player';
```

These work inside `<Player>`'s tree; if you need your own JSX around the
state, mount `<PlayerProvider>` directly (also exported).

## Stories

Storybook-style stories live in `AudioPlayer.story.tsx`:

- `Default`, `WithCover`, `Compact`, `CustomLayout`, `Bars`, `Live`,
  `NoWaveform`, `ReactiveCover`, `ErrorState`, `Exclusive`, `Interactive`,
  `Showcase` (one-page demo of every prop / mode / hook).

Run `pnpm playground` from `packages/ui-tools/`.

## Related

- Old (reference only): `tools/AudioPlayer_old/`.
- Architecture: `@dev/@refactoring6-audioplayer/`.
