@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.
Files changed (161) hide show
  1. package/README.md +38 -22
  2. package/dist/{DocsLayout-W5JLRNSZ.mjs → DocsLayout-ESVQZO3V.mjs} +3 -3
  3. package/dist/{DocsLayout-W5JLRNSZ.mjs.map → DocsLayout-ESVQZO3V.mjs.map} +1 -1
  4. package/dist/{DocsLayout-ZXD2CUOH.cjs → DocsLayout-KUPDWJ3G.cjs} +48 -48
  5. package/dist/{DocsLayout-ZXD2CUOH.cjs.map → DocsLayout-KUPDWJ3G.cjs.map} +1 -1
  6. package/dist/Player-M3GC3VPE.mjs +4 -0
  7. package/dist/Player-M3GC3VPE.mjs.map +1 -0
  8. package/dist/Player-ZGQKKOWI.css +65 -0
  9. package/dist/Player-ZGQKKOWI.css.map +1 -0
  10. package/dist/Player-ZL2X5LGG.cjs +13 -0
  11. package/dist/Player-ZL2X5LGG.cjs.map +1 -0
  12. package/dist/{chunk-CXVGN6ZW.cjs → chunk-DFTVB66S.cjs} +7 -6
  13. package/dist/chunk-DFTVB66S.cjs.map +1 -0
  14. package/dist/{chunk-2QY3LJR6.mjs → chunk-EUADAUBQ.mjs} +5 -4
  15. package/dist/chunk-EUADAUBQ.mjs.map +1 -0
  16. package/dist/chunk-FX2QFYWF.mjs +2059 -0
  17. package/dist/chunk-FX2QFYWF.mjs.map +1 -0
  18. package/dist/{chunk-6HNAPVZ2.mjs → chunk-GBLQTHWT.mjs} +11 -13
  19. package/dist/chunk-GBLQTHWT.mjs.map +1 -0
  20. package/dist/{chunk-FYLR232K.cjs → chunk-S44PW6NK.cjs} +11 -13
  21. package/dist/chunk-S44PW6NK.cjs.map +1 -0
  22. package/dist/chunk-ZLQHUZDU.cjs +2061 -0
  23. package/dist/chunk-ZLQHUZDU.cjs.map +1 -0
  24. package/dist/components-WYEZL5TE.cjs +26 -0
  25. package/dist/{components-3RTH76CV.cjs.map → components-WYEZL5TE.cjs.map} +1 -1
  26. package/dist/components-ZAGG2PBO.mjs +5 -0
  27. package/dist/{components-5GVVL2Q6.mjs.map → components-ZAGG2PBO.mjs.map} +1 -1
  28. package/dist/index.cjs +36 -220
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.css +65 -0
  31. package/dist/index.css.map +1 -1
  32. package/dist/index.d.cts +44 -500
  33. package/dist/index.d.ts +44 -500
  34. package/dist/index.mjs +16 -62
  35. package/dist/index.mjs.map +1 -1
  36. package/package.json +6 -6
  37. package/src/components/markdown/MarkdownMessage/ActionRow.tsx +48 -0
  38. package/src/components/markdown/MarkdownMessage/ChatMessageRow.tsx +97 -0
  39. package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +9 -13
  40. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +77 -2
  41. package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +2 -3
  42. package/src/components/markdown/MarkdownMessage/README.md +72 -0
  43. package/src/components/markdown/MarkdownMessage/components.tsx +3 -3
  44. package/src/components/markdown/MarkdownMessage/index.ts +6 -0
  45. package/src/index.ts +2 -11
  46. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +454 -107
  47. package/src/tools/AudioPlayer/Player.tsx +80 -0
  48. package/src/tools/AudioPlayer/PlayerShell.tsx +122 -0
  49. package/src/tools/AudioPlayer/README.md +139 -204
  50. package/src/tools/AudioPlayer/audio/audioContext.ts +39 -0
  51. package/src/tools/AudioPlayer/audio/decodePeaks.ts +36 -0
  52. package/src/tools/AudioPlayer/audio/index.ts +4 -0
  53. package/src/tools/AudioPlayer/audio/mediaElementSourceCache.ts +20 -0
  54. package/src/tools/AudioPlayer/audio/peaksCache.ts +37 -0
  55. package/src/tools/AudioPlayer/context/AudioRefContext.tsx +9 -0
  56. package/src/tools/AudioPlayer/context/ControlsContext.tsx +7 -0
  57. package/src/tools/AudioPlayer/context/LevelsContext.tsx +7 -0
  58. package/src/tools/AudioPlayer/context/MetaContext.tsx +16 -0
  59. package/src/tools/AudioPlayer/context/PlayerProvider.tsx +314 -0
  60. package/src/tools/AudioPlayer/context/StateContext.tsx +7 -0
  61. package/src/tools/AudioPlayer/context/index.ts +16 -15
  62. package/src/tools/AudioPlayer/context/selectors.ts +36 -0
  63. package/src/tools/AudioPlayer/hooks/index.ts +12 -39
  64. package/src/tools/AudioPlayer/hooks/useActivePlayer.ts +31 -0
  65. package/src/tools/AudioPlayer/hooks/useAnalyser.ts +62 -0
  66. package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +102 -0
  67. package/src/tools/AudioPlayer/hooks/useKeyboardShortcuts.ts +91 -0
  68. package/src/tools/AudioPlayer/hooks/useMediaSession.ts +74 -0
  69. package/src/tools/AudioPlayer/hooks/usePeaks.ts +83 -0
  70. package/src/tools/AudioPlayer/hooks/usePlayerPreferences.ts +21 -0
  71. package/src/tools/AudioPlayer/hooks/usePlayheadLoop.ts +77 -0
  72. package/src/tools/AudioPlayer/hooks/useResizeObserver.ts +20 -0
  73. package/src/tools/AudioPlayer/hooks/useThemeWatcher.ts +22 -0
  74. package/src/tools/AudioPlayer/index.ts +63 -134
  75. package/src/tools/AudioPlayer/lazy.tsx +8 -97
  76. package/src/tools/AudioPlayer/parts/Controls/ControlsRow.tsx +30 -0
  77. package/src/tools/AudioPlayer/parts/Controls/IconButton.tsx +62 -0
  78. package/src/tools/AudioPlayer/parts/Controls/LoopButton.tsx +33 -0
  79. package/src/tools/AudioPlayer/parts/Controls/PlayButton.tsx +86 -0
  80. package/src/tools/AudioPlayer/parts/Controls/SkipButton.tsx +17 -0
  81. package/src/tools/AudioPlayer/parts/Controls/VolumeControl.tsx +171 -0
  82. package/src/tools/AudioPlayer/parts/Controls/index.ts +6 -0
  83. package/src/tools/AudioPlayer/parts/Cover/Cover.tsx +24 -0
  84. package/src/tools/AudioPlayer/parts/Cover/CoverPlaceholder.tsx +27 -0
  85. package/src/tools/AudioPlayer/parts/Cover/ReactivePulse.tsx +66 -0
  86. package/src/tools/AudioPlayer/parts/Cover/index.ts +3 -0
  87. package/src/tools/AudioPlayer/parts/ErrorState/ErrorState.tsx +35 -0
  88. package/src/tools/AudioPlayer/parts/ErrorState/index.ts +1 -0
  89. package/src/tools/AudioPlayer/parts/Layout/CompactLayout.tsx +25 -0
  90. package/src/tools/AudioPlayer/parts/Layout/DefaultLayout.tsx +48 -0
  91. package/src/tools/AudioPlayer/parts/Layout/index.ts +2 -0
  92. package/src/tools/AudioPlayer/parts/Meta/Artist.tsx +14 -0
  93. package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +49 -0
  94. package/src/tools/AudioPlayer/parts/Meta/Title.tsx +13 -0
  95. package/src/tools/AudioPlayer/parts/Meta/index.ts +3 -0
  96. package/src/tools/AudioPlayer/parts/Skeleton/CoverSkeleton.tsx +13 -0
  97. package/src/tools/AudioPlayer/parts/Skeleton/MetaSkeleton.tsx +10 -0
  98. package/src/tools/AudioPlayer/parts/Skeleton/index.ts +2 -0
  99. package/src/tools/AudioPlayer/parts/Waveform/BarsWaveform.tsx +48 -0
  100. package/src/tools/AudioPlayer/parts/Waveform/LiveWaveform.tsx +95 -0
  101. package/src/tools/AudioPlayer/parts/Waveform/PeaksWaveform.tsx +100 -0
  102. package/src/tools/AudioPlayer/parts/Waveform/ProgressBar.tsx +76 -0
  103. package/src/tools/AudioPlayer/parts/Waveform/Waveform.tsx +74 -0
  104. package/src/tools/AudioPlayer/parts/Waveform/WaveformSkeleton.tsx +16 -0
  105. package/src/tools/AudioPlayer/parts/Waveform/index.ts +8 -0
  106. package/src/tools/AudioPlayer/parts/Waveform/waveformInteraction.ts +106 -0
  107. package/src/tools/AudioPlayer/parts/Waveform/waveformRenderer.ts +91 -0
  108. package/src/tools/AudioPlayer/parts/index.ts +1 -0
  109. package/src/tools/AudioPlayer/store/activePlayerBus.ts +63 -0
  110. package/src/tools/AudioPlayer/store/createLevelsStore.ts +37 -0
  111. package/src/tools/AudioPlayer/store/index.ts +16 -0
  112. package/src/tools/AudioPlayer/store/preferencesStore.ts +104 -0
  113. package/src/tools/AudioPlayer/styles/webview-safe.css +77 -0
  114. package/src/tools/AudioPlayer/types.ts +95 -0
  115. package/src/tools/AudioPlayer/utils/bucketize.ts +27 -0
  116. package/src/tools/AudioPlayer/utils/clamp.ts +5 -0
  117. package/src/tools/AudioPlayer/utils/dpr.ts +19 -0
  118. package/src/tools/AudioPlayer/utils/formatTime.ts +12 -8
  119. package/src/tools/AudioPlayer/utils/index.ts +4 -5
  120. package/src/tools/AudioPlayer/utils/readCssVar.ts +7 -0
  121. package/src/tools/AudioPlayer/utils/resolveCanvasColor.ts +28 -0
  122. package/src/tools/index.ts +5 -75
  123. package/dist/chunk-2QY3LJR6.mjs.map +0 -1
  124. package/dist/chunk-6HNAPVZ2.mjs.map +0 -1
  125. package/dist/chunk-CXVGN6ZW.cjs.map +0 -1
  126. package/dist/chunk-F2N7P5XU.cjs +0 -30
  127. package/dist/chunk-F2N7P5XU.cjs.map +0 -1
  128. package/dist/chunk-FYLR232K.cjs.map +0 -1
  129. package/dist/chunk-HMHIVEMS.mjs +0 -1619
  130. package/dist/chunk-HMHIVEMS.mjs.map +0 -1
  131. package/dist/chunk-JWB2EWQO.mjs +0 -5
  132. package/dist/chunk-JWB2EWQO.mjs.map +0 -1
  133. package/dist/chunk-YZX6FH3H.cjs +0 -1656
  134. package/dist/chunk-YZX6FH3H.cjs.map +0 -1
  135. package/dist/components-3RTH76CV.cjs +0 -27
  136. package/dist/components-5GVVL2Q6.mjs +0 -5
  137. package/dist/components-CPHOUQ5F.cjs +0 -46
  138. package/dist/components-CPHOUQ5F.cjs.map +0 -1
  139. package/dist/components-OTK43IMD.mjs +0 -6
  140. package/dist/components-OTK43IMD.mjs.map +0 -1
  141. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +0 -225
  142. package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +0 -163
  143. package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +0 -284
  144. package/src/tools/AudioPlayer/components/HybridWaveform.tsx +0 -286
  145. package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +0 -151
  146. package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +0 -110
  147. package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +0 -58
  148. package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +0 -45
  149. package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +0 -82
  150. package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +0 -8
  151. package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +0 -6
  152. package/src/tools/AudioPlayer/components/index.ts +0 -23
  153. package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +0 -158
  154. package/src/tools/AudioPlayer/effects/index.ts +0 -412
  155. package/src/tools/AudioPlayer/hooks/useAudioBus.ts +0 -76
  156. package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +0 -403
  157. package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +0 -96
  158. package/src/tools/AudioPlayer/hooks/useVisualization.tsx +0 -207
  159. package/src/tools/AudioPlayer/types/effects.ts +0 -73
  160. package/src/tools/AudioPlayer/types/index.ts +0 -27
  161. package/src/tools/AudioPlayer/utils/debug.ts +0 -14
@@ -0,0 +1,122 @@
1
+ 'use client';
2
+
3
+ // Inner shell that runs inside <PlayerProvider>. Owns variant selection,
4
+ // keyboard shortcuts and MediaSession wiring; renders the picked layout.
5
+
6
+ import { useCallback, useEffect, useImperativeHandle, useState } from 'react';
7
+ import { TooltipProvider } from '@djangocfg/ui-core/components';
8
+ import { useIsPhone } from '@djangocfg/ui-core/hooks';
9
+ import { usePlayerAudio, usePlayerControls, usePlayerMeta } from './context/selectors';
10
+ import { useElementWidth } from './hooks/useResizeObserver';
11
+ import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
12
+ import { useMediaSession } from './hooks/useMediaSession';
13
+ import { CompactLayout, DefaultLayout } from './parts/Layout';
14
+ import type { PlayerHandle, PlayerProps, PlayerVariant } from './types';
15
+
16
+ const COMPACT_BREAKPOINT = 480;
17
+
18
+ type Props = Pick<
19
+ PlayerProps,
20
+ | 'className'
21
+ | 'variant'
22
+ | 'waveform'
23
+ | 'reactiveCover'
24
+ | 'onPrev'
25
+ | 'onNext'
26
+ | 'enableKeyboardShortcuts'
27
+ | 'ariaLabel'
28
+ | 'seekStartsPlayback'
29
+ > & {
30
+ handleRef?: React.Ref<PlayerHandle>;
31
+ };
32
+
33
+ export function PlayerShell({
34
+ className = '',
35
+ variant = 'auto',
36
+ waveform,
37
+ reactiveCover = false,
38
+ onPrev,
39
+ onNext,
40
+ enableKeyboardShortcuts = true,
41
+ ariaLabel,
42
+ seekStartsPlayback = true,
43
+ handleRef,
44
+ }: Props) {
45
+ const [container, setContainer] = useState<HTMLDivElement | null>(null);
46
+ const audio = usePlayerAudio();
47
+ const controls = usePlayerControls();
48
+ const meta = usePlayerMeta();
49
+ const width = useElementWidth(container);
50
+ const isPhone = useIsPhone();
51
+
52
+ // `auto` resolves to the layout best for the available space:
53
+ // - on a phone viewport we always use compact (regardless of container);
54
+ // - otherwise compact only when the container itself is narrower than
55
+ // COMPACT_BREAKPOINT.
56
+ const resolvedVariant: PlayerVariant =
57
+ variant === 'auto'
58
+ ? isPhone || (width > 0 && width < COMPACT_BREAKPOINT)
59
+ ? 'compact'
60
+ : 'default'
61
+ : variant;
62
+
63
+ useMediaSession(audio, meta, controls, onPrev, onNext);
64
+ const hotkeys = useKeyboardShortcuts({
65
+ audio,
66
+ controls,
67
+ enabled: enableKeyboardShortcuts,
68
+ });
69
+
70
+ // Compose the container state-ref with the hotkey scoping ref. Both want
71
+ // to see the same DOM node — one for layout/keyboard-focus management, the
72
+ // other for react-hotkeys-hook's scoped listener.
73
+ const setRootRef = useCallback(
74
+ (node: HTMLDivElement | null) => {
75
+ setContainer(node);
76
+ hotkeys.ref(node);
77
+ },
78
+ [hotkeys],
79
+ );
80
+
81
+ useImperativeHandle(
82
+ handleRef,
83
+ (): PlayerHandle => ({
84
+ audio,
85
+ play: () => controls.play(),
86
+ pause: () => controls.pause(),
87
+ seek: (s: number) => controls.seek(s),
88
+ getCurrentTime: () => audio.currentTime,
89
+ getDuration: () => (Number.isFinite(audio.duration) ? audio.duration : 0),
90
+ }),
91
+ [audio, controls],
92
+ );
93
+
94
+ // Keyboard shortcuts work only when the container can take focus.
95
+ useEffect(() => {
96
+ if (!container || container.hasAttribute('tabindex')) return;
97
+ container.setAttribute('tabindex', '0');
98
+ }, [container]);
99
+
100
+ return (
101
+ <TooltipProvider delayDuration={400}>
102
+ <div
103
+ ref={setRootRef}
104
+ role="group"
105
+ aria-label={ariaLabel ?? 'Audio player'}
106
+ className={`audioplayer @container/player rounded-lg border border-border/60 bg-card text-foreground shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 ${className}`}
107
+ >
108
+ {resolvedVariant === 'compact' ? (
109
+ <CompactLayout waveform={waveform} seekStartsPlayback={seekStartsPlayback} />
110
+ ) : (
111
+ <DefaultLayout
112
+ waveform={waveform}
113
+ reactiveCover={reactiveCover}
114
+ onPrev={onPrev}
115
+ onNext={onNext}
116
+ seekStartsPlayback={seekStartsPlayback}
117
+ />
118
+ )}
119
+ </div>
120
+ </TooltipProvider>
121
+ );
122
+ }
@@ -1,243 +1,178 @@
1
- # AudioPlayer
1
+ # AudioPlayer (v6)
2
2
 
3
- Audio player with native HTML5 streaming and audio-reactive visualizations.
3
+ WebView-safe React audio player. One component, two layouts, waveform-as-progress.
4
+ Tuned for 60 fps inside WKWebView (Wails / Electron) and Tauri WebView2.
4
5
 
5
- ## Features
6
+ Architecture, ADRs and the visual direction live at
7
+ `packages/ui-tools/@dev/@refactoring6-audioplayer/`.
6
8
 
7
- - Native HTML5 audio playback (no crackling, native streaming support)
8
- - Web Audio API for real-time frequency analysis
9
- - Audio-reactive cover effects (glow, orbs, spotlight, mesh)
10
- - Frequency visualization waveform
11
- - Full playback controls
12
-
13
- ## Quick Start
9
+ ## Quick start
14
10
 
15
11
  ```tsx
16
- import { HybridSimplePlayer, HybridCompactPlayer } from '@djangocfg/ui-nextjs';
17
-
18
- // Simple usage
19
- <HybridSimplePlayer src="https://example.com/audio.mp3" />
20
-
21
- // Compact single-row player (for lists, sidebars)
22
- <HybridCompactPlayer src="https://example.com/audio.mp3" title="Rain & Thunder" />
23
-
24
- // With metadata and reactive cover
25
- <HybridSimplePlayer
26
- src={audioUrl}
27
- title="Track Title"
28
- artist="Artist Name"
29
- coverArt="/path/to/cover.jpg"
30
- reactiveCover
31
- variant="spotlight"
32
- />
12
+ import { LazyAudioPlayer } from '@djangocfg/ui-tools';
33
13
 
34
- // Full customization
35
- <HybridSimplePlayer
36
- src={audioUrl}
37
- title="Track Title"
38
- coverArt={coverUrl}
39
- showWaveform
40
- waveformMode="frequency" // 'frequency' | 'static'
41
- showLoop
42
- reactiveCover
43
- variant="spotlight" // 'glow' | 'orbs' | 'spotlight' | 'mesh' | 'none'
44
- intensity="medium" // 'subtle' | 'medium' | 'strong'
45
- colorScheme="primary" // 'primary' | 'vibrant' | 'cool' | 'warm'
46
- layout="horizontal" // 'vertical' | 'horizontal'
14
+ <LazyAudioPlayer
15
+ src="/audio/track.mp3"
16
+ title="Track"
17
+ artist="Artist"
18
+ cover="/cover.jpg"
47
19
  />
48
20
  ```
49
21
 
50
- ## Props
51
-
52
- | Prop | Type | Default | Description |
53
- |------|------|---------|-------------|
54
- | `src` | `string` | required | Audio URL |
55
- | `title` | `string` | - | Track title |
56
- | `artist` | `string` | - | Artist name |
57
- | `coverArt` | `string \| ReactNode` | - | Cover image URL or custom element |
58
- | `coverSize` | `'sm' \| 'md' \| 'lg'` | `'md'` | Cover art size |
59
- | `showWaveform` | `boolean` | `true` | Show frequency visualization |
60
- | `waveformMode` | `'frequency' \| 'static'` | `'frequency'` | Visualization mode |
61
- | `waveformHeight` | `number` | `64` | Waveform height in pixels |
62
- | `showTimer` | `boolean` | `true` | Show time display |
63
- | `showVolume` | `boolean` | `true` | Show volume control |
64
- | `showLoop` | `boolean` | `true` | Show loop button |
65
- | `reactiveCover` | `boolean` | `true` | Enable reactive effects |
66
- | `variant` | `VisualizationVariant` | `'spotlight'` | Effect variant |
67
- | `intensity` | `EffectIntensity` | `'medium'` | Effect intensity |
68
- | `colorScheme` | `EffectColorScheme` | `'primary'` | Effect colors |
69
- | `autoPlay` | `boolean` | `false` | Auto-play on load |
70
- | `loop` | `boolean` | `false` | Loop playback |
71
- | `layout` | `'vertical' \| 'horizontal'` | `'vertical'` | Layout direction |
72
-
73
- ## Advanced Usage
74
-
75
- ### HybridAudioProvider + HybridAudioPlayer
76
-
77
- For custom layouts:
22
+ For non-lazy use (e.g. above-the-fold):
78
23
 
79
24
  ```tsx
80
- import {
81
- HybridAudioProvider,
82
- HybridAudioPlayer,
83
- AudioReactiveCover,
84
- useHybridAudioContext
85
- } from '@djangocfg/ui-nextjs';
86
-
87
- function MyPlayer({ audioUrl }: { audioUrl: string }) {
88
- return (
89
- <HybridAudioProvider src={audioUrl}>
90
- <AudioReactiveCover variant="spotlight" onClick={handleClick}>
91
- <img src={coverUrl} alt="Cover" />
92
- </AudioReactiveCover>
93
- <HybridAudioPlayer showWaveform showControls />
94
- <CustomControls />
95
- </HybridAudioProvider>
96
- );
97
- }
98
-
99
- function CustomControls() {
100
- const { state, controls, audioLevels } = useHybridAudioContext();
101
-
102
- return (
103
- <div>
104
- <p>Bass level: {(audioLevels.bass * 100).toFixed(0)}%</p>
105
- <button onClick={controls.togglePlay}>
106
- {state.isPlaying ? 'Pause' : 'Play'}
107
- </button>
108
- </div>
109
- );
110
- }
25
+ import { Player } from '@djangocfg/ui-tools/tools/AudioPlayer';
26
+
27
+ <Player src="/audio/track.mp3" />
111
28
  ```
112
29
 
113
- ## Hooks
30
+ ## Why v6
114
31
 
115
- ### useHybridAudioContext
32
+ The previous Hybrid* surface (now `AudioPlayer_old/`) used a per-frame
33
+ `AnalyserNode` redraw, multiple effect components and a single Context that
34
+ re-rendered every consumer at 60 Hz. v6 fixes the structural issues:
116
35
 
117
- Full context access:
36
+ - Static peaks decoded **once**, painted **twice** (background + foreground),
37
+ playhead animated via CSS `clip-path` + a single CSS variable. Zero canvas
38
+ paints during steady-state playback.
39
+ - Three split contexts (State / Controls / Levels) — controls memoised once,
40
+ levels exposed as an imperative store; React doesn't re-render on level
41
+ updates at all.
42
+ - One module-level `AudioContext`, `MediaElementSourceNode` cached in a
43
+ `WeakMap` per `<audio>` — Safari context-quota safe and StrictMode safe.
118
44
 
119
- ```tsx
120
- const {
121
- state, // { isReady, isPlaying, currentTime, duration, volume, isMuted, isLooping }
122
- controls, // { play, pause, togglePlay, seek, skip, setVolume, toggleMute, toggleLoop }
123
- audioLevels, // { bass, mid, high, overall }
124
- webAudio, // { context, analyser, sourceNode }
125
- audioRef, // React ref to HTMLAudioElement
126
- } = useHybridAudioContext();
127
- ```
45
+ ## Props
128
46
 
129
- ### Specialized Hooks
47
+ | Prop | Type | Default | Notes |
48
+ |---|---|---|---|
49
+ | `src` | `string` | required | |
50
+ | `peaks` | `Float32Array` | — | Pre-computed peaks (skips client decode). |
51
+ | `title` / `artist` / `album` / `cover` | `string` | — | Drive `<MediaSession>` too. |
52
+ | `variant` | `'auto' \| 'default' \| 'compact'` | `'auto'` | `auto` → compact on phones / narrow containers. |
53
+ | `waveform.mode` | `'peaks' \| 'live' \| 'bars' \| 'progress' \| 'none'` | `'peaks'` | `progress` is a plain scrubber, no animation. `peaks` falls back to `progress` on decode failure. |
54
+ | `waveform.height` | `number` | 40 (default) / 24 (compact) | |
55
+ | `reactiveCover` | `false \| 'subtle'` | `false` | Compositor-only scale pulse. |
56
+ | `exclusive` | `boolean` | `true` | Pauses sibling players via `activePlayerBus` (cross-tab via `BroadcastChannel`). |
57
+ | `seekStartsPlayback` | `boolean` | `true` | Click on the waveform also starts playback when paused. |
58
+ | `autoplay` / `loop` / `muted` / `initialVolume` / `preload` | — | — | Standard HTML media. |
59
+ | `onPrev` / `onNext` | `() => void` | — | Render skip buttons + `MediaSession` handlers. |
60
+ | `onPlay` / `onPause` / `onEnded` / `onError` | callbacks | — | |
61
+ | `enableKeyboardShortcuts` | `boolean` | `true` | Active only when focus is inside the player. |
62
+ | `ariaLabel` / `className` | `string` | — | |
63
+
64
+ ## Controls
65
+
66
+ - **Mouse / touch** — click the waveform to seek (and start playback if
67
+ paused); drag to scrub. Hover shows a time tooltip (desktop only).
68
+ - **Keyboard** — Space/K play-pause, ←/→ seek 5 s, ↑/↓ volume, M mute, L loop.
69
+ - **MediaSession** — wires play / pause / next / prev / seek to OS-level Now
70
+ Playing controls (works in Wails through WKWebView).
71
+
72
+ ## Mobile
73
+
74
+ - `variant: 'auto'` resolves to `compact` on phone viewports (< 640 px).
75
+ - Volume popover opens by tap on touch (no hover); hover affordances are
76
+ hidden via `@media (hover: none)`.
77
+ - iOS Safari: the volume slider is hidden (the OS controls hardware volume
78
+ there — `audio.volume` is read-only); mute toggle stays.
79
+ - Tap targets ≥ 40 px on coarse pointers.
80
+
81
+ ## Persistent preferences
82
+
83
+ `volume` and `muted` are stored in `localStorage` (key
84
+ `djangocfg-audioplayer:prefs`) and synced across all uncontrolled players in
85
+ the page and across tabs (`storage` event). Pass `initialVolume` / `muted` to
86
+ opt a specific player out — it then ignores the store and never writes to it.
87
+
88
+ ## Active-player coordination
89
+
90
+ `exclusive` (default `true`) registers each player with a small in-memory bus.
91
+ Only one player plays at a time per page; `BroadcastChannel` extends the same
92
+ rule across tabs. Hooks for the bus state:
130
93
 
131
94
  ```tsx
132
- // State only
133
- const { isPlaying, currentTime, duration } = useHybridAudioState();
134
-
135
- // Controls only (no re-render on time updates)
136
- const { play, pause, togglePlay, seek } = useHybridAudioControls();
137
-
138
- // Audio levels for reactive effects
139
- const { bass, mid, high, overall } = useHybridAudioLevels();
140
-
141
- // Web Audio API access
142
- const { context, analyser, sourceNode } = useHybridWebAudio();
95
+ import {
96
+ useActivePlayer, // currently playing id (or null)
97
+ useLastActivePlayer, // most recently active id (sticky)
98
+ useIsActivePlayer, // boolean for a specific id
99
+ } from '@djangocfg/ui-tools';
143
100
  ```
144
101
 
145
- ## Components
146
-
147
- ### AudioReactiveCover
102
+ ## Custom layouts (slot composition)
148
103
 
149
- Album art wrapper with audio-reactive effects:
104
+ Drop `<PlayerProvider>` and arrange the parts you want yourself. Every part
105
+ reads from the player context — pass nothing, just compose.
150
106
 
151
107
  ```tsx
152
- <AudioReactiveCover
153
- variant="spotlight" // 'glow' | 'orbs' | 'spotlight' | 'mesh'
154
- intensity="medium" // 'subtle' | 'medium' | 'strong'
155
- colorScheme="primary" // 'primary' | 'vibrant' | 'cool' | 'warm'
156
- onClick={() => nextVariant()}
157
- >
158
- <img src={coverArt} alt="Album cover" />
159
- </AudioReactiveCover>
108
+ import {
109
+ PlayerProvider,
110
+ Cover, Title, Artist, TimeDisplay,
111
+ PlayButton, VolumeControl, LoopButton,
112
+ Waveform,
113
+ } from '@djangocfg/ui-tools';
114
+
115
+ <PlayerProvider src="/track.mp3" title="…" artist="…" cover="/cover.jpg">
116
+ <div className="grid grid-cols-[96px_1fr_auto] gap-4 rounded-lg border bg-card p-4">
117
+ <div className="row-span-2"><Cover size={96} /></div>
118
+ <div className="min-w-0"><Title /><Artist /></div>
119
+ <TimeDisplay />
120
+ <div className="col-span-2 space-y-3">
121
+ <Waveform height={48} />
122
+ <div className="flex items-center justify-between">
123
+ <PlayButton />
124
+ <div className="flex items-center gap-1">
125
+ <VolumeControl />
126
+ <LoopButton />
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </PlayerProvider>
160
132
  ```
161
133
 
162
- ### HybridCompactPlayer
134
+ > **Note:** the slot-composed players use Radix `Tooltip` for control labels.
135
+ > The default `<Player>` ships its own `TooltipProvider`, but slot composition
136
+ > bypasses it — wrap your custom layout in
137
+ > `<TooltipProvider>` (from `@djangocfg/ui-core`) once at the app root, or
138
+ > tooltips will throw `"Tooltip must be used within TooltipProvider"`.
163
139
 
164
- Single-row player play/pause + waveform + timer. No cover art, no volume slider.
140
+ Re-exported parts: `Cover`, `CoverPlaceholder`, `ReactivePulse`, `Title`,
141
+ `Artist`, `TimeDisplay`, `PlayButton`, `SkipButton`, `VolumeControl`,
142
+ `LoopButton`, `ControlsRow`, `IconButton`, `Waveform`, `PeaksWaveform`,
143
+ `LiveWaveform`, `BarsWaveform`, `ProgressBar`, `WaveformSkeleton`,
144
+ `ErrorState`, `DefaultLayout`, `CompactLayout`.
165
145
 
166
- ```tsx
167
- <HybridCompactPlayer
168
- src={audioUrl}
169
- title="Track name" // used as tooltip
170
- waveformMode="frequency" // 'frequency' | 'static'
171
- showTimer={true}
172
- autoPlay={false}
173
- />
174
- ```
175
-
176
- | Prop | Type | Default | Description |
177
- |------|------|---------|-------------|
178
- | `src` | `string` | required | Audio URL |
179
- | `title` | `string` | - | Tooltip / aria-label |
180
- | `waveformMode` | `'frequency' \| 'static'` | `'frequency'` | Visualization mode |
181
- | `showTimer` | `boolean` | `true` | Show current/total time |
182
- | `buttonSize` | `'sm' \| 'md'` | `'md'` | Play button size (`sm` = h-6 w-6, `md` = h-8 w-8) |
183
- | `autoPlay` | `boolean` | `false` | Auto-play on load |
184
- | `loop` | `boolean` | `false` | Loop playback |
185
- | `initialVolume` | `number` | `1` | Initial volume 0-1 |
146
+ ## Selector hooks
186
147
 
187
- For lazy-loading (preferred in Next.js apps):
148
+ For custom toolbars — read state without re-implementing the controls UI:
188
149
 
189
150
  ```tsx
190
- import { LazyHybridCompactPlayer } from '@djangocfg/ui-tools';
191
-
192
- <LazyHybridCompactPlayer src={url} title="Track" autoPlay />
151
+ import {
152
+ usePlayerState, // discriminated union: idle | loading | playing | paused | …
153
+ usePlayerControls, // play / pause / toggle / seek / setVolume / …
154
+ usePlayerLevels, // imperative store; for live-mode canvases
155
+ usePlayerMeta, // src / title / artist / cover
156
+ usePlayerPaused, // boolean shortcut
157
+ usePlayerDuration, // number shortcut
158
+ usePlayerPreferences, // { volume, muted } from the persistent store
159
+ } from '@djangocfg/ui-tools';
193
160
  ```
194
161
 
195
- ### HybridWaveform
162
+ These work inside `<Player>`'s tree; if you need your own JSX around the
163
+ state, mount `<PlayerProvider>` directly (also exported).
196
164
 
197
- Real-time frequency visualization:
165
+ ## Stories
198
166
 
199
- ```tsx
200
- <HybridWaveform
201
- mode="frequency" // 'frequency' | 'static'
202
- height={64}
203
- barWidth={3}
204
- barGap={2}
205
- />
206
- ```
167
+ Storybook-style stories live in `AudioPlayer.story.tsx`:
207
168
 
208
- ## Effect Variants
169
+ - `Default`, `WithCover`, `Compact`, `CustomLayout`, `Bars`, `Live`,
170
+ `NoWaveform`, `ReactiveCover`, `ErrorState`, `Exclusive`, `Interactive`,
171
+ `Showcase` (one-page demo of every prop / mode / hook).
209
172
 
210
- | Variant | Description |
211
- |---------|-------------|
212
- | `spotlight` | Rotating conic gradient with bass pulse |
213
- | `glow` | Multi-layered radial glows from edges |
214
- | `orbs` | Floating orbs that react to frequencies |
215
- | `mesh` | Large gradient blobs with movement |
216
- | `none` | Effects disabled |
173
+ Run `pnpm playground` from `packages/ui-tools/`.
217
174
 
218
- ## Architecture
219
-
220
- ```
221
- AudioPlayer/
222
- ├── index.ts # Public API exports
223
- ├── types/ # TypeScript types
224
- ├── hooks/
225
- │ ├── useHybridAudio.ts # HTML5 audio + Web Audio hook
226
- │ ├── useHybridAudioAnalysis.ts # Frequency analysis
227
- │ └── useVisualization.tsx # Visualization settings
228
- ├── context/
229
- │ └── HybridAudioProvider.tsx # Audio context provider
230
- ├── components/
231
- │ ├── HybridAudioPlayer.tsx # Main player component
232
- │ ├── HybridSimplePlayer.tsx # All-in-one wrapper (with cover, volume, effects)
233
- │ ├── HybridCompactPlayer.tsx # Compact single-row player
234
- │ ├── HybridWaveform.tsx # Frequency visualization
235
- │ └── ReactiveCover/ # Reactive effects
236
- ├── effects/ # Effect calculations
237
- └── utils/ # Utilities
238
- ```
175
+ ## Related
239
176
 
240
- Key design:
241
- - HTML5 `<audio>` for playback (native streaming, no crackling)
242
- - Web Audio API AnalyserNode for visualization only (not connected to output)
243
- - Audio graph: `source → destination` + `source → analyser` (passive)
177
+ - Old (reference only): `tools/AudioPlayer_old/`.
178
+ - Architecture: `@dev/@refactoring6-audioplayer/`.
@@ -0,0 +1,39 @@
1
+ // Singleton AudioContext for the whole tab.
2
+ // See ADR-004. Never closed during normal operation.
3
+
4
+ let _ctx: AudioContext | null = null;
5
+
6
+ type WebkitWindow = typeof window & {
7
+ webkitAudioContext?: typeof AudioContext;
8
+ };
9
+
10
+ export function getAudioContext(): AudioContext {
11
+ if (_ctx) return _ctx;
12
+ const w = window as WebkitWindow;
13
+ const Ctor = w.AudioContext ?? w.webkitAudioContext;
14
+ if (!Ctor) {
15
+ throw new Error('Web Audio API is not supported in this environment');
16
+ }
17
+ _ctx = new Ctor({ latencyHint: 'interactive' });
18
+ return _ctx;
19
+ }
20
+
21
+ export async function unlockAudioContext(): Promise<void> {
22
+ const ctx = getAudioContext();
23
+ if (ctx.state === 'suspended') {
24
+ try {
25
+ await ctx.resume();
26
+ } catch {
27
+ // Resume requires a user gesture; caller should retry from one.
28
+ }
29
+ }
30
+ }
31
+
32
+ export function isAudioContextRunning(): boolean {
33
+ return _ctx?.state === 'running';
34
+ }
35
+
36
+ // Test-only: drop the singleton between test cases.
37
+ export function _resetAudioContextForTesting(): void {
38
+ _ctx = null;
39
+ }
@@ -0,0 +1,36 @@
1
+ // fetch + OfflineAudioContext + bucketize. See ADR-002 / research 02.
2
+ // Uses a low-sample-rate mono context to keep memory bounded.
3
+
4
+ import { bucketize } from '../utils/bucketize';
5
+
6
+ const SAMPLE_RATE = 22_050;
7
+ const DEFAULT_BUCKETS = 1800;
8
+
9
+ type WebkitWindow = typeof window & {
10
+ webkitOfflineAudioContext?: typeof OfflineAudioContext;
11
+ };
12
+
13
+ function getOfflineCtor(): typeof OfflineAudioContext {
14
+ const w = window as WebkitWindow;
15
+ const Ctor = w.OfflineAudioContext ?? w.webkitOfflineAudioContext;
16
+ if (!Ctor) throw new Error('OfflineAudioContext is not supported');
17
+ return Ctor;
18
+ }
19
+
20
+ export async function decodePeaks(
21
+ src: string,
22
+ buckets: number = DEFAULT_BUCKETS,
23
+ signal?: AbortSignal,
24
+ ): Promise<Float32Array> {
25
+ const response = await fetch(src, { signal, credentials: 'same-origin' });
26
+ if (!response.ok) {
27
+ throw new Error(`Failed to fetch audio for peaks (${response.status})`);
28
+ }
29
+ const arr = await response.arrayBuffer();
30
+ const Ctor = getOfflineCtor();
31
+ // Length must be ≥ 1; the actual length doesn't matter for decodeAudioData.
32
+ const ctx = new Ctor(1, 1, SAMPLE_RATE);
33
+ const audio = await ctx.decodeAudioData(arr);
34
+ const channel = audio.getChannelData(0);
35
+ return bucketize(channel, buckets);
36
+ }
@@ -0,0 +1,4 @@
1
+ export { getAudioContext, unlockAudioContext, isAudioContextRunning } from './audioContext';
2
+ export { getMediaElementSource, hasMediaElementSource } from './mediaElementSourceCache';
3
+ export { decodePeaks } from './decodePeaks';
4
+ export { getPeaks, setPeaks, getPeaksFromCache, clearPeaksCache } from './peaksCache';
@@ -0,0 +1,20 @@
1
+ // Per-element MediaElementSourceNode cache.
2
+ // See ADR-004 §B. WeakMap so entries die with the audio element.
3
+
4
+ import { getAudioContext } from './audioContext';
5
+
6
+ const cache = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>();
7
+
8
+ export function getMediaElementSource(el: HTMLAudioElement): MediaElementAudioSourceNode {
9
+ const hit = cache.get(el);
10
+ if (hit) return hit;
11
+ const ctx = getAudioContext();
12
+ const node = ctx.createMediaElementSource(el);
13
+ node.connect(ctx.destination);
14
+ cache.set(el, node);
15
+ return node;
16
+ }
17
+
18
+ export function hasMediaElementSource(el: HTMLAudioElement): boolean {
19
+ return cache.has(el);
20
+ }
@@ -0,0 +1,37 @@
1
+ // Module-level peaks cache. Dedupes in-flight decodes; caches resolved buffers.
2
+ // See ADR-002.
3
+
4
+ import { decodePeaks } from './decodePeaks';
5
+
6
+ const cache = new Map<string, Float32Array>();
7
+ const inflight = new Map<string, Promise<Float32Array>>();
8
+
9
+ export async function getPeaks(src: string, buckets?: number): Promise<Float32Array> {
10
+ const hit = cache.get(src);
11
+ if (hit) return hit;
12
+ const flying = inflight.get(src);
13
+ if (flying) return flying;
14
+ const promise = decodePeaks(src, buckets).then((peaks) => {
15
+ cache.set(src, peaks);
16
+ return peaks;
17
+ });
18
+ inflight.set(src, promise);
19
+ try {
20
+ return await promise;
21
+ } finally {
22
+ inflight.delete(src);
23
+ }
24
+ }
25
+
26
+ export function setPeaks(src: string, peaks: Float32Array): void {
27
+ cache.set(src, peaks);
28
+ }
29
+
30
+ export function getPeaksFromCache(src: string): Float32Array | undefined {
31
+ return cache.get(src);
32
+ }
33
+
34
+ export function clearPeaksCache(src?: string): void {
35
+ if (src) cache.delete(src);
36
+ else cache.clear();
37
+ }
@@ -0,0 +1,9 @@
1
+ 'use client';
2
+
3
+ import { createContext } from 'react';
4
+
5
+ // The persistent <audio> element. Components that need imperative access
6
+ // (TimeDisplay, Waveform playhead, click-to-seek) read this — not the React
7
+ // state — to avoid coupling per-frame work to renders.
8
+ export const AudioRefCtx = createContext<HTMLAudioElement | null>(null);
9
+ AudioRefCtx.displayName = 'AudioPlayerAudioRefCtx';
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+
3
+ import { createContext } from 'react';
4
+ import type { PlayerControls } from '../types';
5
+
6
+ export const ControlsCtx = createContext<PlayerControls | null>(null);
7
+ ControlsCtx.displayName = 'AudioPlayerControlsCtx';
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+
3
+ import { createContext } from 'react';
4
+ import type { LevelsStore } from '../store';
5
+
6
+ export const LevelsCtx = createContext<LevelsStore | null>(null);
7
+ LevelsCtx.displayName = 'AudioPlayerLevelsCtx';