179 lines
12 KiB
Markdown
179 lines
12 KiB
Markdown
<!-- 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 -->
|