@djangocfg/ui-tools 2.1.312 → 2.1.313

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +38 -22
  2. package/dist/{DocsLayout-W5JLRNSZ.mjs → DocsLayout-ESVQZO3V.mjs} +3 -3
  3. package/dist/{DocsLayout-W5JLRNSZ.mjs.map → DocsLayout-ESVQZO3V.mjs.map} +1 -1
  4. package/dist/{DocsLayout-ZXD2CUOH.cjs → DocsLayout-KUPDWJ3G.cjs} +48 -48
  5. package/dist/{DocsLayout-ZXD2CUOH.cjs.map → DocsLayout-KUPDWJ3G.cjs.map} +1 -1
  6. package/dist/Player-M3GC3VPE.mjs +4 -0
  7. package/dist/Player-M3GC3VPE.mjs.map +1 -0
  8. package/dist/Player-ZGQKKOWI.css +65 -0
  9. package/dist/Player-ZGQKKOWI.css.map +1 -0
  10. package/dist/Player-ZL2X5LGG.cjs +13 -0
  11. package/dist/Player-ZL2X5LGG.cjs.map +1 -0
  12. package/dist/{chunk-CXVGN6ZW.cjs → chunk-DFTVB66S.cjs} +7 -6
  13. package/dist/chunk-DFTVB66S.cjs.map +1 -0
  14. package/dist/{chunk-2QY3LJR6.mjs → chunk-EUADAUBQ.mjs} +5 -4
  15. package/dist/chunk-EUADAUBQ.mjs.map +1 -0
  16. package/dist/chunk-FX2QFYWF.mjs +2059 -0
  17. package/dist/chunk-FX2QFYWF.mjs.map +1 -0
  18. package/dist/{chunk-6HNAPVZ2.mjs → chunk-GBLQTHWT.mjs} +11 -13
  19. package/dist/chunk-GBLQTHWT.mjs.map +1 -0
  20. package/dist/{chunk-FYLR232K.cjs → chunk-S44PW6NK.cjs} +11 -13
  21. package/dist/chunk-S44PW6NK.cjs.map +1 -0
  22. package/dist/chunk-ZLQHUZDU.cjs +2061 -0
  23. package/dist/chunk-ZLQHUZDU.cjs.map +1 -0
  24. package/dist/components-WYEZL5TE.cjs +26 -0
  25. package/dist/{components-3RTH76CV.cjs.map → components-WYEZL5TE.cjs.map} +1 -1
  26. package/dist/components-ZAGG2PBO.mjs +5 -0
  27. package/dist/{components-5GVVL2Q6.mjs.map → components-ZAGG2PBO.mjs.map} +1 -1
  28. package/dist/index.cjs +36 -220
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.css +65 -0
  31. package/dist/index.css.map +1 -1
  32. package/dist/index.d.cts +44 -500
  33. package/dist/index.d.ts +44 -500
  34. package/dist/index.mjs +16 -62
  35. package/dist/index.mjs.map +1 -1
  36. package/package.json +6 -6
  37. package/src/components/markdown/MarkdownMessage/ActionRow.tsx +48 -0
  38. package/src/components/markdown/MarkdownMessage/ChatMessageRow.tsx +97 -0
  39. package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +9 -13
  40. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +77 -2
  41. package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +2 -3
  42. package/src/components/markdown/MarkdownMessage/README.md +72 -0
  43. package/src/components/markdown/MarkdownMessage/components.tsx +3 -3
  44. package/src/components/markdown/MarkdownMessage/index.ts +6 -0
  45. package/src/index.ts +2 -11
  46. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +454 -107
  47. package/src/tools/AudioPlayer/Player.tsx +80 -0
  48. package/src/tools/AudioPlayer/PlayerShell.tsx +122 -0
  49. package/src/tools/AudioPlayer/README.md +139 -204
  50. package/src/tools/AudioPlayer/audio/audioContext.ts +39 -0
  51. package/src/tools/AudioPlayer/audio/decodePeaks.ts +36 -0
  52. package/src/tools/AudioPlayer/audio/index.ts +4 -0
  53. package/src/tools/AudioPlayer/audio/mediaElementSourceCache.ts +20 -0
  54. package/src/tools/AudioPlayer/audio/peaksCache.ts +37 -0
  55. package/src/tools/AudioPlayer/context/AudioRefContext.tsx +9 -0
  56. package/src/tools/AudioPlayer/context/ControlsContext.tsx +7 -0
  57. package/src/tools/AudioPlayer/context/LevelsContext.tsx +7 -0
  58. package/src/tools/AudioPlayer/context/MetaContext.tsx +16 -0
  59. package/src/tools/AudioPlayer/context/PlayerProvider.tsx +314 -0
  60. package/src/tools/AudioPlayer/context/StateContext.tsx +7 -0
  61. package/src/tools/AudioPlayer/context/index.ts +16 -15
  62. package/src/tools/AudioPlayer/context/selectors.ts +36 -0
  63. package/src/tools/AudioPlayer/hooks/index.ts +12 -39
  64. package/src/tools/AudioPlayer/hooks/useActivePlayer.ts +31 -0
  65. package/src/tools/AudioPlayer/hooks/useAnalyser.ts +62 -0
  66. package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +102 -0
  67. package/src/tools/AudioPlayer/hooks/useKeyboardShortcuts.ts +91 -0
  68. package/src/tools/AudioPlayer/hooks/useMediaSession.ts +74 -0
  69. package/src/tools/AudioPlayer/hooks/usePeaks.ts +83 -0
  70. package/src/tools/AudioPlayer/hooks/usePlayerPreferences.ts +21 -0
  71. package/src/tools/AudioPlayer/hooks/usePlayheadLoop.ts +77 -0
  72. package/src/tools/AudioPlayer/hooks/useResizeObserver.ts +20 -0
  73. package/src/tools/AudioPlayer/hooks/useThemeWatcher.ts +22 -0
  74. package/src/tools/AudioPlayer/index.ts +63 -134
  75. package/src/tools/AudioPlayer/lazy.tsx +8 -97
  76. package/src/tools/AudioPlayer/parts/Controls/ControlsRow.tsx +30 -0
  77. package/src/tools/AudioPlayer/parts/Controls/IconButton.tsx +62 -0
  78. package/src/tools/AudioPlayer/parts/Controls/LoopButton.tsx +33 -0
  79. package/src/tools/AudioPlayer/parts/Controls/PlayButton.tsx +86 -0
  80. package/src/tools/AudioPlayer/parts/Controls/SkipButton.tsx +17 -0
  81. package/src/tools/AudioPlayer/parts/Controls/VolumeControl.tsx +171 -0
  82. package/src/tools/AudioPlayer/parts/Controls/index.ts +6 -0
  83. package/src/tools/AudioPlayer/parts/Cover/Cover.tsx +24 -0
  84. package/src/tools/AudioPlayer/parts/Cover/CoverPlaceholder.tsx +27 -0
  85. package/src/tools/AudioPlayer/parts/Cover/ReactivePulse.tsx +66 -0
  86. package/src/tools/AudioPlayer/parts/Cover/index.ts +3 -0
  87. package/src/tools/AudioPlayer/parts/ErrorState/ErrorState.tsx +35 -0
  88. package/src/tools/AudioPlayer/parts/ErrorState/index.ts +1 -0
  89. package/src/tools/AudioPlayer/parts/Layout/CompactLayout.tsx +25 -0
  90. package/src/tools/AudioPlayer/parts/Layout/DefaultLayout.tsx +48 -0
  91. package/src/tools/AudioPlayer/parts/Layout/index.ts +2 -0
  92. package/src/tools/AudioPlayer/parts/Meta/Artist.tsx +14 -0
  93. package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +49 -0
  94. package/src/tools/AudioPlayer/parts/Meta/Title.tsx +13 -0
  95. package/src/tools/AudioPlayer/parts/Meta/index.ts +3 -0
  96. package/src/tools/AudioPlayer/parts/Skeleton/CoverSkeleton.tsx +13 -0
  97. package/src/tools/AudioPlayer/parts/Skeleton/MetaSkeleton.tsx +10 -0
  98. package/src/tools/AudioPlayer/parts/Skeleton/index.ts +2 -0
  99. package/src/tools/AudioPlayer/parts/Waveform/BarsWaveform.tsx +48 -0
  100. package/src/tools/AudioPlayer/parts/Waveform/LiveWaveform.tsx +95 -0
  101. package/src/tools/AudioPlayer/parts/Waveform/PeaksWaveform.tsx +100 -0
  102. package/src/tools/AudioPlayer/parts/Waveform/ProgressBar.tsx +76 -0
  103. package/src/tools/AudioPlayer/parts/Waveform/Waveform.tsx +74 -0
  104. package/src/tools/AudioPlayer/parts/Waveform/WaveformSkeleton.tsx +16 -0
  105. package/src/tools/AudioPlayer/parts/Waveform/index.ts +8 -0
  106. package/src/tools/AudioPlayer/parts/Waveform/waveformInteraction.ts +106 -0
  107. package/src/tools/AudioPlayer/parts/Waveform/waveformRenderer.ts +91 -0
  108. package/src/tools/AudioPlayer/parts/index.ts +1 -0
  109. package/src/tools/AudioPlayer/store/activePlayerBus.ts +63 -0
  110. package/src/tools/AudioPlayer/store/createLevelsStore.ts +37 -0
  111. package/src/tools/AudioPlayer/store/index.ts +16 -0
  112. package/src/tools/AudioPlayer/store/preferencesStore.ts +104 -0
  113. package/src/tools/AudioPlayer/styles/webview-safe.css +77 -0
  114. package/src/tools/AudioPlayer/types.ts +95 -0
  115. package/src/tools/AudioPlayer/utils/bucketize.ts +27 -0
  116. package/src/tools/AudioPlayer/utils/clamp.ts +5 -0
  117. package/src/tools/AudioPlayer/utils/dpr.ts +19 -0
  118. package/src/tools/AudioPlayer/utils/formatTime.ts +12 -8
  119. package/src/tools/AudioPlayer/utils/index.ts +4 -5
  120. package/src/tools/AudioPlayer/utils/readCssVar.ts +7 -0
  121. package/src/tools/AudioPlayer/utils/resolveCanvasColor.ts +28 -0
  122. package/src/tools/index.ts +5 -75
  123. package/dist/chunk-2QY3LJR6.mjs.map +0 -1
  124. package/dist/chunk-6HNAPVZ2.mjs.map +0 -1
  125. package/dist/chunk-CXVGN6ZW.cjs.map +0 -1
  126. package/dist/chunk-F2N7P5XU.cjs +0 -30
  127. package/dist/chunk-F2N7P5XU.cjs.map +0 -1
  128. package/dist/chunk-FYLR232K.cjs.map +0 -1
  129. package/dist/chunk-HMHIVEMS.mjs +0 -1619
  130. package/dist/chunk-HMHIVEMS.mjs.map +0 -1
  131. package/dist/chunk-JWB2EWQO.mjs +0 -5
  132. package/dist/chunk-JWB2EWQO.mjs.map +0 -1
  133. package/dist/chunk-YZX6FH3H.cjs +0 -1656
  134. package/dist/chunk-YZX6FH3H.cjs.map +0 -1
  135. package/dist/components-3RTH76CV.cjs +0 -27
  136. package/dist/components-5GVVL2Q6.mjs +0 -5
  137. package/dist/components-CPHOUQ5F.cjs +0 -46
  138. package/dist/components-CPHOUQ5F.cjs.map +0 -1
  139. package/dist/components-OTK43IMD.mjs +0 -6
  140. package/dist/components-OTK43IMD.mjs.map +0 -1
  141. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +0 -225
  142. package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +0 -163
  143. package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +0 -284
  144. package/src/tools/AudioPlayer/components/HybridWaveform.tsx +0 -286
  145. package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +0 -151
  146. package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +0 -110
  147. package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +0 -58
  148. package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +0 -45
  149. package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +0 -82
  150. package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +0 -8
  151. package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +0 -6
  152. package/src/tools/AudioPlayer/components/index.ts +0 -23
  153. package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +0 -158
  154. package/src/tools/AudioPlayer/effects/index.ts +0 -412
  155. package/src/tools/AudioPlayer/hooks/useAudioBus.ts +0 -76
  156. package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +0 -403
  157. package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +0 -96
  158. package/src/tools/AudioPlayer/hooks/useVisualization.tsx +0 -207
  159. package/src/tools/AudioPlayer/types/effects.ts +0 -73
  160. package/src/tools/AudioPlayer/types/index.ts +0 -27
  161. package/src/tools/AudioPlayer/utils/debug.ts +0 -14
@@ -1,134 +1,481 @@
1
+ import { useEffect, useState } from 'react';
1
2
  import { defineStory, useBoolean, useSelect } from '@djangocfg/playground';
2
- import { HybridSimplePlayer, HybridCompactPlayer, HybridAudioProvider, HybridAudioPlayer, AudioReactiveCover } from './index';
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: HybridSimplePlayer,
7
- description: 'Audio player with waveform visualization and reactive effects.',
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
- // CORS-friendly audio samples from Wikimedia Commons (Access-Control-Allow-Origin: *)
11
- const AUDIO_SAMPLES = {
12
- sample1: 'https://upload.wikimedia.org/wikipedia/commons/4/4e/BWV_543-fugue.ogg',
13
- sample2: 'https://upload.wikimedia.org/wikipedia/commons/c/c4/Muriel-Nguyen-Xuan-Chopin-valse-opus64-1.ogg',
14
- sample3: 'https://upload.wikimedia.org/wikipedia/commons/a/a9/Tromboon-sample.ogg',
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
- const [showControls] = useBoolean('showControls', {
32
- defaultValue: true,
33
- label: 'Show Controls',
34
- description: 'Display playback controls',
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
- const [coverVariant] = useSelect('coverVariant', {
38
- options: ['spotlight', 'glow', 'orbs', 'mesh', 'none'] as const,
39
- defaultValue: 'spotlight',
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
- <div className="max-w-md space-y-6">
46
- <HybridAudioProvider src={AUDIO_SAMPLES[track]}>
47
- {coverVariant !== 'none' && (
48
- <AudioReactiveCover variant={coverVariant}>
49
- <img
50
- src="https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400"
51
- alt="Album cover"
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 Simple = () => (
63
- <div className="max-w-2xl">
64
- <HybridSimplePlayer
65
- src={AUDIO_SAMPLES.sample1}
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 WithWaveform = () => (
72
- <div className="max-w-2xl">
73
- <HybridAudioProvider src={AUDIO_SAMPLES.sample2}>
74
- <HybridAudioPlayer showWaveform showControls />
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 WithReactiveCover = () => (
80
- <div className="max-w-md">
81
- <HybridAudioProvider src={AUDIO_SAMPLES.sample3}>
82
- <div className="space-y-4">
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 Multiple = () => (
97
- <div className="max-w-2xl space-y-4">
98
- <HybridSimplePlayer src={AUDIO_SAMPLES.sample1} title="Track 1" />
99
- <HybridSimplePlayer src={AUDIO_SAMPLES.sample2} title="Track 2" />
100
- <HybridSimplePlayer src={AUDIO_SAMPLES.sample3} title="Track 3" />
101
- </div>
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 Compact = () => (
105
- <div className="max-w-sm space-y-2">
106
- <p className="text-sm text-muted-foreground mb-3">
107
- Single-row player — ideal for lists, sidebars, and tight spaces.
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
- <HybridCompactPlayer src={AUDIO_SAMPLES.sample1} title="Rain & Thunder" />
110
- <HybridCompactPlayer src={AUDIO_SAMPLES.sample2} title="Ambient Loop" />
111
- <HybridCompactPlayer src={AUDIO_SAMPLES.sample3} title="City Noise" />
112
- </div>
115
+ <CustomLayoutDemo />
116
+ </Frame>
113
117
  );
114
118
 
115
- export const CompactNoTimer = () => (
116
- <div className="max-w-xs">
117
- <HybridCompactPlayer
118
- src={AUDIO_SAMPLES.sample1}
119
- title="Without timer"
120
- showTimer={false}
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
- </div>
129
+ </Frame>
123
130
  );
124
131
 
125
- export const CompactSmall = () => (
126
- <div className="max-w-sm space-y-2">
127
- <p className="text-sm text-muted-foreground mb-3">
128
- Small button size — for inline use in lists and cards.
129
- </p>
130
- <HybridCompactPlayer src={AUDIO_SAMPLES.sample1} title="Track 1" buttonSize="sm" />
131
- <HybridCompactPlayer src={AUDIO_SAMPLES.sample2} title="Track 2" buttonSize="sm" waveformMode="static" />
132
- <HybridCompactPlayer src={AUDIO_SAMPLES.sample3} title="Track 3" buttonSize="sm" showTimer={false} />
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';