@djangocfg/ui-tools 2.1.310 → 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.
- package/README.md +38 -22
- package/dist/{DocsLayout-W5JLRNSZ.mjs → DocsLayout-ESVQZO3V.mjs} +3 -3
- package/dist/{DocsLayout-W5JLRNSZ.mjs.map → DocsLayout-ESVQZO3V.mjs.map} +1 -1
- package/dist/{DocsLayout-ZXD2CUOH.cjs → DocsLayout-KUPDWJ3G.cjs} +48 -48
- package/dist/{DocsLayout-ZXD2CUOH.cjs.map → DocsLayout-KUPDWJ3G.cjs.map} +1 -1
- package/dist/Player-M3GC3VPE.mjs +4 -0
- package/dist/Player-M3GC3VPE.mjs.map +1 -0
- package/dist/Player-ZGQKKOWI.css +65 -0
- package/dist/Player-ZGQKKOWI.css.map +1 -0
- package/dist/Player-ZL2X5LGG.cjs +13 -0
- package/dist/Player-ZL2X5LGG.cjs.map +1 -0
- package/dist/{chunk-CXVGN6ZW.cjs → chunk-DFTVB66S.cjs} +7 -6
- package/dist/chunk-DFTVB66S.cjs.map +1 -0
- package/dist/{chunk-2QY3LJR6.mjs → chunk-EUADAUBQ.mjs} +5 -4
- package/dist/chunk-EUADAUBQ.mjs.map +1 -0
- package/dist/chunk-FX2QFYWF.mjs +2059 -0
- package/dist/chunk-FX2QFYWF.mjs.map +1 -0
- package/dist/{chunk-6HNAPVZ2.mjs → chunk-GBLQTHWT.mjs} +11 -13
- package/dist/chunk-GBLQTHWT.mjs.map +1 -0
- package/dist/{chunk-FYLR232K.cjs → chunk-S44PW6NK.cjs} +11 -13
- package/dist/chunk-S44PW6NK.cjs.map +1 -0
- package/dist/chunk-ZLQHUZDU.cjs +2061 -0
- package/dist/chunk-ZLQHUZDU.cjs.map +1 -0
- package/dist/components-WYEZL5TE.cjs +26 -0
- package/dist/{components-3RTH76CV.cjs.map → components-WYEZL5TE.cjs.map} +1 -1
- package/dist/components-ZAGG2PBO.mjs +5 -0
- package/dist/{components-5GVVL2Q6.mjs.map → components-ZAGG2PBO.mjs.map} +1 -1
- package/dist/index.cjs +36 -220
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +65 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +44 -500
- package/dist/index.d.ts +44 -500
- package/dist/index.mjs +16 -62
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/components/markdown/MarkdownMessage/ActionRow.tsx +48 -0
- package/src/components/markdown/MarkdownMessage/ChatMessageRow.tsx +97 -0
- package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +9 -13
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +77 -2
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +2 -3
- package/src/components/markdown/MarkdownMessage/README.md +72 -0
- package/src/components/markdown/MarkdownMessage/components.tsx +3 -3
- package/src/components/markdown/MarkdownMessage/index.ts +6 -0
- package/src/index.ts +2 -11
- package/src/tools/AudioPlayer/AudioPlayer.story.tsx +454 -107
- package/src/tools/AudioPlayer/Player.tsx +80 -0
- package/src/tools/AudioPlayer/PlayerShell.tsx +122 -0
- package/src/tools/AudioPlayer/README.md +139 -204
- package/src/tools/AudioPlayer/audio/audioContext.ts +39 -0
- package/src/tools/AudioPlayer/audio/decodePeaks.ts +36 -0
- package/src/tools/AudioPlayer/audio/index.ts +4 -0
- package/src/tools/AudioPlayer/audio/mediaElementSourceCache.ts +20 -0
- package/src/tools/AudioPlayer/audio/peaksCache.ts +37 -0
- package/src/tools/AudioPlayer/context/AudioRefContext.tsx +9 -0
- package/src/tools/AudioPlayer/context/ControlsContext.tsx +7 -0
- package/src/tools/AudioPlayer/context/LevelsContext.tsx +7 -0
- package/src/tools/AudioPlayer/context/MetaContext.tsx +16 -0
- package/src/tools/AudioPlayer/context/PlayerProvider.tsx +314 -0
- package/src/tools/AudioPlayer/context/StateContext.tsx +7 -0
- package/src/tools/AudioPlayer/context/index.ts +16 -15
- package/src/tools/AudioPlayer/context/selectors.ts +36 -0
- package/src/tools/AudioPlayer/hooks/index.ts +12 -39
- package/src/tools/AudioPlayer/hooks/useActivePlayer.ts +31 -0
- package/src/tools/AudioPlayer/hooks/useAnalyser.ts +62 -0
- package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +102 -0
- package/src/tools/AudioPlayer/hooks/useKeyboardShortcuts.ts +91 -0
- package/src/tools/AudioPlayer/hooks/useMediaSession.ts +74 -0
- package/src/tools/AudioPlayer/hooks/usePeaks.ts +83 -0
- package/src/tools/AudioPlayer/hooks/usePlayerPreferences.ts +21 -0
- package/src/tools/AudioPlayer/hooks/usePlayheadLoop.ts +77 -0
- package/src/tools/AudioPlayer/hooks/useResizeObserver.ts +20 -0
- package/src/tools/AudioPlayer/hooks/useThemeWatcher.ts +22 -0
- package/src/tools/AudioPlayer/index.ts +63 -134
- package/src/tools/AudioPlayer/lazy.tsx +8 -97
- package/src/tools/AudioPlayer/parts/Controls/ControlsRow.tsx +30 -0
- package/src/tools/AudioPlayer/parts/Controls/IconButton.tsx +62 -0
- package/src/tools/AudioPlayer/parts/Controls/LoopButton.tsx +33 -0
- package/src/tools/AudioPlayer/parts/Controls/PlayButton.tsx +86 -0
- package/src/tools/AudioPlayer/parts/Controls/SkipButton.tsx +17 -0
- package/src/tools/AudioPlayer/parts/Controls/VolumeControl.tsx +171 -0
- package/src/tools/AudioPlayer/parts/Controls/index.ts +6 -0
- package/src/tools/AudioPlayer/parts/Cover/Cover.tsx +24 -0
- package/src/tools/AudioPlayer/parts/Cover/CoverPlaceholder.tsx +27 -0
- package/src/tools/AudioPlayer/parts/Cover/ReactivePulse.tsx +66 -0
- package/src/tools/AudioPlayer/parts/Cover/index.ts +3 -0
- package/src/tools/AudioPlayer/parts/ErrorState/ErrorState.tsx +35 -0
- package/src/tools/AudioPlayer/parts/ErrorState/index.ts +1 -0
- package/src/tools/AudioPlayer/parts/Layout/CompactLayout.tsx +25 -0
- package/src/tools/AudioPlayer/parts/Layout/DefaultLayout.tsx +48 -0
- package/src/tools/AudioPlayer/parts/Layout/index.ts +2 -0
- package/src/tools/AudioPlayer/parts/Meta/Artist.tsx +14 -0
- package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +49 -0
- package/src/tools/AudioPlayer/parts/Meta/Title.tsx +13 -0
- package/src/tools/AudioPlayer/parts/Meta/index.ts +3 -0
- package/src/tools/AudioPlayer/parts/Skeleton/CoverSkeleton.tsx +13 -0
- package/src/tools/AudioPlayer/parts/Skeleton/MetaSkeleton.tsx +10 -0
- package/src/tools/AudioPlayer/parts/Skeleton/index.ts +2 -0
- package/src/tools/AudioPlayer/parts/Waveform/BarsWaveform.tsx +48 -0
- package/src/tools/AudioPlayer/parts/Waveform/LiveWaveform.tsx +95 -0
- package/src/tools/AudioPlayer/parts/Waveform/PeaksWaveform.tsx +100 -0
- package/src/tools/AudioPlayer/parts/Waveform/ProgressBar.tsx +76 -0
- package/src/tools/AudioPlayer/parts/Waveform/Waveform.tsx +74 -0
- package/src/tools/AudioPlayer/parts/Waveform/WaveformSkeleton.tsx +16 -0
- package/src/tools/AudioPlayer/parts/Waveform/index.ts +8 -0
- package/src/tools/AudioPlayer/parts/Waveform/waveformInteraction.ts +106 -0
- package/src/tools/AudioPlayer/parts/Waveform/waveformRenderer.ts +91 -0
- package/src/tools/AudioPlayer/parts/index.ts +1 -0
- package/src/tools/AudioPlayer/store/activePlayerBus.ts +63 -0
- package/src/tools/AudioPlayer/store/createLevelsStore.ts +37 -0
- package/src/tools/AudioPlayer/store/index.ts +16 -0
- package/src/tools/AudioPlayer/store/preferencesStore.ts +104 -0
- package/src/tools/AudioPlayer/styles/webview-safe.css +77 -0
- package/src/tools/AudioPlayer/types.ts +95 -0
- package/src/tools/AudioPlayer/utils/bucketize.ts +27 -0
- package/src/tools/AudioPlayer/utils/clamp.ts +5 -0
- package/src/tools/AudioPlayer/utils/dpr.ts +19 -0
- package/src/tools/AudioPlayer/utils/formatTime.ts +12 -8
- package/src/tools/AudioPlayer/utils/index.ts +4 -5
- package/src/tools/AudioPlayer/utils/readCssVar.ts +7 -0
- package/src/tools/AudioPlayer/utils/resolveCanvasColor.ts +28 -0
- package/src/tools/index.ts +5 -75
- package/dist/chunk-2QY3LJR6.mjs.map +0 -1
- package/dist/chunk-6HNAPVZ2.mjs.map +0 -1
- package/dist/chunk-CXVGN6ZW.cjs.map +0 -1
- package/dist/chunk-F2N7P5XU.cjs +0 -30
- package/dist/chunk-F2N7P5XU.cjs.map +0 -1
- package/dist/chunk-FYLR232K.cjs.map +0 -1
- package/dist/chunk-HMHIVEMS.mjs +0 -1619
- package/dist/chunk-HMHIVEMS.mjs.map +0 -1
- package/dist/chunk-JWB2EWQO.mjs +0 -5
- package/dist/chunk-JWB2EWQO.mjs.map +0 -1
- package/dist/chunk-YZX6FH3H.cjs +0 -1656
- package/dist/chunk-YZX6FH3H.cjs.map +0 -1
- package/dist/components-3RTH76CV.cjs +0 -27
- package/dist/components-5GVVL2Q6.mjs +0 -5
- package/dist/components-CPHOUQ5F.cjs +0 -46
- package/dist/components-CPHOUQ5F.cjs.map +0 -1
- package/dist/components-OTK43IMD.mjs +0 -6
- package/dist/components-OTK43IMD.mjs.map +0 -1
- package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +0 -225
- package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +0 -163
- package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +0 -284
- package/src/tools/AudioPlayer/components/HybridWaveform.tsx +0 -286
- package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +0 -151
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +0 -110
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +0 -58
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +0 -45
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +0 -82
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +0 -8
- package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +0 -6
- package/src/tools/AudioPlayer/components/index.ts +0 -23
- package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +0 -158
- package/src/tools/AudioPlayer/effects/index.ts +0 -412
- package/src/tools/AudioPlayer/hooks/useAudioBus.ts +0 -76
- package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +0 -403
- package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +0 -96
- package/src/tools/AudioPlayer/hooks/useVisualization.tsx +0 -207
- package/src/tools/AudioPlayer/types/effects.ts +0 -73
- package/src/tools/AudioPlayer/types/index.ts +0 -27
- 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
|
+
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export {
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export {
|
|
11
|
-
export
|
|
12
|
-
|
|
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
|
+
}
|