@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.
- 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,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Player keyboard shortcuts via ui-core's useHotkey (react-hotkeys-hook).
|
|
4
|
+
//
|
|
5
|
+
// Idiomatic pattern: each useHotkey call returns a callback ref that scopes
|
|
6
|
+
// the binding to the element it's attached to. We compose all of them into
|
|
7
|
+
// one ref and hand it to the player container. Library handles focus gating;
|
|
8
|
+
// no manual `document.activeElement` polling, no `data-scope` juggling.
|
|
9
|
+
|
|
10
|
+
import { useCallback } from 'react';
|
|
11
|
+
import { useHotkey } from '@djangocfg/ui-core/hooks';
|
|
12
|
+
import { clamp } from '../utils/clamp';
|
|
13
|
+
import type { PlayerControls } from '../types';
|
|
14
|
+
|
|
15
|
+
export type HotkeyBinding = {
|
|
16
|
+
/** Human label for help dialogs and tooltips. */
|
|
17
|
+
label: string;
|
|
18
|
+
/** Pretty hint shown to users (e.g. "Space", "←", "M"). */
|
|
19
|
+
hint: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type UseKeyboardShortcutsOptions = {
|
|
23
|
+
audio: HTMLAudioElement;
|
|
24
|
+
controls: PlayerControls;
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type UseKeyboardShortcutsReturn = {
|
|
29
|
+
/** Attach to the player container — react-hotkeys-hook only fires when
|
|
30
|
+
* focus is within this element. Composes with other refs via callback. */
|
|
31
|
+
ref: (instance: HTMLElement | null) => void;
|
|
32
|
+
/** All registered bindings — useful for rendering a help dialog. */
|
|
33
|
+
bindings: ReadonlyArray<HotkeyBinding>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const OPTS = { preventDefault: true } as const;
|
|
37
|
+
|
|
38
|
+
const BINDINGS: ReadonlyArray<HotkeyBinding> = [
|
|
39
|
+
{ label: 'Play / pause', hint: 'Space' },
|
|
40
|
+
{ label: 'Seek +5s', hint: '→' },
|
|
41
|
+
{ label: 'Seek −5s', hint: '←' },
|
|
42
|
+
{ label: 'Volume up', hint: '↑' },
|
|
43
|
+
{ label: 'Volume down', hint: '↓' },
|
|
44
|
+
{ label: 'Toggle mute', hint: 'M' },
|
|
45
|
+
{ label: 'Toggle loop', hint: 'L' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function useKeyboardShortcuts({
|
|
49
|
+
audio,
|
|
50
|
+
controls,
|
|
51
|
+
enabled = true,
|
|
52
|
+
}: UseKeyboardShortcutsOptions): UseKeyboardShortcutsReturn {
|
|
53
|
+
// Each useHotkey returns a callback ref that scopes the binding to its
|
|
54
|
+
// element. We must call them at the top level (rules of hooks).
|
|
55
|
+
const refToggle = useHotkey(['space', 'k'], () => void controls.toggle(), {
|
|
56
|
+
...OPTS, enabled, description: 'Play / pause',
|
|
57
|
+
});
|
|
58
|
+
const refForward = useHotkey('right', () => controls.seek(audio.currentTime + 5), {
|
|
59
|
+
...OPTS, enabled, description: 'Seek +5s',
|
|
60
|
+
});
|
|
61
|
+
const refBackward = useHotkey('left', () => controls.seek(audio.currentTime - 5), {
|
|
62
|
+
...OPTS, enabled, description: 'Seek −5s',
|
|
63
|
+
});
|
|
64
|
+
const refVolUp = useHotkey('up', () => controls.setVolume(clamp(audio.volume + 0.05, 0, 1)), {
|
|
65
|
+
...OPTS, enabled, description: 'Volume up',
|
|
66
|
+
});
|
|
67
|
+
const refVolDown = useHotkey('down', () => controls.setVolume(clamp(audio.volume - 0.05, 0, 1)), {
|
|
68
|
+
...OPTS, enabled, description: 'Volume down',
|
|
69
|
+
});
|
|
70
|
+
const refMute = useHotkey('m', () => controls.toggleMute(), {
|
|
71
|
+
...OPTS, enabled, description: 'Toggle mute',
|
|
72
|
+
});
|
|
73
|
+
const refLoop = useHotkey('l', () => controls.toggleLoop(), {
|
|
74
|
+
...OPTS, enabled, description: 'Toggle loop',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const ref = useCallback(
|
|
78
|
+
(instance: HTMLElement | null) => {
|
|
79
|
+
refToggle(instance);
|
|
80
|
+
refForward(instance);
|
|
81
|
+
refBackward(instance);
|
|
82
|
+
refVolUp(instance);
|
|
83
|
+
refVolDown(instance);
|
|
84
|
+
refMute(instance);
|
|
85
|
+
refLoop(instance);
|
|
86
|
+
},
|
|
87
|
+
[refToggle, refForward, refBackward, refVolUp, refVolDown, refMute, refLoop],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return { ref, bindings: BINDINGS };
|
|
91
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import type { PlayerControls } from '../types';
|
|
5
|
+
import type { PlayerMeta } from '../context/MetaContext';
|
|
6
|
+
|
|
7
|
+
const ACTIONS: MediaSessionAction[] = [
|
|
8
|
+
'play',
|
|
9
|
+
'pause',
|
|
10
|
+
'previoustrack',
|
|
11
|
+
'nexttrack',
|
|
12
|
+
'seekbackward',
|
|
13
|
+
'seekforward',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function useMediaSession(
|
|
17
|
+
audio: HTMLAudioElement,
|
|
18
|
+
meta: PlayerMeta,
|
|
19
|
+
controls: PlayerControls,
|
|
20
|
+
onPrev?: () => void,
|
|
21
|
+
onNext?: () => void,
|
|
22
|
+
): void {
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
|
|
25
|
+
const ms = navigator.mediaSession;
|
|
26
|
+
try {
|
|
27
|
+
ms.metadata = new MediaMetadata({
|
|
28
|
+
title: meta.title ?? '',
|
|
29
|
+
artist: meta.artist ?? '',
|
|
30
|
+
album: meta.album ?? '',
|
|
31
|
+
artwork: meta.cover
|
|
32
|
+
? [{ src: meta.cover, sizes: '512x512', type: 'image/jpeg' }]
|
|
33
|
+
: [],
|
|
34
|
+
});
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const handlers: Partial<Record<MediaSessionAction, MediaSessionActionHandler | null>> = {
|
|
40
|
+
play: () => {
|
|
41
|
+
void controls.play();
|
|
42
|
+
},
|
|
43
|
+
pause: () => controls.pause(),
|
|
44
|
+
previoustrack: onPrev ?? null,
|
|
45
|
+
nexttrack: onNext ?? null,
|
|
46
|
+
seekbackward: (details) => {
|
|
47
|
+
const offset = details.seekOffset ?? 10;
|
|
48
|
+
controls.seek(audio.currentTime - offset);
|
|
49
|
+
},
|
|
50
|
+
seekforward: (details) => {
|
|
51
|
+
const offset = details.seekOffset ?? 10;
|
|
52
|
+
controls.seek(audio.currentTime + offset);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
for (const a of ACTIONS) {
|
|
57
|
+
try {
|
|
58
|
+
ms.setActionHandler(a, handlers[a] ?? null);
|
|
59
|
+
} catch {
|
|
60
|
+
// ignore unsupported actions
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
for (const a of ACTIONS) {
|
|
66
|
+
try {
|
|
67
|
+
ms.setActionHandler(a, null);
|
|
68
|
+
} catch {
|
|
69
|
+
// ignore
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}, [audio, controls, meta.title, meta.artist, meta.album, meta.cover, onPrev, onNext]);
|
|
74
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Lazy peaks fetch with module-cache + in-flight dedupe. Triggers on first
|
|
4
|
+
// `play()`, IntersectionObserver visibility, or explicit `decodeOnMount` flag.
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import { getPeaks, getPeaksFromCache } from '../audio/peaksCache';
|
|
8
|
+
|
|
9
|
+
export type UsePeaksOptions = {
|
|
10
|
+
src: string;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
triggerRef?: React.RefObject<Element | null>;
|
|
13
|
+
decodeOnMount?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function usePeaks(opts: UsePeaksOptions): {
|
|
17
|
+
peaks: Float32Array | null;
|
|
18
|
+
loading: boolean;
|
|
19
|
+
error: unknown;
|
|
20
|
+
} {
|
|
21
|
+
const { src, enabled = true, triggerRef, decodeOnMount = false } = opts;
|
|
22
|
+
const cached = getPeaksFromCache(src) ?? null;
|
|
23
|
+
const [peaks, setLocal] = useState<Float32Array | null>(cached);
|
|
24
|
+
const [loading, setLoading] = useState<boolean>(!cached && enabled);
|
|
25
|
+
const [error, setError] = useState<unknown>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!enabled) return;
|
|
29
|
+
const hit = getPeaksFromCache(src);
|
|
30
|
+
if (hit) {
|
|
31
|
+
setLocal(hit);
|
|
32
|
+
setLoading(false);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setLocal(null);
|
|
36
|
+
setLoading(true);
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
let started = false;
|
|
39
|
+
|
|
40
|
+
const startDecode = () => {
|
|
41
|
+
if (started) return;
|
|
42
|
+
started = true;
|
|
43
|
+
getPeaks(src)
|
|
44
|
+
.then((p) => {
|
|
45
|
+
if (cancelled) return;
|
|
46
|
+
setLocal(p);
|
|
47
|
+
setLoading(false);
|
|
48
|
+
})
|
|
49
|
+
.catch((e) => {
|
|
50
|
+
if (cancelled) return;
|
|
51
|
+
setError(e);
|
|
52
|
+
setLoading(false);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (decodeOnMount || !triggerRef?.current || typeof IntersectionObserver === 'undefined') {
|
|
57
|
+
startDecode();
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const obs = new IntersectionObserver(
|
|
64
|
+
(entries) => {
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (entry.isIntersecting) {
|
|
67
|
+
startDecode();
|
|
68
|
+
obs.disconnect();
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{ rootMargin: '200px' },
|
|
74
|
+
);
|
|
75
|
+
obs.observe(triggerRef.current);
|
|
76
|
+
return () => {
|
|
77
|
+
cancelled = true;
|
|
78
|
+
obs.disconnect();
|
|
79
|
+
};
|
|
80
|
+
}, [src, enabled, decodeOnMount, triggerRef]);
|
|
81
|
+
|
|
82
|
+
return { peaks, loading, error };
|
|
83
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Read the persistent player preferences (volume, muted). useSyncExternalStore
|
|
4
|
+
// over preferencesStore — re-renders when localStorage changes (same tab or
|
|
5
|
+
// other tab via the native `storage` event).
|
|
6
|
+
|
|
7
|
+
import { useSyncExternalStore } from 'react';
|
|
8
|
+
import {
|
|
9
|
+
getPreferences,
|
|
10
|
+
subscribePreferences,
|
|
11
|
+
type PlayerPreferences,
|
|
12
|
+
} from '../store/preferencesStore';
|
|
13
|
+
|
|
14
|
+
const SSR: PlayerPreferences = { volume: 1, muted: false };
|
|
15
|
+
function getServerSnapshot(): PlayerPreferences {
|
|
16
|
+
return SSR;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function usePlayerPreferences(): PlayerPreferences {
|
|
20
|
+
return useSyncExternalStore(subscribePreferences, getPreferences, getServerSnapshot);
|
|
21
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// One rAF loop reads audio.currentTime and writes a single CSS variable.
|
|
4
|
+
// Pauses on document.hidden and when the audio is paused (with a final
|
|
5
|
+
// flush to keep the bar in sync after the seek that triggered the pause).
|
|
6
|
+
|
|
7
|
+
import { useEffect } from 'react';
|
|
8
|
+
|
|
9
|
+
const VAR = '--p';
|
|
10
|
+
|
|
11
|
+
export function usePlayheadLoop(
|
|
12
|
+
audio: HTMLAudioElement,
|
|
13
|
+
el: HTMLElement | null,
|
|
14
|
+
enabled = true,
|
|
15
|
+
): void {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!enabled || !el) return;
|
|
18
|
+
let raf = 0;
|
|
19
|
+
let lastPct = -1;
|
|
20
|
+
|
|
21
|
+
const writePct = () => {
|
|
22
|
+
const dur = audio.duration;
|
|
23
|
+
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
24
|
+
const pct = Math.max(0, Math.min(100, (audio.currentTime / dur) * 100));
|
|
25
|
+
if (Math.abs(pct - lastPct) < 0.01) return;
|
|
26
|
+
lastPct = pct;
|
|
27
|
+
el.style.setProperty(VAR, `${pct.toFixed(2)}%`);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const tick = () => {
|
|
31
|
+
if (typeof document !== 'undefined' && document.hidden) {
|
|
32
|
+
raf = 0;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
writePct();
|
|
36
|
+
raf = requestAnimationFrame(tick);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const start = () => {
|
|
40
|
+
if (raf) return;
|
|
41
|
+
raf = requestAnimationFrame(tick);
|
|
42
|
+
};
|
|
43
|
+
const stop = () => {
|
|
44
|
+
if (!raf) return;
|
|
45
|
+
cancelAnimationFrame(raf);
|
|
46
|
+
raf = 0;
|
|
47
|
+
writePct();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (!audio.paused) start();
|
|
51
|
+
|
|
52
|
+
const onPlay = () => start();
|
|
53
|
+
const onPauseOrEnd = () => stop();
|
|
54
|
+
const onSeek = () => writePct();
|
|
55
|
+
const onVisibility = () => {
|
|
56
|
+
if (document.hidden) stop();
|
|
57
|
+
else if (!audio.paused) start();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
audio.addEventListener('play', onPlay);
|
|
61
|
+
audio.addEventListener('pause', onPauseOrEnd);
|
|
62
|
+
audio.addEventListener('ended', onPauseOrEnd);
|
|
63
|
+
audio.addEventListener('seeked', onSeek);
|
|
64
|
+
audio.addEventListener('timeupdate', writePct);
|
|
65
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
stop();
|
|
69
|
+
audio.removeEventListener('play', onPlay);
|
|
70
|
+
audio.removeEventListener('pause', onPauseOrEnd);
|
|
71
|
+
audio.removeEventListener('ended', onPauseOrEnd);
|
|
72
|
+
audio.removeEventListener('seeked', onSeek);
|
|
73
|
+
audio.removeEventListener('timeupdate', writePct);
|
|
74
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
75
|
+
};
|
|
76
|
+
}, [audio, el, enabled]);
|
|
77
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export function useElementWidth(el: HTMLElement | null): number {
|
|
6
|
+
const [w, setW] = useState(0);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
9
|
+
setW(el.clientWidth);
|
|
10
|
+
const obs = new ResizeObserver((entries) => {
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
const next = entry.contentRect.width;
|
|
13
|
+
setW((prev) => (Math.abs(prev - next) < 0.5 ? prev : next));
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
obs.observe(el);
|
|
17
|
+
return () => obs.disconnect();
|
|
18
|
+
}, [el]);
|
|
19
|
+
return w;
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Fires `cb` whenever <html> class changes (typical light/dark toggle convention)
|
|
4
|
+
// or the user's color-scheme media preference flips. Used to repaint canvases
|
|
5
|
+
// after a theme switch — see ADR-003.
|
|
6
|
+
|
|
7
|
+
import { useEffect } from 'react';
|
|
8
|
+
|
|
9
|
+
export function useThemeWatcher(cb: () => void): void {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const root = document.documentElement;
|
|
12
|
+
const obs = new MutationObserver(cb);
|
|
13
|
+
obs.observe(root, { attributes: true, attributeFilter: ['class', 'data-theme'] });
|
|
14
|
+
const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
|
|
15
|
+
const onMq = () => cb();
|
|
16
|
+
mq?.addEventListener?.('change', onMq);
|
|
17
|
+
return () => {
|
|
18
|
+
obs.disconnect();
|
|
19
|
+
mq?.removeEventListener?.('change', onMq);
|
|
20
|
+
};
|
|
21
|
+
}, [cb]);
|
|
22
|
+
}
|
|
@@ -1,139 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Uses HTML5 audio for playback + Web Audio API for visualization.
|
|
5
|
-
* No crackling, native streaming support.
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* // Simple usage
|
|
9
|
-
* import { HybridSimplePlayer } from '../../_shared';
|
|
10
|
-
* <HybridSimplePlayer src="/audio.mp3" title="Track Title" />
|
|
11
|
-
*
|
|
12
|
-
* @example
|
|
13
|
-
* // Custom setup with context
|
|
14
|
-
* import { HybridAudioProvider, HybridAudioPlayer } from '../../_shared';
|
|
15
|
-
* <HybridAudioProvider src={audioUrl}>
|
|
16
|
-
* <HybridAudioPlayer showWaveform showControls />
|
|
17
|
-
* </HybridAudioProvider>
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
// =============================================================================
|
|
21
|
-
// COMPONENTS
|
|
22
|
-
// =============================================================================
|
|
23
|
-
|
|
24
|
-
export {
|
|
25
|
-
HybridAudioPlayer,
|
|
26
|
-
HybridSimplePlayer,
|
|
27
|
-
HybridCompactPlayer,
|
|
28
|
-
HybridWaveform,
|
|
29
|
-
AudioReactiveCover,
|
|
30
|
-
// Effect components (for custom implementations)
|
|
31
|
-
GlowEffect,
|
|
32
|
-
OrbsEffect,
|
|
33
|
-
SpotlightEffect,
|
|
34
|
-
MeshEffect,
|
|
35
|
-
} from './components';
|
|
36
|
-
|
|
1
|
+
export { Player } from './Player';
|
|
2
|
+
export { LazyPlayer } from './lazy';
|
|
37
3
|
export type {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
4
|
+
PlayerProps,
|
|
5
|
+
PlayerState,
|
|
6
|
+
PlayerStateKind,
|
|
7
|
+
PlayerControls,
|
|
8
|
+
PlayerHandle,
|
|
9
|
+
WaveformConfig,
|
|
10
|
+
WaveformMode,
|
|
11
|
+
ReactiveCoverMode,
|
|
12
|
+
PlayerVariant,
|
|
13
|
+
PlayerErrorReason,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
// Selector hooks for advanced consumers (rare).
|
|
50
17
|
export {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
18
|
+
PlayerProvider,
|
|
19
|
+
usePlayerAudio,
|
|
20
|
+
usePlayerControls,
|
|
21
|
+
usePlayerDuration,
|
|
22
|
+
usePlayerLevels,
|
|
23
|
+
usePlayerMeta,
|
|
24
|
+
usePlayerPaused,
|
|
25
|
+
usePlayerState,
|
|
57
26
|
} from './context';
|
|
58
|
-
|
|
59
|
-
export type {
|
|
60
|
-
HybridAudioContextValue,
|
|
61
|
-
HybridAudioProviderProps,
|
|
62
|
-
} from './context';
|
|
63
|
-
|
|
64
|
-
// =============================================================================
|
|
65
|
-
// HOOKS
|
|
66
|
-
// =============================================================================
|
|
67
|
-
|
|
68
27
|
export {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
VARIANT_INFO,
|
|
80
|
-
INTENSITY_INFO,
|
|
81
|
-
COLOR_SCHEME_INFO,
|
|
82
|
-
} from './hooks';
|
|
83
|
-
|
|
84
|
-
export type {
|
|
85
|
-
UseAudioBusReturn,
|
|
86
|
-
UseHybridAudioOptions,
|
|
87
|
-
HybridAudioState,
|
|
88
|
-
HybridAudioControls,
|
|
89
|
-
HybridWebAudioAPI,
|
|
90
|
-
UseHybridAudioReturn,
|
|
91
|
-
VisualizationSettings,
|
|
92
|
-
VisualizationVariant,
|
|
93
|
-
VisualizationIntensity,
|
|
94
|
-
VisualizationColorScheme,
|
|
95
|
-
UseVisualizationReturn,
|
|
96
|
-
UseAudioVisualizationReturn,
|
|
97
|
-
VisualizationProviderProps,
|
|
98
|
-
} from './hooks';
|
|
99
|
-
|
|
100
|
-
// =============================================================================
|
|
101
|
-
// TYPES
|
|
102
|
-
// =============================================================================
|
|
103
|
-
|
|
104
|
-
export type { EqualizerOptions } from './types';
|
|
105
|
-
|
|
106
|
-
// =============================================================================
|
|
107
|
-
// EFFECTS
|
|
108
|
-
// =============================================================================
|
|
109
|
-
|
|
28
|
+
setActivePlayer,
|
|
29
|
+
getActivePlayer,
|
|
30
|
+
getLastActivePlayer,
|
|
31
|
+
subscribeActivePlayer,
|
|
32
|
+
getPreferences,
|
|
33
|
+
setStoredVolume,
|
|
34
|
+
setStoredMuted,
|
|
35
|
+
subscribePreferences,
|
|
36
|
+
} from './store';
|
|
37
|
+
export type { PlayerPreferences } from './store';
|
|
110
38
|
export {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
export {
|
|
39
|
+
useActivePlayer,
|
|
40
|
+
useLastActivePlayer,
|
|
41
|
+
useIsActivePlayer,
|
|
42
|
+
} from './hooks/useActivePlayer';
|
|
43
|
+
export { usePlayerPreferences } from './hooks/usePlayerPreferences';
|
|
44
|
+
export { clearPeaksCache, setPeaks } from './audio';
|
|
45
|
+
|
|
46
|
+
// Slot components — for custom layouts inside <PlayerProvider>.
|
|
47
|
+
// Each one is presentational: reads from the player context, renders one
|
|
48
|
+
// piece of UI. Compose them in any grid you want.
|
|
49
|
+
export { Cover, CoverPlaceholder, ReactivePulse } from './parts/Cover';
|
|
50
|
+
export { Title, Artist, TimeDisplay } from './parts/Meta';
|
|
51
|
+
export {
|
|
52
|
+
PlayButton,
|
|
53
|
+
SkipButton,
|
|
54
|
+
VolumeControl,
|
|
55
|
+
LoopButton,
|
|
56
|
+
ControlsRow,
|
|
57
|
+
IconButton,
|
|
58
|
+
} from './parts/Controls';
|
|
59
|
+
export {
|
|
60
|
+
Waveform,
|
|
61
|
+
PeaksWaveform,
|
|
62
|
+
LiveWaveform,
|
|
63
|
+
BarsWaveform,
|
|
64
|
+
ProgressBar,
|
|
65
|
+
WaveformSkeleton,
|
|
66
|
+
} from './parts/Waveform';
|
|
67
|
+
export { ErrorState } from './parts/ErrorState';
|
|
68
|
+
export { DefaultLayout, CompactLayout } from './parts/Layout';
|