Files
2026-05-27 23:29:53 +01:00

12 KiB

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"