5.7 KiB
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 viareact-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.
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 againstEQPlugin.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 arole="slider"keyboard-seekable with arrow keys for 5 s, page-up/down for 30 s.<Queue>isrole="listbox"with up/down navigation andEnterto play.<LyricsDisplay>exposes the active lyric asaria-live="polite"so screen readers announce lyric changes (configurable off viaannounceLyrics={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.