@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
|
@@ -1,134 +1,481 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
1
2
|
import { defineStory, useBoolean, useSelect } from '@djangocfg/playground';
|
|
2
|
-
import {
|
|
3
|
+
import { TooltipProvider } from '@djangocfg/ui-core/components';
|
|
4
|
+
import { decodePeaks } from './audio/decodePeaks';
|
|
5
|
+
import { PlayerProvider } from './context/PlayerProvider';
|
|
6
|
+
import { Player } from './Player';
|
|
7
|
+
import {
|
|
8
|
+
useActivePlayer,
|
|
9
|
+
useIsActivePlayer,
|
|
10
|
+
useLastActivePlayer,
|
|
11
|
+
} from './hooks/useActivePlayer';
|
|
12
|
+
import { usePlayerPreferences } from './hooks/usePlayerPreferences';
|
|
13
|
+
import { Cover } from './parts/Cover/Cover';
|
|
14
|
+
import { LoopButton } from './parts/Controls/LoopButton';
|
|
15
|
+
import { PlayButton } from './parts/Controls/PlayButton';
|
|
16
|
+
import { VolumeControl } from './parts/Controls/VolumeControl';
|
|
17
|
+
import { Artist } from './parts/Meta/Artist';
|
|
18
|
+
import { TimeDisplay } from './parts/Meta/TimeDisplay';
|
|
19
|
+
import { Title } from './parts/Meta/Title';
|
|
20
|
+
import { Waveform } from './parts/Waveform/Waveform';
|
|
21
|
+
import type { WaveformMode } from './types';
|
|
3
22
|
|
|
4
23
|
export default defineStory({
|
|
5
24
|
title: 'Tools/Audio Player',
|
|
6
|
-
component:
|
|
7
|
-
description:
|
|
25
|
+
component: Player,
|
|
26
|
+
description:
|
|
27
|
+
'WebView-safe audio player. Static peaks waveform by default; clip-path playhead; one accent.',
|
|
8
28
|
});
|
|
9
29
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export const Interactive = () => {
|
|
18
|
-
const [track] = useSelect('track', {
|
|
19
|
-
options: ['sample1', 'sample2', 'sample3'] as const,
|
|
20
|
-
defaultValue: 'sample1',
|
|
21
|
-
label: 'Track',
|
|
22
|
-
description: 'Select audio track',
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
const [showWaveform] = useBoolean('showWaveform', {
|
|
26
|
-
defaultValue: true,
|
|
27
|
-
label: 'Show Waveform',
|
|
28
|
-
description: 'Display audio waveform visualization',
|
|
29
|
-
});
|
|
30
|
+
// Local samples copied from @sources/. Vite serves them same-origin so
|
|
31
|
+
// crossOrigin="anonymous" + decodeAudioData work without CORS friction.
|
|
32
|
+
const SAMPLES = {
|
|
33
|
+
short: '/audio/short.mp3',
|
|
34
|
+
voice: '/audio/voice.mp3',
|
|
35
|
+
long: '/audio/long.mp3',
|
|
36
|
+
} as const;
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
const COVER = 'data:image/svg+xml;utf8,' + encodeURIComponent(`
|
|
39
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
|
|
40
|
+
<rect width="200" height="200" fill="#0f172a"/>
|
|
41
|
+
<circle cx="100" cy="100" r="56" fill="none" stroke="#94a3b8" stroke-width="2"/>
|
|
42
|
+
<circle cx="100" cy="100" r="6" fill="#94a3b8"/>
|
|
43
|
+
</svg>
|
|
44
|
+
`);
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
label: 'Cover Effect',
|
|
41
|
-
description: 'Reactive cover animation style',
|
|
42
|
-
});
|
|
46
|
+
const Frame = ({ children, max = 'max-w-xl' }: { children: React.ReactNode; max?: string }) => (
|
|
47
|
+
<div className={`mx-auto w-full ${max} p-6`}>{children}</div>
|
|
48
|
+
);
|
|
43
49
|
|
|
50
|
+
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
|
44
51
|
return (
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
className="w-full h-full object-cover"
|
|
53
|
-
/>
|
|
54
|
-
</AudioReactiveCover>
|
|
55
|
-
)}
|
|
56
|
-
<HybridAudioPlayer showWaveform={showWaveform} showControls={showControls} />
|
|
57
|
-
</HybridAudioProvider>
|
|
58
|
-
</div>
|
|
52
|
+
<section className="space-y-2">
|
|
53
|
+
<header>
|
|
54
|
+
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
|
55
|
+
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
56
|
+
</header>
|
|
57
|
+
{children}
|
|
58
|
+
</section>
|
|
59
59
|
);
|
|
60
|
-
}
|
|
60
|
+
}
|
|
61
61
|
|
|
62
|
-
export const
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
title="Sample Track 1"
|
|
67
|
-
/>
|
|
68
|
-
</div>
|
|
62
|
+
export const Default = () => (
|
|
63
|
+
<Frame>
|
|
64
|
+
<Player src={SAMPLES.short} title="Stereo demo" artist="wavesurfer samples" />
|
|
65
|
+
</Frame>
|
|
69
66
|
);
|
|
70
67
|
|
|
71
|
-
export const
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
</HybridAudioProvider>
|
|
76
|
-
</div>
|
|
68
|
+
export const WithCover = () => (
|
|
69
|
+
<Frame>
|
|
70
|
+
<Player src={SAMPLES.short} title="With cover" artist="wavesurfer samples" cover={COVER} />
|
|
71
|
+
</Frame>
|
|
77
72
|
);
|
|
78
73
|
|
|
79
|
-
export const
|
|
80
|
-
<
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
<AudioReactiveCover variant="glow">
|
|
84
|
-
<img
|
|
85
|
-
src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400"
|
|
86
|
-
alt="Album cover"
|
|
87
|
-
className="w-full h-full object-cover"
|
|
88
|
-
/>
|
|
89
|
-
</AudioReactiveCover>
|
|
90
|
-
<HybridAudioPlayer showControls />
|
|
91
|
-
</div>
|
|
92
|
-
</HybridAudioProvider>
|
|
93
|
-
</div>
|
|
74
|
+
export const Compact = () => (
|
|
75
|
+
<Frame max="max-w-md">
|
|
76
|
+
<Player src={SAMPLES.short} title="Compact" variant="compact" />
|
|
77
|
+
</Frame>
|
|
94
78
|
);
|
|
95
79
|
|
|
96
|
-
export const
|
|
97
|
-
<
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
80
|
+
export const Bars = () => (
|
|
81
|
+
<Frame>
|
|
82
|
+
<Player
|
|
83
|
+
src={SAMPLES.short}
|
|
84
|
+
title="Bars decoration"
|
|
85
|
+
artist="No audio coupling"
|
|
86
|
+
waveform={{ mode: 'bars', height: 28 }}
|
|
87
|
+
/>
|
|
88
|
+
</Frame>
|
|
102
89
|
);
|
|
103
90
|
|
|
104
|
-
export const
|
|
105
|
-
<
|
|
106
|
-
<
|
|
107
|
-
|
|
91
|
+
export const Live = () => (
|
|
92
|
+
<Frame>
|
|
93
|
+
<Player
|
|
94
|
+
src={SAMPLES.voice}
|
|
95
|
+
title="Live analyser"
|
|
96
|
+
artist="Reads from AnalyserNode at 30 Hz"
|
|
97
|
+
waveform={{ mode: 'live', height: 48 }}
|
|
98
|
+
/>
|
|
99
|
+
</Frame>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
export const NoWaveform = () => (
|
|
103
|
+
<Frame>
|
|
104
|
+
<Player src={SAMPLES.short} title="Minimal" artist="No waveform" waveform={{ mode: 'none' }} />
|
|
105
|
+
</Frame>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
export const CustomLayout = () => (
|
|
109
|
+
<Frame max="max-w-2xl">
|
|
110
|
+
<p className="mb-4 text-xs text-muted-foreground">
|
|
111
|
+
Slot composition: drop <code className="rounded bg-muted px-1">PlayerProvider</code> and
|
|
112
|
+
arrange the parts you want yourself. Big cover left (spans two rows), meta + timer right,
|
|
113
|
+
waveform + controls full-width below.
|
|
108
114
|
</p>
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
<HybridCompactPlayer src={AUDIO_SAMPLES.sample3} title="City Noise" />
|
|
112
|
-
</div>
|
|
115
|
+
<CustomLayoutDemo />
|
|
116
|
+
</Frame>
|
|
113
117
|
);
|
|
114
118
|
|
|
115
|
-
export const
|
|
116
|
-
<
|
|
117
|
-
<
|
|
118
|
-
src={
|
|
119
|
-
title="
|
|
120
|
-
|
|
119
|
+
export const ReactiveCover = () => (
|
|
120
|
+
<Frame>
|
|
121
|
+
<Player
|
|
122
|
+
src={SAMPLES.voice}
|
|
123
|
+
title="Subtle reactive cover"
|
|
124
|
+
artist="Tiny scale on the cover"
|
|
125
|
+
cover={COVER}
|
|
126
|
+
reactiveCover="subtle"
|
|
127
|
+
waveform={{ mode: 'live' }}
|
|
121
128
|
/>
|
|
122
|
-
</
|
|
129
|
+
</Frame>
|
|
123
130
|
);
|
|
124
131
|
|
|
125
|
-
export const
|
|
126
|
-
<
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
</div>
|
|
132
|
+
export const ErrorState = () => (
|
|
133
|
+
<Frame>
|
|
134
|
+
<Player
|
|
135
|
+
src="/audio/this-does-not-exist.mp3"
|
|
136
|
+
title="Broken source"
|
|
137
|
+
artist="Demonstrates error state"
|
|
138
|
+
/>
|
|
139
|
+
</Frame>
|
|
134
140
|
);
|
|
141
|
+
|
|
142
|
+
export const Exclusive = () => {
|
|
143
|
+
const active = useActivePlayer();
|
|
144
|
+
return (
|
|
145
|
+
<Frame max="max-w-2xl">
|
|
146
|
+
<p className="mb-4 text-xs text-muted-foreground">
|
|
147
|
+
Three players, <code className="rounded bg-muted px-1">exclusive</code> on (default).
|
|
148
|
+
Press play on one — the others pause automatically (same tab + cross-tab via{' '}
|
|
149
|
+
<code className="rounded bg-muted px-1">BroadcastChannel</code>).
|
|
150
|
+
<br />
|
|
151
|
+
Active id: <code className="rounded bg-muted px-1">{active ?? '∅'}</code>
|
|
152
|
+
</p>
|
|
153
|
+
<div className="space-y-3">
|
|
154
|
+
<Player src={SAMPLES.short} title="Track A · stereo" artist="wavesurfer" />
|
|
155
|
+
<Player src={SAMPLES.voice} title="Track B · librivox" artist="voice" />
|
|
156
|
+
<Player src={SAMPLES.long} title="Track C · phonograph" artist="deepnote" />
|
|
157
|
+
</div>
|
|
158
|
+
</Frame>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// One-stop demo of every prop / mode / hook. Long but scrollable; each section
|
|
163
|
+
// is independent so you can grab a single example as a reference.
|
|
164
|
+
export const Showcase = () => {
|
|
165
|
+
return (
|
|
166
|
+
<div className="mx-auto w-full max-w-3xl space-y-8 p-6">
|
|
167
|
+
<Section title="1 · Default" hint="Static peaks waveform, container query auto-switches to compact below 480 px / on phones.">
|
|
168
|
+
<Player src={SAMPLES.short} title="Stereo demo" artist="wavesurfer" />
|
|
169
|
+
</Section>
|
|
170
|
+
|
|
171
|
+
<Section title="2 · With cover" hint="Cover uses <img loading='lazy' decoding='async'>; placeholder otherwise.">
|
|
172
|
+
<Player src={SAMPLES.short} title="With cover" artist="wavesurfer" cover={COVER} />
|
|
173
|
+
</Section>
|
|
174
|
+
|
|
175
|
+
<Section title="3 · Compact (forced)" hint="Single-row layout. Always picked when variant='compact'.">
|
|
176
|
+
<Player src={SAMPLES.short} title="Compact" variant="compact" />
|
|
177
|
+
</Section>
|
|
178
|
+
|
|
179
|
+
<Section title="4 · Skip controls" hint="onPrev / onNext render the SkipBack / SkipForward buttons (and wire MediaSession).">
|
|
180
|
+
<Player
|
|
181
|
+
src={SAMPLES.short}
|
|
182
|
+
title="With prev / next"
|
|
183
|
+
artist="onPrev / onNext"
|
|
184
|
+
cover={COVER}
|
|
185
|
+
onPrev={() => console.log('prev')}
|
|
186
|
+
onNext={() => console.log('next')}
|
|
187
|
+
/>
|
|
188
|
+
</Section>
|
|
189
|
+
|
|
190
|
+
<Section title="5 · Live analyser" hint="mode='live' — AnalyserNode @ 30 Hz pushed into LevelsStore; canvas paints from rAF.">
|
|
191
|
+
<Player
|
|
192
|
+
src={SAMPLES.voice}
|
|
193
|
+
title="Live waveform"
|
|
194
|
+
artist="AnalyserNode at 30 Hz"
|
|
195
|
+
waveform={{ mode: 'live', height: 48 }}
|
|
196
|
+
/>
|
|
197
|
+
</Section>
|
|
198
|
+
|
|
199
|
+
<Section title="6 · Bars (decoration)" hint="mode='bars' — CSS-only equalizer animation, no audio coupling.">
|
|
200
|
+
<Player
|
|
201
|
+
src={SAMPLES.short}
|
|
202
|
+
title="Bars"
|
|
203
|
+
artist="No audio coupling"
|
|
204
|
+
waveform={{ mode: 'bars', height: 28 }}
|
|
205
|
+
/>
|
|
206
|
+
</Section>
|
|
207
|
+
|
|
208
|
+
<Section title="7a · Progress bar (no animation)" hint="mode='progress' — thin scrubber, no waveform/animation. Same click + drag + hover-tip + playhead pipeline.">
|
|
209
|
+
<Player
|
|
210
|
+
src={SAMPLES.short}
|
|
211
|
+
title="Plain progress"
|
|
212
|
+
artist="No waveform animation"
|
|
213
|
+
waveform={{ mode: 'progress', height: 4 }}
|
|
214
|
+
/>
|
|
215
|
+
</Section>
|
|
216
|
+
|
|
217
|
+
<Section title="7b · No waveform" hint="mode='none' — bare meta + controls. Use when even the scrubber gets in the way.">
|
|
218
|
+
<Player src={SAMPLES.short} title="Minimal" waveform={{ mode: 'none' }} />
|
|
219
|
+
</Section>
|
|
220
|
+
|
|
221
|
+
<Section title="8 · Subtle reactive cover" hint="reactiveCover='subtle' — compositor-only scale tied to a low-pass envelope.">
|
|
222
|
+
<Player
|
|
223
|
+
src={SAMPLES.voice}
|
|
224
|
+
title="Reactive cover"
|
|
225
|
+
artist="scale(1.00 — 1.03)"
|
|
226
|
+
cover={COVER}
|
|
227
|
+
reactiveCover="subtle"
|
|
228
|
+
waveform={{ mode: 'live' }}
|
|
229
|
+
/>
|
|
230
|
+
</Section>
|
|
231
|
+
|
|
232
|
+
<Section title="9 · Pre-computed peaks" hint="peaks prop seeds the cache — skips the OfflineAudioContext decode entirely.">
|
|
233
|
+
<PrecomputedPeaksDemo />
|
|
234
|
+
</Section>
|
|
235
|
+
|
|
236
|
+
<Section title="10 · seekStartsPlayback={false}" hint="Click on the waveform seeks but does not start playback. Useful in embeds.">
|
|
237
|
+
<Player
|
|
238
|
+
src={SAMPLES.short}
|
|
239
|
+
title="Click only seeks"
|
|
240
|
+
artist="No autoplay on click"
|
|
241
|
+
seekStartsPlayback={false}
|
|
242
|
+
/>
|
|
243
|
+
</Section>
|
|
244
|
+
|
|
245
|
+
<Section title="11 · Persistent volume sync" hint="Two uncontrolled players read / write the same persisted prefs (also synced across tabs).">
|
|
246
|
+
<PreferencesSyncDemo />
|
|
247
|
+
</Section>
|
|
248
|
+
|
|
249
|
+
<Section title="12 · Active-player coordination" hint="exclusive (default) pauses sibling players. Active id + 'last active' badges from useActivePlayer / useLastActivePlayer.">
|
|
250
|
+
<ActivePlayerDemo />
|
|
251
|
+
</Section>
|
|
252
|
+
|
|
253
|
+
<Section title="13 · Custom toolbar via useIsActivePlayer" hint="Uses Player's id+`useIsActivePlayer` to outline the playing card.">
|
|
254
|
+
<ActiveOutlineDemo />
|
|
255
|
+
</Section>
|
|
256
|
+
|
|
257
|
+
<Section title="14 · Custom layout (slot composition)" hint="Drop <PlayerProvider> + import the parts you want. Build any grid.">
|
|
258
|
+
<CustomLayoutDemo />
|
|
259
|
+
</Section>
|
|
260
|
+
|
|
261
|
+
<Section title="15 · Error state" hint="Bad src triggers MediaError → ErrorState with retry.">
|
|
262
|
+
<Player src="/audio/this-does-not-exist.mp3" title="Broken source" artist="See error pane" />
|
|
263
|
+
</Section>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function PrecomputedPeaksDemo() {
|
|
269
|
+
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
let alive = true;
|
|
272
|
+
decodePeaks(SAMPLES.short).then((p) => alive && setPeaks(p)).catch(() => undefined);
|
|
273
|
+
return () => {
|
|
274
|
+
alive = false;
|
|
275
|
+
};
|
|
276
|
+
}, []);
|
|
277
|
+
if (!peaks) {
|
|
278
|
+
return (
|
|
279
|
+
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-4 text-xs text-muted-foreground">
|
|
280
|
+
Decoding peaks once (so the player below mounts with them already in hand)…
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return (
|
|
285
|
+
<Player
|
|
286
|
+
src={SAMPLES.short}
|
|
287
|
+
title="Pre-computed peaks"
|
|
288
|
+
artist={`Float32Array · ${peaks.length} buckets`}
|
|
289
|
+
peaks={peaks}
|
|
290
|
+
/>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function PreferencesSyncDemo() {
|
|
295
|
+
const prefs = usePlayerPreferences();
|
|
296
|
+
return (
|
|
297
|
+
<div className="space-y-3">
|
|
298
|
+
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs">
|
|
299
|
+
Stored prefs:{' '}
|
|
300
|
+
<code className="text-foreground">
|
|
301
|
+
volume {Math.round(prefs.volume * 100)}% · {prefs.muted ? 'muted' : 'unmuted'}
|
|
302
|
+
</code>
|
|
303
|
+
</div>
|
|
304
|
+
<Player src={SAMPLES.short} title="Player A" artist="uncontrolled volume" />
|
|
305
|
+
<Player src={SAMPLES.voice} title="Player B" artist="uncontrolled volume" />
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function ActivePlayerDemo() {
|
|
311
|
+
const active = useActivePlayer();
|
|
312
|
+
const last = useLastActivePlayer();
|
|
313
|
+
return (
|
|
314
|
+
<div className="space-y-3">
|
|
315
|
+
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
316
|
+
<div className="rounded border border-border/60 bg-card px-2 py-1.5">
|
|
317
|
+
Active: <code className="text-foreground">{active ?? '∅'}</code>
|
|
318
|
+
</div>
|
|
319
|
+
<div className="rounded border border-border/60 bg-card px-2 py-1.5">
|
|
320
|
+
Last: <code className="text-foreground">{last ?? '∅'}</code>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
<Player src={SAMPLES.short} title="Track A" artist="exclusive default" />
|
|
324
|
+
<Player src={SAMPLES.voice} title="Track B" artist="exclusive default" />
|
|
325
|
+
<Player src={SAMPLES.long} title="Track C" artist="exclusive default" />
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ActiveOutlineCard({
|
|
331
|
+
id,
|
|
332
|
+
src,
|
|
333
|
+
title,
|
|
334
|
+
}: {
|
|
335
|
+
id: string;
|
|
336
|
+
src: string;
|
|
337
|
+
title: string;
|
|
338
|
+
}) {
|
|
339
|
+
const isActive = useIsActivePlayer(id);
|
|
340
|
+
return (
|
|
341
|
+
<div
|
|
342
|
+
className={`rounded-lg p-px transition-colors ${
|
|
343
|
+
isActive ? 'bg-primary/60' : 'bg-transparent'
|
|
344
|
+
}`}
|
|
345
|
+
>
|
|
346
|
+
<Player src={src} title={title} artist={isActive ? 'now playing' : 'idle'} ariaLabel={id} />
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function ActiveOutlineDemo() {
|
|
352
|
+
// We pass a stable `ariaLabel` as the id we read back from the bus.
|
|
353
|
+
// useActivePlayer returns React's useId-generated value, so a real consumer
|
|
354
|
+
// would key off that instead — this is just to demonstrate the pattern.
|
|
355
|
+
return (
|
|
356
|
+
<div className="space-y-3">
|
|
357
|
+
<p className="text-xs text-muted-foreground">
|
|
358
|
+
The currently playing card gets a primary ring. Implementation reads
|
|
359
|
+
<code className="rounded bg-muted px-1">useIsActivePlayer(id)</code>.
|
|
360
|
+
</p>
|
|
361
|
+
<ActiveOutlineCard id="card-a" src={SAMPLES.short} title="Card A" />
|
|
362
|
+
<ActiveOutlineCard id="card-b" src={SAMPLES.voice} title="Card B" />
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function CustomLayoutDemo() {
|
|
368
|
+
return (
|
|
369
|
+
<TooltipProvider delayDuration={400}>
|
|
370
|
+
<PlayerProvider src={SAMPLES.long} title="Custom layout demo" artist="phonograph" cover={COVER}>
|
|
371
|
+
<div className="grid grid-cols-[96px_1fr_auto] gap-4 rounded-lg border border-border/60 bg-card p-4">
|
|
372
|
+
{/* Left: large cover spanning two rows */}
|
|
373
|
+
<div className="row-span-2">
|
|
374
|
+
<Cover size={96} alt="Custom layout cover" />
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{/* Top right: meta + timer */}
|
|
378
|
+
<div className="min-w-0">
|
|
379
|
+
<Title />
|
|
380
|
+
<Artist />
|
|
381
|
+
</div>
|
|
382
|
+
<TimeDisplay />
|
|
383
|
+
|
|
384
|
+
{/* Bottom: full-width waveform + controls */}
|
|
385
|
+
<div className="col-span-2 space-y-3">
|
|
386
|
+
<Waveform height={48} />
|
|
387
|
+
<div className="flex items-center justify-between gap-2">
|
|
388
|
+
<PlayButton />
|
|
389
|
+
<div className="flex items-center gap-1">
|
|
390
|
+
<VolumeControl />
|
|
391
|
+
<LoopButton />
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
</PlayerProvider>
|
|
397
|
+
</TooltipProvider>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export const Interactive = () => {
|
|
402
|
+
const [sampleKey] = useSelect('sample', {
|
|
403
|
+
options: ['short', 'voice', 'long'] as const,
|
|
404
|
+
defaultValue: 'short',
|
|
405
|
+
label: 'Sample',
|
|
406
|
+
});
|
|
407
|
+
const [mode] = useSelect('waveformMode', {
|
|
408
|
+
options: ['peaks', 'live', 'bars', 'progress', 'none'] as const,
|
|
409
|
+
defaultValue: 'peaks',
|
|
410
|
+
label: 'Waveform mode',
|
|
411
|
+
});
|
|
412
|
+
const [variant] = useSelect('variant', {
|
|
413
|
+
options: ['auto', 'default', 'compact'] as const,
|
|
414
|
+
defaultValue: 'default',
|
|
415
|
+
label: 'Variant',
|
|
416
|
+
});
|
|
417
|
+
const [withCover] = useBoolean('withCover', { defaultValue: true, label: 'Show cover' });
|
|
418
|
+
const [reactive] = useBoolean('reactive', {
|
|
419
|
+
defaultValue: false,
|
|
420
|
+
label: 'Reactive cover (subtle)',
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const [userSrc, setUserSrc] = useState<string | null>(null);
|
|
424
|
+
const [userName, setUserName] = useState<string>('');
|
|
425
|
+
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
return () => {
|
|
428
|
+
if (userSrc?.startsWith('blob:')) URL.revokeObjectURL(userSrc);
|
|
429
|
+
};
|
|
430
|
+
}, [userSrc]);
|
|
431
|
+
|
|
432
|
+
const src = userSrc ?? SAMPLES[sampleKey];
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<Frame>
|
|
436
|
+
<div className="mb-4 flex flex-wrap items-center gap-3">
|
|
437
|
+
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border/60 bg-card px-3 py-1.5 text-xs hover:bg-accent">
|
|
438
|
+
<span>Drop / pick a file</span>
|
|
439
|
+
<input
|
|
440
|
+
type="file"
|
|
441
|
+
accept="audio/*"
|
|
442
|
+
className="hidden"
|
|
443
|
+
onChange={(e) => {
|
|
444
|
+
const file = e.target.files?.[0];
|
|
445
|
+
if (!file) return;
|
|
446
|
+
if (userSrc?.startsWith('blob:')) URL.revokeObjectURL(userSrc);
|
|
447
|
+
setUserSrc(URL.createObjectURL(file));
|
|
448
|
+
setUserName(file.name);
|
|
449
|
+
}}
|
|
450
|
+
/>
|
|
451
|
+
</label>
|
|
452
|
+
{userSrc && (
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
onClick={() => {
|
|
456
|
+
if (userSrc.startsWith('blob:')) URL.revokeObjectURL(userSrc);
|
|
457
|
+
setUserSrc(null);
|
|
458
|
+
setUserName('');
|
|
459
|
+
}}
|
|
460
|
+
className="rounded-md border border-border/60 px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
|
461
|
+
>
|
|
462
|
+
Reset to sample
|
|
463
|
+
</button>
|
|
464
|
+
)}
|
|
465
|
+
<span className="text-xs text-muted-foreground">
|
|
466
|
+
{userSrc ? userName : `sample: ${sampleKey}`}
|
|
467
|
+
</span>
|
|
468
|
+
</div>
|
|
469
|
+
<Player
|
|
470
|
+
key={src}
|
|
471
|
+
src={src}
|
|
472
|
+
title={userSrc ? userName : 'Local sample'}
|
|
473
|
+
artist={userSrc ? 'Your file (blob URL)' : 'wavesurfer / phonograph'}
|
|
474
|
+
cover={withCover ? COVER : undefined}
|
|
475
|
+
variant={variant}
|
|
476
|
+
waveform={{ mode: mode as WaveformMode }}
|
|
477
|
+
reactiveCover={reactive ? 'subtle' : false}
|
|
478
|
+
/>
|
|
479
|
+
</Frame>
|
|
480
|
+
);
|
|
481
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { forwardRef } from 'react';
|
|
4
|
+
import { PlayerProvider } from './context/PlayerProvider';
|
|
5
|
+
import { PlayerShell } from './PlayerShell';
|
|
6
|
+
import './styles/webview-safe.css';
|
|
7
|
+
import type { PlayerHandle, PlayerProps } from './types';
|
|
8
|
+
|
|
9
|
+
export const Player = forwardRef<PlayerHandle, PlayerProps>(function Player(props, ref) {
|
|
10
|
+
const {
|
|
11
|
+
src,
|
|
12
|
+
peaks,
|
|
13
|
+
title,
|
|
14
|
+
artist,
|
|
15
|
+
album,
|
|
16
|
+
cover,
|
|
17
|
+
autoplay,
|
|
18
|
+
loop,
|
|
19
|
+
initialVolume,
|
|
20
|
+
muted,
|
|
21
|
+
preload,
|
|
22
|
+
exclusive,
|
|
23
|
+
onPrev,
|
|
24
|
+
onNext,
|
|
25
|
+
onPlay,
|
|
26
|
+
onPause,
|
|
27
|
+
onEnded,
|
|
28
|
+
onError,
|
|
29
|
+
onTimeUpdate,
|
|
30
|
+
variant,
|
|
31
|
+
waveform,
|
|
32
|
+
reactiveCover,
|
|
33
|
+
className,
|
|
34
|
+
ariaLabel,
|
|
35
|
+
enableKeyboardShortcuts,
|
|
36
|
+
seekStartsPlayback,
|
|
37
|
+
} = props;
|
|
38
|
+
|
|
39
|
+
// onTimeUpdate is intentionally not wired in the provider — we expose it via
|
|
40
|
+
// ref or future hook. Keep the prop accepted so callers can adopt later.
|
|
41
|
+
void onTimeUpdate;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<PlayerProvider
|
|
45
|
+
src={src}
|
|
46
|
+
peaks={peaks}
|
|
47
|
+
title={title}
|
|
48
|
+
artist={artist}
|
|
49
|
+
album={album}
|
|
50
|
+
cover={cover}
|
|
51
|
+
autoplay={autoplay}
|
|
52
|
+
loop={loop}
|
|
53
|
+
initialVolume={initialVolume}
|
|
54
|
+
muted={muted}
|
|
55
|
+
preload={preload}
|
|
56
|
+
exclusive={exclusive}
|
|
57
|
+
onPrev={onPrev}
|
|
58
|
+
onNext={onNext}
|
|
59
|
+
onPlay={onPlay}
|
|
60
|
+
onPause={onPause}
|
|
61
|
+
onEnded={onEnded}
|
|
62
|
+
onError={onError}
|
|
63
|
+
>
|
|
64
|
+
<PlayerShell
|
|
65
|
+
className={className}
|
|
66
|
+
variant={variant}
|
|
67
|
+
waveform={waveform}
|
|
68
|
+
reactiveCover={reactiveCover}
|
|
69
|
+
onPrev={onPrev}
|
|
70
|
+
onNext={onNext}
|
|
71
|
+
enableKeyboardShortcuts={enableKeyboardShortcuts}
|
|
72
|
+
ariaLabel={ariaLabel}
|
|
73
|
+
seekStartsPlayback={seekStartsPlayback}
|
|
74
|
+
handleRef={ref}
|
|
75
|
+
/>
|
|
76
|
+
</PlayerProvider>
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
Player.displayName = 'AudioPlayer';
|