Initial Commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
# Design — enterprise-react-audio-player
|
||||
|
||||
`@suaveplan/react-audio-player` is a **UI suite** layered on `@suaveplan/react-audio`. Its job is to give a consumer a one-component drop-in player (`<BasicPlayer>` / `<AudioPlayer>` / `<PremiumPlayer>`) that takes a track URL or `Playlist` prop and renders a complete, accessible, beautiful audio player UI, with everything from the source plugin down handled by the unified architecture.
|
||||
|
||||
Cross-cutting architecture: see `openspec/changes/enterprise-audio-fx-plugin-base/design.md` and `openspec/changes/enterprise-react-audio/design.md`. This document covers only the player-specific concerns.
|
||||
|
||||
---
|
||||
|
||||
## 1. Player variants
|
||||
|
||||
Three player components share state via the `audio-player-provider` and a Zustand store, differing only in layout density and feature set:
|
||||
|
||||
- **`<BasicPlayer>`** — single-track playback, play/pause/seek/volume, no chain, no visualiser. ~5 KB tree-shaken.
|
||||
- **`<AudioPlayer>`** — adds playlist, 3-band EQ, waveform visualiser, repeat/shuffle. Re-enabled from upstream "commented-out" state via proper subpath exports.
|
||||
- **`<PremiumPlayer>`** — adds spectrum analyser, R3F screensavers, karaoke (LRC lyrics), multi-peer playback sync via `react-audio-stream`.
|
||||
|
||||
The three sit on the same store and the same `AudioPlayerPlugin`. Switching components without unmounting the provider preserves playback state. Each variant is a separate subpath export; importing one does not pull in the others.
|
||||
|
||||
---
|
||||
|
||||
## 2. Per-player chain layout
|
||||
|
||||
The player constructs a fixed track on the timeline (`player-track`) with a fixed chain:
|
||||
|
||||
```
|
||||
chain [
|
||||
AudioPlayerPlugin ← source plugin (index 0)
|
||||
EQPlugin (3-band, in BasicPlayer this slot is absent)
|
||||
...consumer-added FX slots...
|
||||
]
|
||||
```
|
||||
|
||||
`<AudioPlayer>` and `<PremiumPlayer>` expose a `<PluginChainView chain>` slot so consumers can add reverb, delay, etc. The chain owns the FX rack; the player owns the source.
|
||||
|
||||
The source plugin's `'ended'` event drives playlist advance via `usePlaylist`; the playlist hook calls `audioPlayerPlugin.load(nextSource)` and `audioPlayerPlugin.play()`.
|
||||
|
||||
---
|
||||
|
||||
## 3. LRC karaoke
|
||||
|
||||
LRC is parsed once into `{ timestampSec, text }[]` and stored in `useLyricsStore`. `<LyricsDisplay>` reads `useTransportTime()` at frame rate and binary-searches the lyric list to find the active line. Active line highlight uses `framer-motion` with `prefers-reduced-motion` honoured.
|
||||
|
||||
```ts
|
||||
const activeIndex = useMemo(
|
||||
() => binarySearchActive(lyrics, currentTimeSec),
|
||||
[lyrics, Math.floor(currentTimeSec * 10)] // re-bind every 100 ms
|
||||
)
|
||||
```
|
||||
|
||||
The binary search is O(log n); a 1000-line LRC re-evaluates in ~10 comparisons per 100 ms — well below 1 ms total.
|
||||
|
||||
---
|
||||
|
||||
## 4. Visualisers
|
||||
|
||||
`<WaveformVisualizer>` and `<SpectrumAnalyzer>` both consume `useAnalyser(trackId | 'master')` from `react-audio`. The hook samples the analyser at frame rate; the visualisers render to `<canvas>` (waveform) or `<canvas>` with FFT-bin-to-pixel mapping (spectrum). No new analyser instance is constructed; both share the chain's master analyser node.
|
||||
|
||||
---
|
||||
|
||||
## 5. R3F screensavers (optional)
|
||||
|
||||
`<GLBViewer src animation?>` and `<ShaderBackground shader uniforms>` are R3F components gated to the `./screensavers/*` subpath. They are not pulled in unless explicitly imported. The screensaver wraps the player UI as a backdrop; opacity and visibility are React-controlled.
|
||||
|
||||
Three.js is a peer dep — non-screensaver consumers pay nothing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Multi-peer sync (optional)
|
||||
|
||||
`<PremiumPlayer multiPeer={{ roomId, peers }}>` consumes `useMultiPeerSync` from this package, which in turn consumes hooks from `@suaveplan/react-audio-stream` to negotiate WebRTC peers, share transport `play/pause/seek` events across peers via a control channel, and stream the player's audio output to peers via the engine's tap-to-bus surface. The control protocol uses `@suaveplan/audio-transport` as the source of truth for transport state, so a peer disconnect does not desync the local player.
|
||||
|
||||
`react-audio-stream` is a peer dep of this subpath — basic/audio players don't pay for it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Persistence
|
||||
|
||||
A `PlayerPersistenceStore` interface backs:
|
||||
|
||||
- the playlist (`{ tracks: Track[], position: number, repeat: 'off'|'one'|'all', shuffle: boolean }`),
|
||||
- the EQ preset (`{ schemaVersion, bands: [...] }` validated against `EQPlugin.schema`),
|
||||
- the user's volume + mute state.
|
||||
|
||||
Default implementation: `localStorage` keyed on the provider's `id` prop. Consumers may inject a different store (cloud-sync, IndexedDB, SSR cookie). Reads return defaults; writes are debounced (300 ms).
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility
|
||||
|
||||
Inherits everything from `react-audio`: keyboard, ARIA, reduced-motion. Player-specific additions:
|
||||
|
||||
- `<ProgressBar>` is a `role="slider"` keyboard-seekable with arrow keys for 5 s, page-up/down for 30 s.
|
||||
- `<Queue>` is `role="listbox"` with up/down navigation and `Enter` to play.
|
||||
- `<LyricsDisplay>` exposes the active lyric as `aria-live="polite"` so screen readers announce lyric changes (configurable off via `announceLyrics={false}`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Decisions and trade-offs
|
||||
|
||||
- **Three variants, one source plugin, one chain.** Sharing the source + chain across variants means switching variants doesn't restart playback.
|
||||
- **`<AudioPlayer>` and `<PremiumPlayer>` expose the chain via `<PluginChainView>`.** Consumers extend FX without breaking the player UI.
|
||||
- **LRC binary search re-binds every 100 ms.** Frame-rate re-bind (every 16 ms) is unnecessary; lyrics don't change that fast.
|
||||
- **Screensavers and multi-peer sync are opt-in subpaths.** R3F and WebRTC peer deps are paid only by consumers that actually use them.
|
||||
- **Persistence is injectable.** Cloud sync, SSR session, IndexedDB — all the same interface; localStorage is just the default.
|
||||
- **No new analyser node.** Reuse the engine's master analyser; one instance per chain, not per visualiser.
|
||||
@@ -0,0 +1,112 @@
|
||||
# OpenSpec Change: enterprise-react-audio-player
|
||||
|
||||
- **Change ID**: enterprise-react-audio-player
|
||||
- **Package**: `@suaveplan/react-audio-player`
|
||||
- **Category**: react
|
||||
- **Target path**: `packages/ui/react/react-audio-player`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-15
|
||||
- **Design**: `./design.md`
|
||||
- **Spec delta**: `./specs/react-audio-player/spec.md`
|
||||
|
||||
> Replaces the prior 2026-04-24 draft flagged `NEEDS-REWRITE`. See `../REACT_AUDIO_WAVE_REWRITE_COMPLETE.md`.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/react-audio-player` on genesis from scratch at enterprise quality. The package is a track-style media player UI suite built on the unified architecture: it consumes `AudioPlayerPlugin` from `@suaveplan/audio-fx-plugin-base` as its playback source, hosts an optional FX chain via the universal `PluginChain` primitive, exposes a Zustand-backed player store layered over the transport, and provides three player variants (`<BasicPlayer>`, `<AudioPlayer>`, `<PremiumPlayer>`) plus karaoke (LRC parsing + `<LyricsDisplay>`), localStorage-backed playlist, two visualisers (waveform + spectrum) sourced from `useAnalyser`, two R3F screensavers (`<GLBViewer>`, `<ShaderBackground>`), and (in the premium variant) WebRTC multi-peer playback sync via `@suaveplan/react-audio-stream`.
|
||||
|
||||
The package owns **no DSP, no transport scheduler, no parameter automation, no audio context**. Every audio operation goes through `@suaveplan/react-audio`'s hooks; UI state goes into a per-provider Zustand store; persistence goes through an injectable store (default `localStorage`).
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
The package does not exist on genesis yet — fresh build replacing the obsolete pre-tier 2026-04-24 draft.
|
||||
|
||||
---
|
||||
|
||||
## Parity Checklist (vs upstream `../suaveplan/packages/react/media/react-audio-player/`)
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|:---:|:---:|---|
|
||||
| Zustand `useAudioPlayerStore` | ✅ | ⬆️ | Slimmer; no playback state (lives on transport). UI + playlist + lyrics state only. |
|
||||
| `<AudioPlayerProvider>` | ✅ | ✅ | Thin wrapper that nests `<AudioProvider>` from `react-audio` and adds player-specific stores. |
|
||||
| `createPluginManager` | ✅ | ⚠️ refactored | Replaced by the generic `<PluginChainView>` from `react-audio`; consumers can drop in any `AudioFxPlugin`. |
|
||||
| `<PlayPauseButton>` | ✅ | ✅ | Uses `useTransport().play/pause`. |
|
||||
| `<VolumeControl>` | ✅ | ✅ | Drives the player's `AudioPlayerPlugin` volume parameter. |
|
||||
| `<ProgressBar>` | ✅ | ✅ | Reads `useTransportTime`. |
|
||||
| `<Queue>` (playlist) | ✅ | ✅ | Backed by `usePlaylist` from `react-audio`. |
|
||||
| 3-band EQ + presets | ✅ | ⬆️ | Built on `EQPlugin` from `audio-fx-plugin-base`. Presets via `usePreset`. |
|
||||
| LRC karaoke parser | ✅ | ✅ | Universal parser; no UI deps. |
|
||||
| `<LyricsDisplay>` | ✅ | ✅ | Reads `useTransportTime` for per-line highlight. |
|
||||
| `createNetworkSyncManager` (WebRTC/WebSocket multi-peer) | ✅ | ⬆️ | Delegated to `@suaveplan/react-audio-stream`; this package consumes its hooks. |
|
||||
| localStorage-backed track storage | ✅ | ✅ | Through injectable store (default `localStorage`). |
|
||||
| `<GLBViewer>` R3F screensaver | ✅ | ✅ | Optional subpath; r3f peer dep. |
|
||||
| `<ShaderBackground>` R3F screensaver | ✅ | ✅ | Optional subpath; r3f peer dep. |
|
||||
| `<WaveformVisualizer>` | ✅ | ⬆️ | Reads `useAnalyser` from `react-audio`. |
|
||||
| `<SpectrumAnalyzer>` | ✅ | ⬆️ | Reads `useAnalyser` from `react-audio`. |
|
||||
| Coverage | partial | ✅ | 100/100/100/100. |
|
||||
| Storybook | partial | ✅ | Every component: default + interactive + edge. |
|
||||
|
||||
No 🚫 rows.
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/react-audio` (`workspace:^`) | Provider + hooks (`useAudioFxPlugin`, `useTransport`, `useTransportTime`, `useAnalyser`, `usePlaylist`) |
|
||||
| `@suaveplan/audio-fx-plugin-base` (`workspace:^`) | `AudioPlayerPlugin`, `EQPlugin`, `PluginChain` |
|
||||
| `@suaveplan/audio-transport` (`workspace:^`) | Transport reference |
|
||||
| `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `@suaveplan/events` | foundations |
|
||||
| `react`, optional `@react-three/fiber` + `three` for screensavers (peer deps; gated by subpath) |
|
||||
| `zustand` (`catalog:state`), `framer-motion` (`catalog:animation`), `lucide-react` (`catalog:ui`), `zod` (`catalog:forms`) |
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/ui/react/react-audio-player/
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts
|
||||
provider/ audio-player-provider/
|
||||
stores/ use-audio-player-store/ use-playlist-store/ use-lyrics-store/
|
||||
hooks/ use-audio-player/ use-playlist-controls/ use-lyrics/ use-eq-band/
|
||||
karaoke/ lrc-parser/ lyrics-display/ use-active-lyric/
|
||||
components/
|
||||
basic-player/ audio-player/ premium-player/
|
||||
play-pause-button/ volume-control/ progress-bar/
|
||||
queue/ eq-panel/ preset-selector-3band/
|
||||
waveform-visualizer/ spectrum-analyzer/
|
||||
screensavers/ ← optional subpaths, R3F-gated
|
||||
glb-viewer/ shader-background/
|
||||
sync/ ← optional subpaths, WebRTC-gated
|
||||
use-multi-peer-sync/
|
||||
__benchmarks__/
|
||||
lrc-parse.bench.ts
|
||||
visualizer.bench.ts
|
||||
tests/
|
||||
integration.test.tsx
|
||||
purity.test.ts
|
||||
exports.test.ts
|
||||
e2e/
|
||||
play.spec.ts
|
||||
karaoke.spec.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Breaking changes**: none — fresh build.
|
||||
|
||||
**Prerequisites**: `react-audio`, `audio-fx-plugin-base`, `audio-transport`, foundations.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
No dependents in this batch (verified by `bunx turbo ls --filter=...@suaveplan/react-audio-player`).
|
||||
@@ -0,0 +1,178 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: react-audio-player (enterprise-react-audio-player)
|
||||
|
||||
**Package**: `@suaveplan/react-audio-player`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Player-tier purity — no DSP, no audio context
|
||||
|
||||
`@suaveplan/react-audio-player` SHALL import zero browser audio APIs (`AudioContext`, `OfflineAudioContext`, `AudioNode`, `AudioWorklet`) and zero Node-only APIs. Every audio surface is reached through `@suaveplan/react-audio` hooks and `@suaveplan/audio-fx-plugin-base` plugins. Runtime `dependencies` SHALL be limited to `@suaveplan/react-audio`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/audio-transport`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `@suaveplan/events`, `zustand`, `framer-motion`, `lucide-react`, `zod`, `tslib`. `react`, `@react-three/fiber`, `three`, and `@suaveplan/react-audio-stream` SHALL be peer dependencies; the latter three SHALL be required only by their respective optional subpath exports (`./screensavers/*`, `./sync/*`).
|
||||
|
||||
#### Scenario: built dist references no audio context
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd
|
||||
- **THEN** it MUST NOT reference `AudioContext`, `OfflineAudioContext`, `AudioNode`, or `AudioWorklet`
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"dist references no audio context"` AND `tests/purity.test.ts::"runtime dependency set matches the allowlist"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioPlayerProvider nests AudioProvider and owns player stores
|
||||
|
||||
The package SHALL export `<AudioPlayerProvider id options>` which wraps a `<AudioProvider>` from `@suaveplan/react-audio` and additionally constructs one `useAudioPlayerStore`, `usePlaylistStore`, and `useLyricsStore` (Zustand) scoped to its React subtree. On mount the provider SHALL construct one `AudioPlayerPlugin` instance via the inner provider's `PluginRegistry`, insert it as the source plugin of a `player-track` chain, and forward `'ended'` events to the playlist for auto-advance. On unmount it SHALL dispose the player track and stores.
|
||||
|
||||
#### Scenario: provider constructs one source plugin per id
|
||||
|
||||
- **WHEN** two `<AudioPlayerProvider id="a">` instances mount with different ids
|
||||
- **THEN** two distinct `AudioPlayerPlugin` instances exist, each in its own `player-track` chain
|
||||
|
||||
#### Scenario: 'ended' event advances the playlist
|
||||
|
||||
- **WHEN** the source plugin emits `'ended'` and the playlist's `position` is not at the last item
|
||||
- **THEN** the next track's source is loaded, the player plays, and the playlist `position` advances by 1
|
||||
|
||||
**Verified by:** `src/provider/audio-player-provider.test.tsx::"constructs one source plugin per provider id"` AND `src/provider/audio-player-provider.test.tsx::"ended event advances the playlist"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Three player variants share state through the provider
|
||||
|
||||
The package SHALL export `<BasicPlayer>`, `<AudioPlayer>`, and `<PremiumPlayer>`. All three SHALL read from and write to the same provider stores so swapping between them while the provider remains mounted SHALL preserve playback position, volume, mute, playlist, repeat/shuffle, and EQ settings. `<BasicPlayer>` SHALL render only `<PlayPauseButton>`, `<VolumeControl>`, and `<ProgressBar>`. `<AudioPlayer>` SHALL additionally render `<Queue>`, `<EqPanel>` (3-band), and `<WaveformVisualizer>`. `<PremiumPlayer>` SHALL additionally render `<SpectrumAnalyzer>`, optional `<LyricsDisplay>` when `lrc` prop is supplied, and optional `<PluginChainView>` when `showChain` prop is true.
|
||||
|
||||
#### Scenario: swapping variants preserves state
|
||||
|
||||
- **WHEN** `<AudioPlayer>` is playing a track at position 30 s with volume 0.7 and `<PremiumPlayer>` is then rendered under the same provider with no other changes
|
||||
- **THEN** `<PremiumPlayer>` shows the same track playing at ~30 s with volume 0.7 and the playback was not interrupted
|
||||
|
||||
#### Scenario: BasicPlayer does not bundle Queue or visualiser code
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/react-audio-player/components/basic-player` only
|
||||
- **THEN** the tree-shaken bundle contains zero bytes from `<Queue>` or `<WaveformVisualizer>`
|
||||
|
||||
**Verified by:** `src/components/audio-player/audio-player.test.tsx::"swapping variants preserves playback state"` AND `tests/bundle-size/basic-only.size.test.ts::"BasicPlayer subpath does not bundle Queue or visualiser code"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: LRC parser and LyricsDisplay
|
||||
|
||||
The `./karaoke/lrc-parser` subpath SHALL export `parseLrc(source: string): Lyric[]` returning `{ timestampSec: number; text: string }[]` sorted ascending by timestamp. Tags `[ar:]`, `[ti:]`, `[al:]`, `[length:]` SHALL be ignored (no metadata extraction in v1). Lines without a leading timestamp SHALL be skipped. `<LyricsDisplay>` SHALL read `useTransportTime()` and via a binary search re-bound every 100 ms (not every frame) compute the active lyric index; the active line SHALL be highlighted; an optional `announceLyrics` prop (default true) SHALL set `aria-live="polite"` on the active line for screen-reader announcement.
|
||||
|
||||
#### Scenario: parser sorts by timestamp and skips invalid lines
|
||||
|
||||
- **WHEN** `parseLrc("[ar:Foo]\n[00:30.00]Line A\nBroken line\n[00:10.50]Line B")` is called
|
||||
- **THEN** the result is `[{ timestampSec: 10.5, text: "Line B" }, { timestampSec: 30, text: "Line A" }]`
|
||||
|
||||
#### Scenario: active lyric advances with transport time
|
||||
|
||||
- **WHEN** `<LyricsDisplay lyrics={...}>` is rendered and the transport time crosses a lyric's timestamp
|
||||
- **THEN** within 100 ms the active line is the one whose timestamp is the greatest not exceeding current time, and the previous line is no longer highlighted
|
||||
|
||||
**Verified by:** `src/karaoke/lrc-parser/lrc-parser.test.ts::"parser sorts by timestamp and skips invalid lines"` AND `src/karaoke/lyrics-display/lyrics-display.test.tsx::"active lyric advances within 100 ms of transport time"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Persistence is injectable; default is localStorage
|
||||
|
||||
The provider SHALL accept a `persistence?: PlayerPersistenceStore` option; if absent, the package SHALL use a `localStorage`-backed default implementation keyed on the provider's `id` prop. The store SHALL persist `{ playlist: PlaylistState, eqPreset: PresetSnapshot, volume: number, muted: boolean }`. Writes SHALL be debounced 300 ms. Reads at provider mount SHALL initialise the player to the persisted state, but the playlist's `position` SHALL be clamped to a valid range against the loaded `tracks` length.
|
||||
|
||||
#### Scenario: persisted state is restored on remount
|
||||
|
||||
- **WHEN** the consumer mounts `<AudioPlayerProvider id="a">`, sets volume to 0.42, advances the playlist by 2, unmounts, and remounts the same provider
|
||||
- **THEN** on remount `volume === 0.42` and `playlist.position === 2`
|
||||
|
||||
#### Scenario: injected store supersedes localStorage
|
||||
|
||||
- **WHEN** the consumer mounts the provider with an injected `persistence` store that returns a fixed state
|
||||
- **THEN** the player initialises from the injected store's state and `localStorage` is never read
|
||||
|
||||
**Verified by:** `src/provider/audio-player-provider.test.tsx::"persisted state is restored on remount"` AND `src/provider/audio-player-provider.test.tsx::"injected persistence store supersedes localStorage"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Visualisers share the chain's master analyser
|
||||
|
||||
`<WaveformVisualizer>` and `<SpectrumAnalyzer>` SHALL each consume `useAnalyser(trackId | 'master')` from `@suaveplan/react-audio`. The package SHALL NOT construct its own `AnalyserNode`. Multiple visualisers mounted simultaneously SHALL share the same analyser instance and SHALL each render to their own `<canvas>` independently without contending for the analyser's frequency-data buffer.
|
||||
|
||||
#### Scenario: two visualisers share one analyser
|
||||
|
||||
- **WHEN** `<WaveformVisualizer>` and `<SpectrumAnalyzer>` are both mounted in the same provider
|
||||
- **THEN** exactly one `AnalyserNode` is in use within the engine and each visualiser reads its frequency / waveform data independently per frame
|
||||
|
||||
**Verified by:** `src/components/spectrum-analyzer/spectrum-analyzer.test.tsx::"two visualisers share one analyser instance"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: R3F screensavers are gated to optional subpaths
|
||||
|
||||
`<GLBViewer>` and `<ShaderBackground>` SHALL be exported only via the `./screensavers/glb-viewer` and `./screensavers/shader-background` subpaths. The root barrel (`./`) and any non-screensaver subpath SHALL NOT pull in `@react-three/fiber` or `three`. The screensavers SHALL be designed to render behind the player UI (CSS `z-index` configurable).
|
||||
|
||||
#### Scenario: importing the root barrel does not bundle three.js
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/react-audio-player` (root)
|
||||
- **THEN** the tree-shaken bundle contains zero bytes from `three` or `@react-three/fiber`
|
||||
|
||||
**Verified by:** `tests/bundle-size/root-only.size.test.ts::"root barrel does not bundle three.js"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Multi-peer sync is gated to optional subpath
|
||||
|
||||
`useMultiPeerSync` SHALL be exported only via the `./sync/use-multi-peer-sync` subpath and SHALL consume `@suaveplan/react-audio-stream` for WebRTC transport. Transport state synchronisation SHALL use the local `Transport` from the provider as source of truth; on peer disconnect the local transport SHALL continue uninterrupted. A peer reconnect SHALL resync the joining peer to the local transport's current position.
|
||||
|
||||
#### Scenario: peer disconnect does not interrupt local playback
|
||||
|
||||
- **WHEN** a peer disconnects mid-playback
|
||||
- **THEN** the local transport continues at the same position and the player UI remains responsive
|
||||
|
||||
**Verified by:** `src/sync/use-multi-peer-sync/use-multi-peer-sync.test.tsx::"peer disconnect does not interrupt local playback"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ProgressBar and Queue are keyboard-accessible
|
||||
|
||||
`<ProgressBar>` SHALL be a `role="slider"` with arrow keys for 5 s fine seek, page-up/down for 30 s coarse seek, Home/End for start/end of track, `aria-valuetext` formatted as `MM:SS / MM:SS`. `<Queue>` SHALL be `role="listbox"` with up/down navigation, `Enter` to play the focused track, `Delete` to remove from playlist, `Space` to toggle play/pause. Both SHALL respect `prefers-reduced-motion` for any seek-animation transitions.
|
||||
|
||||
#### Scenario: arrow keys on ProgressBar seek by 5 s
|
||||
|
||||
- **WHEN** a user focuses `<ProgressBar>` at time 30 s and presses `→`
|
||||
- **THEN** `transport.seek(35)` is called and `aria-valuenow` updates
|
||||
|
||||
#### Scenario: Queue Delete removes the focused track
|
||||
|
||||
- **WHEN** a user focuses the third Queue item and presses `Delete`
|
||||
- **THEN** the playlist length decreases by 1 and the third track is no longer present
|
||||
|
||||
**Verified by:** `src/components/progress-bar/progress-bar.test.tsx::"arrow keys seek by 5 seconds"` AND `src/components/queue/queue.test.tsx::"Delete removes the focused track from the playlist"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes
|
||||
|
||||
The package SHALL declare its codes via `defineCodes("REACT_AUDIO_PLAYER", [...])` producing `REACT_AUDIO_PLAYER.NO_PROVIDER`, `REACT_AUDIO_PLAYER.PLAYLIST_EMPTY`, `REACT_AUDIO_PLAYER.PERSISTENCE_FAILED`, `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED`, and `REACT_AUDIO_PLAYER.INVALID_OPTIONS`. Export `ReactAudioPlayerErrorCode`, `REACT_AUDIO_PLAYER_ERROR_METADATA`, `registerReactAudioPlayerErrorCodes()`, and a `ReactAudioPlayerError` class extending `AudioError`. Every registered code SHALL be reachable from a real call site exercised by tests.
|
||||
|
||||
#### Scenario: every registered code is reachable
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** each code in `REACT_AUDIO_PLAYER_ERROR_METADATA` is asserted thrown by at least one test
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every REACT_AUDIO_PLAYER code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per surface
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per surface: `.`, `./provider`, `./components`, `./components/<name>`, `./hooks`, `./hooks/<name>`, `./stores`, `./karaoke`, `./screensavers/glb-viewer`, `./screensavers/shader-background`, `./sync/use-multi-peer-sync`. `sideEffects` SHALL be `false`.
|
||||
|
||||
#### Scenario: importing one component does not pull in others
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/react-audio-player/components/basic-player`
|
||||
- **THEN** the tree-shaken bundle contains zero bytes from `<PremiumPlayer>` or `<Queue>`
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every surface has a subpath export entry"` AND `tests/bundle-size/basic-only.size.test.ts::"importing basic-player does not pull in other components"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,86 @@
|
||||
# Tasks: enterprise-react-audio-player
|
||||
|
||||
**Change ID**: enterprise-react-audio-player
|
||||
**Package**: `@suaveplan/react-audio-player`
|
||||
**Target path**: `packages/ui/react/react-audio-player`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/react-audio`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/audio-transport`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `@suaveplan/events`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] 1.1 Directory tree per proposal.
|
||||
- [ ] 1.2 `package.json` at `0.1.0`, `"sideEffects": false`, full subpath exports map (root + provider + components + hooks + stores + karaoke + optional screensavers + optional sync). React 19 peer. `@react-three/fiber`, `three`, `react-audio-stream` as optional peers gated by subpath.
|
||||
- [ ] 1.3 `tsconfig.json`, `vitest.config.ts` (`happy-dom`).
|
||||
|
||||
## 2. Error codes
|
||||
|
||||
- [ ] 2.1 `src/error-codes.ts` — `defineCodes("REACT_AUDIO_PLAYER", [...])`, `ReactAudioPlayerError extends AudioError`.
|
||||
- [ ] 2.2 `error-codes.test.ts` — every code reachable.
|
||||
|
||||
## 3. Provider + stores
|
||||
|
||||
- [ ] 3.1 `src/provider/audio-player-provider.tsx` — nests `<AudioProvider>`, owns three Zustand stores, constructs source plugin in `player-track` chain, wires `'ended'` to playlist advance, disposal on unmount.
|
||||
- [ ] 3.2 `src/stores/use-audio-player-store.ts` — volume / mute / loading-state / chain-visibility.
|
||||
- [ ] 3.3 `src/stores/use-playlist-store.ts` — tracks / position / repeat / shuffle.
|
||||
- [ ] 3.4 `src/stores/use-lyrics-store.ts` — parsed lyrics + active index cache.
|
||||
- [ ] 3.5 `audio-player-provider.test.tsx` covering: per-id source plugin uniqueness, ended → playlist advance, persistence restore, injected store supersedes default, strict-mode safe.
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
- [ ] 4.1 `use-audio-player` — combined controls (play/pause/seek/setVolume/load).
|
||||
- [ ] 4.2 `use-playlist-controls` — next/previous/select/add/remove/clear, repeat/shuffle toggles.
|
||||
- [ ] 4.3 `use-lyrics` — exposes parsed lyrics and active index sampled at 100 ms.
|
||||
- [ ] 4.4 `use-eq-band` — bind a single band (low/mid/high) to the chain's EQ plugin parameters.
|
||||
|
||||
## 5. Karaoke
|
||||
|
||||
- [ ] 5.1 `src/karaoke/lrc-parser/lrc-parser.ts` — sorted, validated parse; ignores metadata tags; skips malformed lines.
|
||||
- [ ] 5.2 `lrc-parser.test.ts` — sort, skip, malformed, large file (10 k lines) under 50 ms.
|
||||
- [ ] 5.3 `src/karaoke/lyrics-display/lyrics-display.tsx` — 100 ms re-bind binary-search active line; `aria-live` toggle.
|
||||
|
||||
## 6. Components (each: `.tsx`, `.md`, `.test.tsx`, `.stories.tsx`)
|
||||
|
||||
- [ ] 6.1 `play-pause-button/`.
|
||||
- [ ] 6.2 `volume-control/`.
|
||||
- [ ] 6.3 `progress-bar/` — keyboard seek, ARIA per spec.
|
||||
- [ ] 6.4 `queue/` — listbox semantics, keyboard CRUD per spec.
|
||||
- [ ] 6.5 `eq-panel/` — 3-band specialised renderer over `EQPlugin`.
|
||||
- [ ] 6.6 `preset-selector-3band/`.
|
||||
- [ ] 6.7 `waveform-visualizer/` — `<canvas>` over `useAnalyser`.
|
||||
- [ ] 6.8 `spectrum-analyzer/`.
|
||||
- [ ] 6.9 `basic-player/`.
|
||||
- [ ] 6.10 `audio-player/`.
|
||||
- [ ] 6.11 `premium-player/` — composes everything, optional chain UI, optional lyrics, optional multi-peer.
|
||||
|
||||
## 7. Optional subpaths
|
||||
|
||||
- [ ] 7.1 `screensavers/glb-viewer/` — R3F-gated component.
|
||||
- [ ] 7.2 `screensavers/shader-background/` — R3F-gated component.
|
||||
- [ ] 7.3 `sync/use-multi-peer-sync/` — WebRTC-gated hook over `react-audio-stream`.
|
||||
|
||||
## 8. Benchmarks
|
||||
|
||||
- [ ] 8.1 `src/__benchmarks__/lrc-parse.bench.ts` — parse a 10 k-line LRC.
|
||||
- [ ] 8.2 `src/__benchmarks__/visualizer.bench.ts` — `<SpectrumAnalyzer>` frame render.
|
||||
|
||||
## 9. Tests
|
||||
|
||||
- [ ] 9.1 `tests/integration.test.tsx` — load playlist, play, advance on ended, EQ change persists across remount.
|
||||
- [ ] 9.2 `tests/purity.test.ts` — built `dist/` references no audio context.
|
||||
- [ ] 9.3 `tests/exports.test.ts` — every surface has subpath; every component has 3 story types.
|
||||
- [ ] 9.4 `tests/bundle-size/{root-only,basic-only,premium-only}.size.test.ts` — gating proof.
|
||||
- [ ] 9.5 `e2e/play.spec.ts` — Playwright play/pause/seek under a real browser.
|
||||
- [ ] 9.6 `e2e/karaoke.spec.ts` — Playwright lyric advancement.
|
||||
|
||||
## 10. Docs + verify
|
||||
|
||||
- [ ] 10.1 All co-located `.md` (≥200 words).
|
||||
- [ ] 10.2 `README.md` at package root with quick-start for each variant + karaoke example + screensaver example.
|
||||
- [ ] 10.3 `bunx turbo lint typecheck test --filter=@suaveplan/react-audio-player` green; coverage 100/100/100/100; banned-pattern sweeps empty.
|
||||
|
||||
## 11. Spec reconciliation + archive
|
||||
|
||||
- [ ] 11.1 Walk every `### Requirement` and `**Verified by:**`.
|
||||
- [ ] 11.2 Fold spec delta into `openspec/specs/react-audio-player/spec.md`.
|
||||
- [ ] 11.3 Move change folder to archive.
|
||||
Reference in New Issue
Block a user