@djangocfg/ui-tools 2.1.312 → 2.1.313

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +38 -22
  2. package/dist/{DocsLayout-W5JLRNSZ.mjs → DocsLayout-ESVQZO3V.mjs} +3 -3
  3. package/dist/{DocsLayout-W5JLRNSZ.mjs.map → DocsLayout-ESVQZO3V.mjs.map} +1 -1
  4. package/dist/{DocsLayout-ZXD2CUOH.cjs → DocsLayout-KUPDWJ3G.cjs} +48 -48
  5. package/dist/{DocsLayout-ZXD2CUOH.cjs.map → DocsLayout-KUPDWJ3G.cjs.map} +1 -1
  6. package/dist/Player-M3GC3VPE.mjs +4 -0
  7. package/dist/Player-M3GC3VPE.mjs.map +1 -0
  8. package/dist/Player-ZGQKKOWI.css +65 -0
  9. package/dist/Player-ZGQKKOWI.css.map +1 -0
  10. package/dist/Player-ZL2X5LGG.cjs +13 -0
  11. package/dist/Player-ZL2X5LGG.cjs.map +1 -0
  12. package/dist/{chunk-CXVGN6ZW.cjs → chunk-DFTVB66S.cjs} +7 -6
  13. package/dist/chunk-DFTVB66S.cjs.map +1 -0
  14. package/dist/{chunk-2QY3LJR6.mjs → chunk-EUADAUBQ.mjs} +5 -4
  15. package/dist/chunk-EUADAUBQ.mjs.map +1 -0
  16. package/dist/chunk-FX2QFYWF.mjs +2059 -0
  17. package/dist/chunk-FX2QFYWF.mjs.map +1 -0
  18. package/dist/{chunk-6HNAPVZ2.mjs → chunk-GBLQTHWT.mjs} +11 -13
  19. package/dist/chunk-GBLQTHWT.mjs.map +1 -0
  20. package/dist/{chunk-FYLR232K.cjs → chunk-S44PW6NK.cjs} +11 -13
  21. package/dist/chunk-S44PW6NK.cjs.map +1 -0
  22. package/dist/chunk-ZLQHUZDU.cjs +2061 -0
  23. package/dist/chunk-ZLQHUZDU.cjs.map +1 -0
  24. package/dist/components-WYEZL5TE.cjs +26 -0
  25. package/dist/{components-3RTH76CV.cjs.map → components-WYEZL5TE.cjs.map} +1 -1
  26. package/dist/components-ZAGG2PBO.mjs +5 -0
  27. package/dist/{components-5GVVL2Q6.mjs.map → components-ZAGG2PBO.mjs.map} +1 -1
  28. package/dist/index.cjs +36 -220
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.css +65 -0
  31. package/dist/index.css.map +1 -1
  32. package/dist/index.d.cts +44 -500
  33. package/dist/index.d.ts +44 -500
  34. package/dist/index.mjs +16 -62
  35. package/dist/index.mjs.map +1 -1
  36. package/package.json +6 -6
  37. package/src/components/markdown/MarkdownMessage/ActionRow.tsx +48 -0
  38. package/src/components/markdown/MarkdownMessage/ChatMessageRow.tsx +97 -0
  39. package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +9 -13
  40. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +77 -2
  41. package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +2 -3
  42. package/src/components/markdown/MarkdownMessage/README.md +72 -0
  43. package/src/components/markdown/MarkdownMessage/components.tsx +3 -3
  44. package/src/components/markdown/MarkdownMessage/index.ts +6 -0
  45. package/src/index.ts +2 -11
  46. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +454 -107
  47. package/src/tools/AudioPlayer/Player.tsx +80 -0
  48. package/src/tools/AudioPlayer/PlayerShell.tsx +122 -0
  49. package/src/tools/AudioPlayer/README.md +139 -204
  50. package/src/tools/AudioPlayer/audio/audioContext.ts +39 -0
  51. package/src/tools/AudioPlayer/audio/decodePeaks.ts +36 -0
  52. package/src/tools/AudioPlayer/audio/index.ts +4 -0
  53. package/src/tools/AudioPlayer/audio/mediaElementSourceCache.ts +20 -0
  54. package/src/tools/AudioPlayer/audio/peaksCache.ts +37 -0
  55. package/src/tools/AudioPlayer/context/AudioRefContext.tsx +9 -0
  56. package/src/tools/AudioPlayer/context/ControlsContext.tsx +7 -0
  57. package/src/tools/AudioPlayer/context/LevelsContext.tsx +7 -0
  58. package/src/tools/AudioPlayer/context/MetaContext.tsx +16 -0
  59. package/src/tools/AudioPlayer/context/PlayerProvider.tsx +314 -0
  60. package/src/tools/AudioPlayer/context/StateContext.tsx +7 -0
  61. package/src/tools/AudioPlayer/context/index.ts +16 -15
  62. package/src/tools/AudioPlayer/context/selectors.ts +36 -0
  63. package/src/tools/AudioPlayer/hooks/index.ts +12 -39
  64. package/src/tools/AudioPlayer/hooks/useActivePlayer.ts +31 -0
  65. package/src/tools/AudioPlayer/hooks/useAnalyser.ts +62 -0
  66. package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +102 -0
  67. package/src/tools/AudioPlayer/hooks/useKeyboardShortcuts.ts +91 -0
  68. package/src/tools/AudioPlayer/hooks/useMediaSession.ts +74 -0
  69. package/src/tools/AudioPlayer/hooks/usePeaks.ts +83 -0
  70. package/src/tools/AudioPlayer/hooks/usePlayerPreferences.ts +21 -0
  71. package/src/tools/AudioPlayer/hooks/usePlayheadLoop.ts +77 -0
  72. package/src/tools/AudioPlayer/hooks/useResizeObserver.ts +20 -0
  73. package/src/tools/AudioPlayer/hooks/useThemeWatcher.ts +22 -0
  74. package/src/tools/AudioPlayer/index.ts +63 -134
  75. package/src/tools/AudioPlayer/lazy.tsx +8 -97
  76. package/src/tools/AudioPlayer/parts/Controls/ControlsRow.tsx +30 -0
  77. package/src/tools/AudioPlayer/parts/Controls/IconButton.tsx +62 -0
  78. package/src/tools/AudioPlayer/parts/Controls/LoopButton.tsx +33 -0
  79. package/src/tools/AudioPlayer/parts/Controls/PlayButton.tsx +86 -0
  80. package/src/tools/AudioPlayer/parts/Controls/SkipButton.tsx +17 -0
  81. package/src/tools/AudioPlayer/parts/Controls/VolumeControl.tsx +171 -0
  82. package/src/tools/AudioPlayer/parts/Controls/index.ts +6 -0
  83. package/src/tools/AudioPlayer/parts/Cover/Cover.tsx +24 -0
  84. package/src/tools/AudioPlayer/parts/Cover/CoverPlaceholder.tsx +27 -0
  85. package/src/tools/AudioPlayer/parts/Cover/ReactivePulse.tsx +66 -0
  86. package/src/tools/AudioPlayer/parts/Cover/index.ts +3 -0
  87. package/src/tools/AudioPlayer/parts/ErrorState/ErrorState.tsx +35 -0
  88. package/src/tools/AudioPlayer/parts/ErrorState/index.ts +1 -0
  89. package/src/tools/AudioPlayer/parts/Layout/CompactLayout.tsx +25 -0
  90. package/src/tools/AudioPlayer/parts/Layout/DefaultLayout.tsx +48 -0
  91. package/src/tools/AudioPlayer/parts/Layout/index.ts +2 -0
  92. package/src/tools/AudioPlayer/parts/Meta/Artist.tsx +14 -0
  93. package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +49 -0
  94. package/src/tools/AudioPlayer/parts/Meta/Title.tsx +13 -0
  95. package/src/tools/AudioPlayer/parts/Meta/index.ts +3 -0
  96. package/src/tools/AudioPlayer/parts/Skeleton/CoverSkeleton.tsx +13 -0
  97. package/src/tools/AudioPlayer/parts/Skeleton/MetaSkeleton.tsx +10 -0
  98. package/src/tools/AudioPlayer/parts/Skeleton/index.ts +2 -0
  99. package/src/tools/AudioPlayer/parts/Waveform/BarsWaveform.tsx +48 -0
  100. package/src/tools/AudioPlayer/parts/Waveform/LiveWaveform.tsx +95 -0
  101. package/src/tools/AudioPlayer/parts/Waveform/PeaksWaveform.tsx +100 -0
  102. package/src/tools/AudioPlayer/parts/Waveform/ProgressBar.tsx +76 -0
  103. package/src/tools/AudioPlayer/parts/Waveform/Waveform.tsx +74 -0
  104. package/src/tools/AudioPlayer/parts/Waveform/WaveformSkeleton.tsx +16 -0
  105. package/src/tools/AudioPlayer/parts/Waveform/index.ts +8 -0
  106. package/src/tools/AudioPlayer/parts/Waveform/waveformInteraction.ts +106 -0
  107. package/src/tools/AudioPlayer/parts/Waveform/waveformRenderer.ts +91 -0
  108. package/src/tools/AudioPlayer/parts/index.ts +1 -0
  109. package/src/tools/AudioPlayer/store/activePlayerBus.ts +63 -0
  110. package/src/tools/AudioPlayer/store/createLevelsStore.ts +37 -0
  111. package/src/tools/AudioPlayer/store/index.ts +16 -0
  112. package/src/tools/AudioPlayer/store/preferencesStore.ts +104 -0
  113. package/src/tools/AudioPlayer/styles/webview-safe.css +77 -0
  114. package/src/tools/AudioPlayer/types.ts +95 -0
  115. package/src/tools/AudioPlayer/utils/bucketize.ts +27 -0
  116. package/src/tools/AudioPlayer/utils/clamp.ts +5 -0
  117. package/src/tools/AudioPlayer/utils/dpr.ts +19 -0
  118. package/src/tools/AudioPlayer/utils/formatTime.ts +12 -8
  119. package/src/tools/AudioPlayer/utils/index.ts +4 -5
  120. package/src/tools/AudioPlayer/utils/readCssVar.ts +7 -0
  121. package/src/tools/AudioPlayer/utils/resolveCanvasColor.ts +28 -0
  122. package/src/tools/index.ts +5 -75
  123. package/dist/chunk-2QY3LJR6.mjs.map +0 -1
  124. package/dist/chunk-6HNAPVZ2.mjs.map +0 -1
  125. package/dist/chunk-CXVGN6ZW.cjs.map +0 -1
  126. package/dist/chunk-F2N7P5XU.cjs +0 -30
  127. package/dist/chunk-F2N7P5XU.cjs.map +0 -1
  128. package/dist/chunk-FYLR232K.cjs.map +0 -1
  129. package/dist/chunk-HMHIVEMS.mjs +0 -1619
  130. package/dist/chunk-HMHIVEMS.mjs.map +0 -1
  131. package/dist/chunk-JWB2EWQO.mjs +0 -5
  132. package/dist/chunk-JWB2EWQO.mjs.map +0 -1
  133. package/dist/chunk-YZX6FH3H.cjs +0 -1656
  134. package/dist/chunk-YZX6FH3H.cjs.map +0 -1
  135. package/dist/components-3RTH76CV.cjs +0 -27
  136. package/dist/components-5GVVL2Q6.mjs +0 -5
  137. package/dist/components-CPHOUQ5F.cjs +0 -46
  138. package/dist/components-CPHOUQ5F.cjs.map +0 -1
  139. package/dist/components-OTK43IMD.mjs +0 -6
  140. package/dist/components-OTK43IMD.mjs.map +0 -1
  141. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +0 -225
  142. package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +0 -163
  143. package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +0 -284
  144. package/src/tools/AudioPlayer/components/HybridWaveform.tsx +0 -286
  145. package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +0 -151
  146. package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +0 -110
  147. package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +0 -58
  148. package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +0 -45
  149. package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +0 -82
  150. package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +0 -8
  151. package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +0 -6
  152. package/src/tools/AudioPlayer/components/index.ts +0 -23
  153. package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +0 -158
  154. package/src/tools/AudioPlayer/effects/index.ts +0 -412
  155. package/src/tools/AudioPlayer/hooks/useAudioBus.ts +0 -76
  156. package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +0 -403
  157. package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +0 -96
  158. package/src/tools/AudioPlayer/hooks/useVisualization.tsx +0 -207
  159. package/src/tools/AudioPlayer/types/effects.ts +0 -73
  160. package/src/tools/AudioPlayer/types/index.ts +0 -27
  161. package/src/tools/AudioPlayer/utils/debug.ts +0 -14
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+
3
+ import { createContext } from 'react';
4
+
5
+ export type PlayerMeta = {
6
+ src: string;
7
+ title?: string;
8
+ artist?: string;
9
+ album?: string;
10
+ cover?: string;
11
+ hasPrev: boolean;
12
+ hasNext: boolean;
13
+ };
14
+
15
+ export const MetaCtx = createContext<PlayerMeta | null>(null);
16
+ MetaCtx.displayName = 'AudioPlayerMetaCtx';
@@ -0,0 +1,314 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import type { ReactNode } from 'react';
5
+ import { unlockAudioContext } from '../audio/audioContext';
6
+ import { getPeaksFromCache, setPeaks } from '../audio/peaksCache';
7
+ import { registerPlayer, setActivePlayer } from '../store/activePlayerBus';
8
+ import { createLevelsStore } from '../store/createLevelsStore';
9
+ import type { LevelsStore } from '../store/createLevelsStore';
10
+ import {
11
+ getPreferences,
12
+ setStoredMuted,
13
+ setStoredVolume,
14
+ subscribePreferences,
15
+ } from '../store/preferencesStore';
16
+ import { clamp } from '../utils/clamp';
17
+ import type { PlayerControls, PlayerErrorReason } from '../types';
18
+ import { AudioRefCtx } from './AudioRefContext';
19
+ import { ControlsCtx } from './ControlsContext';
20
+ import { LevelsCtx } from './LevelsContext';
21
+ import { MetaCtx, type PlayerMeta } from './MetaContext';
22
+ import { StateCtx } from './StateContext';
23
+ import { createAudioSnapshotSource, useAudioElementState } from '../hooks/useAudioElementEvents';
24
+
25
+ export type PlayerProviderProps = {
26
+ src: string;
27
+ peaks?: Float32Array;
28
+ title?: string;
29
+ artist?: string;
30
+ album?: string;
31
+ cover?: string;
32
+ autoplay?: boolean;
33
+ loop?: boolean;
34
+ initialVolume?: number;
35
+ muted?: boolean;
36
+ preload?: 'none' | 'metadata' | 'auto';
37
+ exclusive?: boolean;
38
+ onPrev?: () => void;
39
+ onNext?: () => void;
40
+ onPlay?: () => void;
41
+ onPause?: () => void;
42
+ onEnded?: () => void;
43
+ onError?: (reason: PlayerErrorReason, e: unknown) => void;
44
+ children: ReactNode;
45
+ };
46
+
47
+ function createAudioElement(): HTMLAudioElement {
48
+ const a = new Audio();
49
+ a.crossOrigin = 'anonymous';
50
+ a.preload = 'metadata';
51
+ return a;
52
+ }
53
+
54
+ export function PlayerProvider(props: PlayerProviderProps) {
55
+ const {
56
+ src,
57
+ peaks: peaksProp,
58
+ title,
59
+ artist,
60
+ album,
61
+ cover,
62
+ autoplay = false,
63
+ loop = false,
64
+ initialVolume,
65
+ muted: mutedProp,
66
+ preload = 'metadata',
67
+ exclusive = true,
68
+ onPrev,
69
+ onNext,
70
+ onPlay,
71
+ onPause,
72
+ onEnded,
73
+ onError,
74
+ children,
75
+ } = props;
76
+
77
+ // Explicit props win and opt the player out of the persisted preference sync.
78
+ // Otherwise we read defaults from the shared store and stay subscribed so a
79
+ // change in one player is reflected in every other player on the page.
80
+ const volumeIsControlled = initialVolume !== undefined;
81
+ const mutedIsControlled = mutedProp !== undefined;
82
+
83
+ const playerId = useId();
84
+
85
+ // <audio>, snapshot source, levels store — created exactly once per
86
+ // component instance via useState lazy initializer (StrictMode-safe: the
87
+ // initializer runs once even though render runs twice).
88
+ const [audio] = useState<HTMLAudioElement>(createAudioElement);
89
+ const [source] = useState(() => createAudioSnapshotSource(audio));
90
+ const [levelsStore] = useState<LevelsStore>(() => createLevelsStore());
91
+
92
+ // Seed peaks-from-prop into the module cache so other consumers benefit too.
93
+ if (peaksProp && getPeaksFromCache(src) === undefined) {
94
+ setPeaks(src, peaksProp);
95
+ }
96
+ const initialPeaks = peaksProp ?? getPeaksFromCache(src);
97
+ const [activePeaks, setActivePeaks] = useState<Float32Array | undefined>(initialPeaks);
98
+
99
+ // Apply src / preload / loop / volume / muted imperatively.
100
+ useEffect(() => {
101
+ if (audio.src !== src) {
102
+ audio.src = src;
103
+ audio.load();
104
+ }
105
+ }, [audio, src]);
106
+
107
+ useEffect(() => {
108
+ audio.preload = preload;
109
+ }, [audio, preload]);
110
+
111
+ useEffect(() => {
112
+ audio.loop = loop;
113
+ }, [audio, loop]);
114
+
115
+ // Initial volume / muted: prop > stored > defaults.
116
+ useEffect(() => {
117
+ const stored = getPreferences();
118
+ audio.volume = clamp(volumeIsControlled ? (initialVolume as number) : stored.volume, 0, 1);
119
+ audio.muted = mutedIsControlled ? Boolean(mutedProp) : stored.muted;
120
+ // Run once at mount; later updates flow through the subscription below
121
+ // (or, for controlled players, through the prop effects).
122
+ // eslint-disable-next-line react-hooks/exhaustive-deps
123
+ }, [audio]);
124
+
125
+ // Re-apply when controlled props change explicitly.
126
+ useEffect(() => {
127
+ if (!volumeIsControlled || initialVolume === undefined) return;
128
+ audio.volume = clamp(initialVolume, 0, 1);
129
+ }, [audio, volumeIsControlled, initialVolume]);
130
+
131
+ useEffect(() => {
132
+ if (!mutedIsControlled || mutedProp === undefined) return;
133
+ audio.muted = mutedProp;
134
+ }, [audio, mutedIsControlled, mutedProp]);
135
+
136
+ // Subscribe uncontrolled players to the shared preferences store so they
137
+ // stay in sync with each other and across tabs.
138
+ useEffect(() => {
139
+ if (volumeIsControlled && mutedIsControlled) return;
140
+ return subscribePreferences((prefs) => {
141
+ if (!volumeIsControlled && audio.volume !== prefs.volume) {
142
+ audio.volume = prefs.volume;
143
+ }
144
+ if (!mutedIsControlled && audio.muted !== prefs.muted) {
145
+ audio.muted = prefs.muted;
146
+ }
147
+ });
148
+ }, [audio, volumeIsControlled, mutedIsControlled]);
149
+
150
+ // When src changes, swap in cached peaks if available; clear otherwise.
151
+ useEffect(() => {
152
+ setActivePeaks(getPeaksFromCache(src));
153
+ }, [src]);
154
+
155
+ // Lifecycle event hooks.
156
+ useEffect(() => {
157
+ const handlePlay = () => onPlay?.();
158
+ const handlePause = () => onPause?.();
159
+ const handleEnded = () => onEnded?.();
160
+ const handleError = (e: Event) => {
161
+ const code = audio.error?.code ?? null;
162
+ const reason: PlayerErrorReason =
163
+ code === 2 ? 'network' : code === 3 ? 'decode' : code === 4 ? 'unsupported' : 'unknown';
164
+ onError?.(reason, e);
165
+ };
166
+ audio.addEventListener('play', handlePlay);
167
+ audio.addEventListener('pause', handlePause);
168
+ audio.addEventListener('ended', handleEnded);
169
+ audio.addEventListener('error', handleError);
170
+ return () => {
171
+ audio.removeEventListener('play', handlePlay);
172
+ audio.removeEventListener('pause', handlePause);
173
+ audio.removeEventListener('ended', handleEnded);
174
+ audio.removeEventListener('error', handleError);
175
+ };
176
+ }, [audio, onEnded, onError, onPause, onPlay]);
177
+
178
+ // Refs let controls.play() claim the active slot immediately, without
179
+ // re-memoizing on every prop change.
180
+ const exclusiveRef = useRef(exclusive);
181
+ exclusiveRef.current = exclusive;
182
+ const playerIdRef = useRef(playerId);
183
+ playerIdRef.current = playerId;
184
+
185
+ // Single-active-player coordination.
186
+ useEffect(() => {
187
+ if (!exclusive) return;
188
+ const unregister = registerPlayer(playerId, () => {
189
+ audio.pause();
190
+ });
191
+ // Backstop: claim the slot whenever the element starts playing, even if
192
+ // playback was triggered outside our controls (MediaSession, native UI).
193
+ const onPlayHandler = () => setActivePlayer(playerId);
194
+ audio.addEventListener('play', onPlayHandler);
195
+ return () => {
196
+ audio.removeEventListener('play', onPlayHandler);
197
+ unregister();
198
+ };
199
+ }, [audio, exclusive, playerId]);
200
+
201
+ // Hard cleanup on unmount: stop playback, release the network buffer.
202
+ // The element + cached MediaElementSourceNode are GC'd together via the
203
+ // WeakMap. In StrictMode (dev) the first mount-then-unmount pair fires this
204
+ // immediately, so the discarded audio never keeps playing in the background.
205
+ useEffect(() => {
206
+ return () => {
207
+ try {
208
+ audio.pause();
209
+ audio.removeAttribute('src');
210
+ audio.load();
211
+ } catch {
212
+ // ignore
213
+ }
214
+ };
215
+ }, [audio]);
216
+
217
+ // Controls — memoized once. Functions close over `audio` (stable ref).
218
+ const controls = useMemo<PlayerControls>(() => {
219
+ const playFn = async () => {
220
+ // Claim the active slot synchronously so siblings get paused before our
221
+ // audio.play() resolves. Backstop listener on `play` event covers
222
+ // playback started outside this function.
223
+ if (exclusiveRef.current) setActivePlayer(playerIdRef.current);
224
+ try {
225
+ await unlockAudioContext();
226
+ } catch {
227
+ // ignored — playback may still work without AudioContext.
228
+ }
229
+ try {
230
+ await audio.play();
231
+ } catch (e) {
232
+ // Caller decides; surface via onError when it's a real failure.
233
+ const isAbort = e instanceof DOMException && e.name === 'AbortError';
234
+ if (!isAbort) onError?.('unknown', e);
235
+ }
236
+ };
237
+ const pauseFn = () => {
238
+ audio.pause();
239
+ };
240
+ return {
241
+ play: playFn,
242
+ pause: pauseFn,
243
+ toggle: async () => {
244
+ if (audio.paused || audio.ended) await playFn();
245
+ else pauseFn();
246
+ },
247
+ seek: (seconds: number) => {
248
+ if (!Number.isFinite(seconds)) return;
249
+ const dur = Number.isFinite(audio.duration) ? audio.duration : 0;
250
+ audio.currentTime = clamp(seconds, 0, dur || seconds);
251
+ },
252
+ seekTo: (ratio: number) => {
253
+ const r = clamp(ratio, 0, 1);
254
+ const dur = Number.isFinite(audio.duration) ? audio.duration : 0;
255
+ if (dur > 0) audio.currentTime = r * dur;
256
+ },
257
+ setVolume: (v: number) => {
258
+ const next = clamp(v, 0, 1);
259
+ audio.volume = next;
260
+ if (next > 0 && audio.muted) audio.muted = false;
261
+ if (!volumeIsControlled) setStoredVolume(next);
262
+ if (!mutedIsControlled && next > 0) setStoredMuted(false);
263
+ },
264
+ toggleMute: () => {
265
+ const next = !audio.muted;
266
+ audio.muted = next;
267
+ if (!mutedIsControlled) setStoredMuted(next);
268
+ },
269
+ toggleLoop: () => {
270
+ audio.loop = !audio.loop;
271
+ },
272
+ };
273
+ // eslint-disable-next-line react-hooks/exhaustive-deps
274
+ }, [audio, volumeIsControlled, mutedIsControlled]);
275
+
276
+ // Autoplay attempt (best-effort; suspended context will reject silently).
277
+ useEffect(() => {
278
+ if (!autoplay) return;
279
+ void controls.play();
280
+ }, [autoplay, controls]);
281
+
282
+ const state = useAudioElementState(source, Boolean(audio.src), activePeaks);
283
+
284
+ const meta = useMemo<PlayerMeta>(
285
+ () => ({
286
+ src,
287
+ title,
288
+ artist,
289
+ album,
290
+ cover,
291
+ hasPrev: Boolean(onPrev),
292
+ hasNext: Boolean(onNext),
293
+ }),
294
+ [src, title, artist, album, cover, onPrev, onNext],
295
+ );
296
+
297
+ return (
298
+ <AudioRefCtx.Provider value={audio}>
299
+ <MetaCtx.Provider value={meta}>
300
+ <StateCtx.Provider value={state}>
301
+ <ControlsCtx.Provider value={controls}>
302
+ <LevelsCtx.Provider value={levelsStore}>{children}</LevelsCtx.Provider>
303
+ </ControlsCtx.Provider>
304
+ </StateCtx.Provider>
305
+ </MetaCtx.Provider>
306
+ </AudioRefCtx.Provider>
307
+ );
308
+ }
309
+
310
+ // Internal helper for parts that want to expose peaks-update from the lazy hook.
311
+ export function usePeaksSetter() {
312
+ // The provider seeds peaks via setPeaks() in the module cache; consumers
313
+ // re-read on src change. usePeaks hook re-uses the cache directly.
314
+ }
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+
3
+ import { createContext } from 'react';
4
+ import type { PlayerState } from '../types';
5
+
6
+ export const StateCtx = createContext<PlayerState | null>(null);
7
+ StateCtx.displayName = 'AudioPlayerStateCtx';
@@ -1,16 +1,17 @@
1
- /**
2
- * AudioPlayer context - Public API
3
- *
4
- * HybridAudioProvider: HTML5 audio + Web Audio API for visualization
5
- */
6
-
1
+ export { AudioRefCtx } from './AudioRefContext';
2
+ export { ControlsCtx } from './ControlsContext';
3
+ export { LevelsCtx } from './LevelsContext';
4
+ export { MetaCtx } from './MetaContext';
5
+ export type { PlayerMeta } from './MetaContext';
6
+ export { StateCtx } from './StateContext';
7
+ export { PlayerProvider } from './PlayerProvider';
8
+ export type { PlayerProviderProps } from './PlayerProvider';
7
9
  export {
8
- HybridAudioProvider,
9
- useHybridAudioContext,
10
- useHybridAudioState,
11
- useHybridAudioControls,
12
- useHybridAudioLevels,
13
- useHybridWebAudio,
14
- type HybridAudioContextValue,
15
- type HybridAudioProviderProps,
16
- } from './HybridAudioProvider';
10
+ usePlayerAudio,
11
+ usePlayerControls,
12
+ usePlayerDuration,
13
+ usePlayerLevels,
14
+ usePlayerMeta,
15
+ usePlayerPaused,
16
+ usePlayerState,
17
+ } from './selectors';
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import { useContext } from 'react';
4
+ import type { Context } from 'react';
5
+ import type { LevelsStore } from '../store';
6
+ import type { PlayerControls, PlayerState } from '../types';
7
+ import { AudioRefCtx } from './AudioRefContext';
8
+ import { ControlsCtx } from './ControlsContext';
9
+ import { LevelsCtx } from './LevelsContext';
10
+ import { MetaCtx, type PlayerMeta } from './MetaContext';
11
+ import { StateCtx } from './StateContext';
12
+
13
+ function useCtxOrThrow<T>(ctx: Context<T | null>, name: string): T {
14
+ const value = useContext(ctx);
15
+ if (value === null) {
16
+ throw new Error(`${name} must be used inside <PlayerProvider>`);
17
+ }
18
+ return value;
19
+ }
20
+
21
+ export const usePlayerState = (): PlayerState => useCtxOrThrow(StateCtx, 'usePlayerState');
22
+ export const usePlayerControls = (): PlayerControls => useCtxOrThrow(ControlsCtx, 'usePlayerControls');
23
+ export const usePlayerLevels = (): LevelsStore => useCtxOrThrow(LevelsCtx, 'usePlayerLevels');
24
+ export const usePlayerMeta = (): PlayerMeta => useCtxOrThrow(MetaCtx, 'usePlayerMeta');
25
+ export const usePlayerAudio = (): HTMLAudioElement => useCtxOrThrow(AudioRefCtx, 'usePlayerAudio');
26
+
27
+ // Narrow slices.
28
+ export const usePlayerPaused = (): boolean => {
29
+ const s = usePlayerState();
30
+ return s.kind !== 'playing';
31
+ };
32
+
33
+ export const usePlayerDuration = (): number => {
34
+ const s = usePlayerState();
35
+ return 'duration' in s ? s.duration : 0;
36
+ };
@@ -1,39 +1,12 @@
1
- /**
2
- * AudioPlayer hooks - Public API
3
- */
4
-
5
- // Audio bus global exclusivity (one player at a time)
6
- export { useAudioBus, useAudioBusStore } from './useAudioBus';
7
- export type { UseAudioBusReturn } from './useAudioBus';
8
-
9
- // Core hybrid audio hook
10
- export { useHybridAudio } from './useHybridAudio';
11
- export type {
12
- UseHybridAudioOptions,
13
- HybridAudioState,
14
- HybridAudioControls,
15
- HybridWebAudioAPI,
16
- UseHybridAudioReturn,
17
- } from './useHybridAudio';
18
-
19
- // Frequency analysis hook
20
- export { useHybridAudioAnalysis } from './useHybridAudioAnalysis';
21
-
22
- // Visualization settings (localStorage persistence)
23
- export {
24
- useVisualization,
25
- useAudioVisualization,
26
- VisualizationProvider,
27
- VARIANT_INFO,
28
- INTENSITY_INFO,
29
- COLOR_SCHEME_INFO,
30
- } from './useVisualization';
31
- export type {
32
- VisualizationSettings,
33
- VisualizationVariant,
34
- VisualizationIntensity,
35
- VisualizationColorScheme,
36
- UseVisualizationReturn,
37
- UseAudioVisualizationReturn,
38
- VisualizationProviderProps,
39
- } from './useVisualization';
1
+ export { createAudioSnapshotSource, useAudioElementState } from './useAudioElementEvents';
2
+ export { usePeaks } from './usePeaks';
3
+ export type { UsePeaksOptions } from './usePeaks';
4
+ export { useAnalyser } from './useAnalyser';
5
+ export { usePlayheadLoop } from './usePlayheadLoop';
6
+ export { useMediaSession } from './useMediaSession';
7
+ export { useKeyboardShortcuts } from './useKeyboardShortcuts';
8
+ export type { UseKeyboardShortcutsOptions } from './useKeyboardShortcuts';
9
+ export { useElementWidth } from './useResizeObserver';
10
+ export { useThemeWatcher } from './useThemeWatcher';
11
+ export { useActivePlayer, useLastActivePlayer, useIsActivePlayer } from './useActivePlayer';
12
+ export { usePlayerPreferences } from './usePlayerPreferences';
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ // Subscribe to the global active-player id. Reads from activePlayerBus, which
4
+ // already coordinates same-tab + cross-tab via BroadcastChannel.
5
+
6
+ import { useSyncExternalStore } from 'react';
7
+ import {
8
+ getActivePlayer,
9
+ getLastActivePlayer,
10
+ subscribeActivePlayer,
11
+ } from '../store/activePlayerBus';
12
+
13
+ const getServerSnapshot = (): string | null => null;
14
+
15
+ /** Currently active player id. `null` when nothing plays. */
16
+ export function useActivePlayer(): string | null {
17
+ return useSyncExternalStore(subscribeActivePlayer, getActivePlayer, getServerSnapshot);
18
+ }
19
+
20
+ /** Most recently active player id. Stays set after pause; useful for "last
21
+ * played" UIs where you want to keep showing a track even when paused. */
22
+ export function useLastActivePlayer(): string | null {
23
+ return useSyncExternalStore(subscribeActivePlayer, getLastActivePlayer, getServerSnapshot);
24
+ }
25
+
26
+ /** True when the given id is currently active. Convenience for conditional
27
+ * styling without comparing strings inline. */
28
+ export function useIsActivePlayer(id: string | null | undefined): boolean {
29
+ const active = useActivePlayer();
30
+ return Boolean(id) && active === id;
31
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ // Lazily creates an AnalyserNode bound to the player's <audio> via the cached
4
+ // MediaElementSourceNode, samples it at ~30 Hz into a Float32Array, and pushes
5
+ // frames into the LevelsStore. Only mounted by LiveWaveform / reactive cover.
6
+
7
+ import { useEffect } from 'react';
8
+ import { getAudioContext } from '../audio/audioContext';
9
+ import { getMediaElementSource } from '../audio/mediaElementSourceCache';
10
+ import type { LevelsStore } from '../store/createLevelsStore';
11
+
12
+ const FFT_SIZE = 1024;
13
+ const READ_INTERVAL_MS = 33;
14
+ const SMOOTHING = 0.8;
15
+
16
+ export function useAnalyser(audio: HTMLAudioElement, store: LevelsStore, enabled: boolean) {
17
+ useEffect(() => {
18
+ if (!enabled) return;
19
+ let analyser: AnalyserNode | null = null;
20
+ let interval: ReturnType<typeof setInterval> | null = null;
21
+ let buffer = new Uint8Array(0);
22
+ let normalized = new Float32Array(0);
23
+ let cancelled = false;
24
+
25
+ try {
26
+ const ctx = getAudioContext();
27
+ const source = getMediaElementSource(audio);
28
+ analyser = ctx.createAnalyser();
29
+ analyser.fftSize = FFT_SIZE;
30
+ analyser.smoothingTimeConstant = SMOOTHING;
31
+ source.connect(analyser);
32
+ buffer = new Uint8Array(analyser.frequencyBinCount);
33
+ normalized = new Float32Array(analyser.frequencyBinCount);
34
+ store.setActive(true);
35
+
36
+ const tick = () => {
37
+ if (cancelled || !analyser) return;
38
+ if (typeof document !== 'undefined' && document.hidden) return;
39
+ analyser.getByteFrequencyData(buffer);
40
+ for (let i = 0; i < buffer.length; i++) normalized[i] = buffer[i] / 255;
41
+ store.set(normalized);
42
+ };
43
+ interval = setInterval(tick, READ_INTERVAL_MS);
44
+ } catch {
45
+ // Not supported in this environment; leave levels empty.
46
+ store.setActive(false);
47
+ }
48
+
49
+ return () => {
50
+ cancelled = true;
51
+ if (interval) clearInterval(interval);
52
+ if (analyser) {
53
+ try {
54
+ analyser.disconnect();
55
+ } catch {
56
+ // ignore
57
+ }
58
+ }
59
+ store.setActive(false);
60
+ };
61
+ }, [audio, store, enabled]);
62
+ }
@@ -0,0 +1,102 @@
1
+ // useSyncExternalStore over a low-frequency set of HTMLAudioElement events.
2
+ // Snapshot is cached; consumers only re-render when one of the tracked fields
3
+ // actually changes (Object.is on the snapshot). See ADR-001 / research 03 §2.
4
+
5
+ import { useSyncExternalStore } from 'react';
6
+ import type { PlayerErrorReason, PlayerState, PlayerStateKind } from '../types';
7
+
8
+ const TRACKED = [
9
+ 'play',
10
+ 'pause',
11
+ 'ended',
12
+ 'loadedmetadata',
13
+ 'durationchange',
14
+ 'emptied',
15
+ 'waiting',
16
+ 'canplay',
17
+ 'error',
18
+ ] as const;
19
+
20
+ type Snapshot = {
21
+ paused: boolean;
22
+ ended: boolean;
23
+ duration: number;
24
+ ready: boolean;
25
+ errorCode: number | null;
26
+ };
27
+
28
+ function readSnapshot(audio: HTMLAudioElement): Snapshot {
29
+ return {
30
+ paused: audio.paused,
31
+ ended: audio.ended,
32
+ duration: Number.isFinite(audio.duration) ? audio.duration : 0,
33
+ ready: audio.readyState >= 2,
34
+ errorCode: audio.error?.code ?? null,
35
+ };
36
+ }
37
+
38
+ function snapshotsEqual(a: Snapshot, b: Snapshot): boolean {
39
+ return (
40
+ a.paused === b.paused &&
41
+ a.ended === b.ended &&
42
+ a.duration === b.duration &&
43
+ a.ready === b.ready &&
44
+ a.errorCode === b.errorCode
45
+ );
46
+ }
47
+
48
+ export function createAudioSnapshotSource(audio: HTMLAudioElement) {
49
+ let cached: Snapshot = readSnapshot(audio);
50
+ return {
51
+ subscribe(cb: () => void): () => void {
52
+ const refresh = () => {
53
+ const next = readSnapshot(audio);
54
+ if (!snapshotsEqual(cached, next)) {
55
+ cached = next;
56
+ cb();
57
+ }
58
+ };
59
+ for (const e of TRACKED) audio.addEventListener(e, refresh);
60
+ return () => {
61
+ for (const e of TRACKED) audio.removeEventListener(e, refresh);
62
+ };
63
+ },
64
+ getSnapshot(): Snapshot {
65
+ return cached;
66
+ },
67
+ getServerSnapshot(): Snapshot {
68
+ return cached;
69
+ },
70
+ };
71
+ }
72
+
73
+ function errorCodeToReason(code: number | null): PlayerErrorReason {
74
+ // MediaError codes: 1 ABORT, 2 NETWORK, 3 DECODE, 4 SRC_NOT_SUPPORTED.
75
+ if (code === 2) return 'network';
76
+ if (code === 3) return 'decode';
77
+ if (code === 4) return 'unsupported';
78
+ return 'unknown';
79
+ }
80
+
81
+ function snapshotToState(snap: Snapshot, hasSrc: boolean, peaks?: Float32Array): PlayerState {
82
+ if (snap.errorCode !== null) {
83
+ return { kind: 'error', reason: errorCodeToReason(snap.errorCode), duration: snap.duration };
84
+ }
85
+ if (!hasSrc) return { kind: 'idle' };
86
+ if (!snap.ready) return { kind: 'loading' };
87
+ const kind: PlayerStateKind = snap.ended
88
+ ? 'ended'
89
+ : snap.paused
90
+ ? 'paused'
91
+ : 'playing';
92
+ return { kind, duration: snap.duration, peaks };
93
+ }
94
+
95
+ export function useAudioElementState(
96
+ source: ReturnType<typeof createAudioSnapshotSource>,
97
+ hasSrc: boolean,
98
+ peaks?: Float32Array,
99
+ ): PlayerState {
100
+ const snap = useSyncExternalStore(source.subscribe, source.getSnapshot, source.getServerSnapshot);
101
+ return snapshotToState(snap, hasSrc, peaks);
102
+ }