@djangocfg/ui-tools 2.1.312 → 2.1.314

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 (165) 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/DocsLayout-MBFIB4NO.css +1 -0
  7. package/dist/JsonTree-MLET23ZA.css +1 -0
  8. package/dist/Mermaid.client-DDXWXZXY.css +1 -0
  9. package/dist/Player-M3GC3VPE.mjs +4 -0
  10. package/dist/Player-M3GC3VPE.mjs.map +1 -0
  11. package/dist/Player-ZGQKKOWI.css +66 -0
  12. package/dist/Player-ZGQKKOWI.css.map +1 -0
  13. package/dist/Player-ZL2X5LGG.cjs +13 -0
  14. package/dist/Player-ZL2X5LGG.cjs.map +1 -0
  15. package/dist/PrettyCode.client-GWFAIVFN.css +1 -0
  16. package/dist/{chunk-CXVGN6ZW.cjs → chunk-DFTVB66S.cjs} +7 -6
  17. package/dist/chunk-DFTVB66S.cjs.map +1 -0
  18. package/dist/{chunk-2QY3LJR6.mjs → chunk-EUADAUBQ.mjs} +5 -4
  19. package/dist/chunk-EUADAUBQ.mjs.map +1 -0
  20. package/dist/chunk-FX2QFYWF.mjs +2059 -0
  21. package/dist/chunk-FX2QFYWF.mjs.map +1 -0
  22. package/dist/{chunk-6HNAPVZ2.mjs → chunk-GBLQTHWT.mjs} +11 -13
  23. package/dist/chunk-GBLQTHWT.mjs.map +1 -0
  24. package/dist/{chunk-FYLR232K.cjs → chunk-S44PW6NK.cjs} +11 -13
  25. package/dist/chunk-S44PW6NK.cjs.map +1 -0
  26. package/dist/chunk-ZLQHUZDU.cjs +2061 -0
  27. package/dist/chunk-ZLQHUZDU.cjs.map +1 -0
  28. package/dist/components-WYEZL5TE.cjs +26 -0
  29. package/dist/{components-3RTH76CV.cjs.map → components-WYEZL5TE.cjs.map} +1 -1
  30. package/dist/components-ZAGG2PBO.mjs +5 -0
  31. package/dist/{components-5GVVL2Q6.mjs.map → components-ZAGG2PBO.mjs.map} +1 -1
  32. package/dist/index.cjs +36 -220
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.css +66 -0
  35. package/dist/index.css.map +1 -1
  36. package/dist/index.d.cts +44 -500
  37. package/dist/index.d.ts +44 -500
  38. package/dist/index.mjs +16 -62
  39. package/dist/index.mjs.map +1 -1
  40. package/package.json +6 -6
  41. package/src/components/markdown/MarkdownMessage/ActionRow.tsx +48 -0
  42. package/src/components/markdown/MarkdownMessage/ChatMessageRow.tsx +97 -0
  43. package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +9 -13
  44. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +77 -2
  45. package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +2 -3
  46. package/src/components/markdown/MarkdownMessage/README.md +72 -0
  47. package/src/components/markdown/MarkdownMessage/components.tsx +3 -3
  48. package/src/components/markdown/MarkdownMessage/index.ts +6 -0
  49. package/src/index.ts +2 -11
  50. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +454 -107
  51. package/src/tools/AudioPlayer/Player.tsx +80 -0
  52. package/src/tools/AudioPlayer/PlayerShell.tsx +122 -0
  53. package/src/tools/AudioPlayer/README.md +139 -204
  54. package/src/tools/AudioPlayer/audio/audioContext.ts +39 -0
  55. package/src/tools/AudioPlayer/audio/decodePeaks.ts +36 -0
  56. package/src/tools/AudioPlayer/audio/index.ts +4 -0
  57. package/src/tools/AudioPlayer/audio/mediaElementSourceCache.ts +20 -0
  58. package/src/tools/AudioPlayer/audio/peaksCache.ts +37 -0
  59. package/src/tools/AudioPlayer/context/AudioRefContext.tsx +9 -0
  60. package/src/tools/AudioPlayer/context/ControlsContext.tsx +7 -0
  61. package/src/tools/AudioPlayer/context/LevelsContext.tsx +7 -0
  62. package/src/tools/AudioPlayer/context/MetaContext.tsx +16 -0
  63. package/src/tools/AudioPlayer/context/PlayerProvider.tsx +314 -0
  64. package/src/tools/AudioPlayer/context/StateContext.tsx +7 -0
  65. package/src/tools/AudioPlayer/context/index.ts +16 -15
  66. package/src/tools/AudioPlayer/context/selectors.ts +36 -0
  67. package/src/tools/AudioPlayer/hooks/index.ts +12 -39
  68. package/src/tools/AudioPlayer/hooks/useActivePlayer.ts +31 -0
  69. package/src/tools/AudioPlayer/hooks/useAnalyser.ts +62 -0
  70. package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +102 -0
  71. package/src/tools/AudioPlayer/hooks/useKeyboardShortcuts.ts +91 -0
  72. package/src/tools/AudioPlayer/hooks/useMediaSession.ts +74 -0
  73. package/src/tools/AudioPlayer/hooks/usePeaks.ts +83 -0
  74. package/src/tools/AudioPlayer/hooks/usePlayerPreferences.ts +21 -0
  75. package/src/tools/AudioPlayer/hooks/usePlayheadLoop.ts +77 -0
  76. package/src/tools/AudioPlayer/hooks/useResizeObserver.ts +20 -0
  77. package/src/tools/AudioPlayer/hooks/useThemeWatcher.ts +22 -0
  78. package/src/tools/AudioPlayer/index.ts +63 -134
  79. package/src/tools/AudioPlayer/lazy.tsx +8 -97
  80. package/src/tools/AudioPlayer/parts/Controls/ControlsRow.tsx +30 -0
  81. package/src/tools/AudioPlayer/parts/Controls/IconButton.tsx +62 -0
  82. package/src/tools/AudioPlayer/parts/Controls/LoopButton.tsx +33 -0
  83. package/src/tools/AudioPlayer/parts/Controls/PlayButton.tsx +86 -0
  84. package/src/tools/AudioPlayer/parts/Controls/SkipButton.tsx +17 -0
  85. package/src/tools/AudioPlayer/parts/Controls/VolumeControl.tsx +171 -0
  86. package/src/tools/AudioPlayer/parts/Controls/index.ts +6 -0
  87. package/src/tools/AudioPlayer/parts/Cover/Cover.tsx +24 -0
  88. package/src/tools/AudioPlayer/parts/Cover/CoverPlaceholder.tsx +27 -0
  89. package/src/tools/AudioPlayer/parts/Cover/ReactivePulse.tsx +66 -0
  90. package/src/tools/AudioPlayer/parts/Cover/index.ts +3 -0
  91. package/src/tools/AudioPlayer/parts/ErrorState/ErrorState.tsx +35 -0
  92. package/src/tools/AudioPlayer/parts/ErrorState/index.ts +1 -0
  93. package/src/tools/AudioPlayer/parts/Layout/CompactLayout.tsx +25 -0
  94. package/src/tools/AudioPlayer/parts/Layout/DefaultLayout.tsx +48 -0
  95. package/src/tools/AudioPlayer/parts/Layout/index.ts +2 -0
  96. package/src/tools/AudioPlayer/parts/Meta/Artist.tsx +14 -0
  97. package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +49 -0
  98. package/src/tools/AudioPlayer/parts/Meta/Title.tsx +13 -0
  99. package/src/tools/AudioPlayer/parts/Meta/index.ts +3 -0
  100. package/src/tools/AudioPlayer/parts/Skeleton/CoverSkeleton.tsx +13 -0
  101. package/src/tools/AudioPlayer/parts/Skeleton/MetaSkeleton.tsx +10 -0
  102. package/src/tools/AudioPlayer/parts/Skeleton/index.ts +2 -0
  103. package/src/tools/AudioPlayer/parts/Waveform/BarsWaveform.tsx +48 -0
  104. package/src/tools/AudioPlayer/parts/Waveform/LiveWaveform.tsx +95 -0
  105. package/src/tools/AudioPlayer/parts/Waveform/PeaksWaveform.tsx +100 -0
  106. package/src/tools/AudioPlayer/parts/Waveform/ProgressBar.tsx +76 -0
  107. package/src/tools/AudioPlayer/parts/Waveform/Waveform.tsx +74 -0
  108. package/src/tools/AudioPlayer/parts/Waveform/WaveformSkeleton.tsx +16 -0
  109. package/src/tools/AudioPlayer/parts/Waveform/index.ts +8 -0
  110. package/src/tools/AudioPlayer/parts/Waveform/waveformInteraction.ts +106 -0
  111. package/src/tools/AudioPlayer/parts/Waveform/waveformRenderer.ts +91 -0
  112. package/src/tools/AudioPlayer/parts/index.ts +1 -0
  113. package/src/tools/AudioPlayer/store/activePlayerBus.ts +63 -0
  114. package/src/tools/AudioPlayer/store/createLevelsStore.ts +37 -0
  115. package/src/tools/AudioPlayer/store/index.ts +16 -0
  116. package/src/tools/AudioPlayer/store/preferencesStore.ts +104 -0
  117. package/src/tools/AudioPlayer/styles/webview-safe.css +77 -0
  118. package/src/tools/AudioPlayer/types.ts +95 -0
  119. package/src/tools/AudioPlayer/utils/bucketize.ts +27 -0
  120. package/src/tools/AudioPlayer/utils/clamp.ts +5 -0
  121. package/src/tools/AudioPlayer/utils/dpr.ts +19 -0
  122. package/src/tools/AudioPlayer/utils/formatTime.ts +12 -8
  123. package/src/tools/AudioPlayer/utils/index.ts +4 -5
  124. package/src/tools/AudioPlayer/utils/readCssVar.ts +7 -0
  125. package/src/tools/AudioPlayer/utils/resolveCanvasColor.ts +28 -0
  126. package/src/tools/index.ts +5 -75
  127. package/dist/chunk-2QY3LJR6.mjs.map +0 -1
  128. package/dist/chunk-6HNAPVZ2.mjs.map +0 -1
  129. package/dist/chunk-CXVGN6ZW.cjs.map +0 -1
  130. package/dist/chunk-F2N7P5XU.cjs +0 -30
  131. package/dist/chunk-F2N7P5XU.cjs.map +0 -1
  132. package/dist/chunk-FYLR232K.cjs.map +0 -1
  133. package/dist/chunk-HMHIVEMS.mjs +0 -1619
  134. package/dist/chunk-HMHIVEMS.mjs.map +0 -1
  135. package/dist/chunk-JWB2EWQO.mjs +0 -5
  136. package/dist/chunk-JWB2EWQO.mjs.map +0 -1
  137. package/dist/chunk-YZX6FH3H.cjs +0 -1656
  138. package/dist/chunk-YZX6FH3H.cjs.map +0 -1
  139. package/dist/components-3RTH76CV.cjs +0 -27
  140. package/dist/components-5GVVL2Q6.mjs +0 -5
  141. package/dist/components-CPHOUQ5F.cjs +0 -46
  142. package/dist/components-CPHOUQ5F.cjs.map +0 -1
  143. package/dist/components-OTK43IMD.mjs +0 -6
  144. package/dist/components-OTK43IMD.mjs.map +0 -1
  145. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +0 -225
  146. package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +0 -163
  147. package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +0 -284
  148. package/src/tools/AudioPlayer/components/HybridWaveform.tsx +0 -286
  149. package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +0 -151
  150. package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +0 -110
  151. package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +0 -58
  152. package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +0 -45
  153. package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +0 -82
  154. package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +0 -8
  155. package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +0 -6
  156. package/src/tools/AudioPlayer/components/index.ts +0 -23
  157. package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +0 -158
  158. package/src/tools/AudioPlayer/effects/index.ts +0 -412
  159. package/src/tools/AudioPlayer/hooks/useAudioBus.ts +0 -76
  160. package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +0 -403
  161. package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +0 -96
  162. package/src/tools/AudioPlayer/hooks/useVisualization.tsx +0 -207
  163. package/src/tools/AudioPlayer/types/effects.ts +0 -73
  164. package/src/tools/AudioPlayer/types/index.ts +0 -27
  165. package/src/tools/AudioPlayer/utils/debug.ts +0 -14
@@ -0,0 +1 @@
1
+ export { ErrorState } from './ErrorState';
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { PlayButton, VolumeControl } from '../Controls';
4
+ import { TimeDisplay } from '../Meta';
5
+ import { Waveform } from '../Waveform';
6
+ import type { WaveformConfig } from '../../types';
7
+
8
+ type Props = { waveform?: WaveformConfig; seekStartsPlayback?: boolean };
9
+
10
+ export function CompactLayout({ waveform, seekStartsPlayback }: Props) {
11
+ return (
12
+ <div className="flex items-center gap-3 p-2">
13
+ <PlayButton size="compact" />
14
+ <div className="min-w-0 flex-1">
15
+ <Waveform
16
+ config={waveform}
17
+ height={waveform?.height ?? 24}
18
+ seekStartsPlayback={seekStartsPlayback}
19
+ />
20
+ </div>
21
+ <TimeDisplay />
22
+ <VolumeControl />
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import { ControlsRow } from '../Controls';
4
+ import { Cover, ReactivePulse } from '../Cover';
5
+ import { ErrorState } from '../ErrorState';
6
+ import { Artist, Title } from '../Meta';
7
+ import { Waveform } from '../Waveform';
8
+ import type { ReactiveCoverMode, WaveformConfig } from '../../types';
9
+ import { usePlayerMeta } from '../../context/selectors';
10
+
11
+ type Props = {
12
+ waveform?: WaveformConfig;
13
+ reactiveCover?: ReactiveCoverMode;
14
+ onPrev?: () => void;
15
+ onNext?: () => void;
16
+ seekStartsPlayback?: boolean;
17
+ };
18
+
19
+ export function DefaultLayout({ waveform, reactiveCover, onPrev, onNext, seekStartsPlayback }: Props) {
20
+ const meta = usePlayerMeta();
21
+ const cover = (
22
+ <Cover src={meta.cover} alt={meta.title ? `${meta.title} cover` : ''} size={56} />
23
+ );
24
+ return (
25
+ <div className="flex flex-col gap-3.5 p-4">
26
+ {/* Header: cover ↔ meta, vertically centered. No timer here — timer
27
+ lives in ControlsRow next to playback. */}
28
+ <div className="flex items-center gap-3">
29
+ {reactiveCover === 'subtle' ? (
30
+ <ReactivePulse enabled>{cover}</ReactivePulse>
31
+ ) : (
32
+ cover
33
+ )}
34
+ <div className="min-w-0 flex-1">
35
+ <Title />
36
+ <Artist />
37
+ </div>
38
+ </div>
39
+ <Waveform
40
+ config={waveform}
41
+ height={waveform?.height ?? 40}
42
+ seekStartsPlayback={seekStartsPlayback}
43
+ />
44
+ <ErrorState />
45
+ <ControlsRow onPrev={onPrev} onNext={onNext} showTime />
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,2 @@
1
+ export { DefaultLayout } from './DefaultLayout';
2
+ export { CompactLayout } from './CompactLayout';
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+
3
+ import { usePlayerMeta } from '../../context/selectors';
4
+
5
+ export function Artist() {
6
+ const { artist, album } = usePlayerMeta();
7
+ if (!artist && !album) return null;
8
+ const text = [artist, album].filter(Boolean).join(' · ');
9
+ return (
10
+ <p className="truncate text-xs text-muted-foreground" title={text}>
11
+ {text}
12
+ </p>
13
+ );
14
+ }
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { usePlayerAudio, usePlayerDuration } from '../../context/selectors';
5
+ import { formatTime } from '../../utils/formatTime';
6
+
7
+ const READ_INTERVAL_MS = 200;
8
+
9
+ export function TimeDisplay() {
10
+ const audio = usePlayerAudio();
11
+ const duration = usePlayerDuration();
12
+ const currentRef = useRef<HTMLSpanElement | null>(null);
13
+
14
+ useEffect(() => {
15
+ const el = currentRef.current;
16
+ if (!el) return;
17
+ let raf = 0;
18
+ let last = -1;
19
+ let timer: ReturnType<typeof setInterval> | null = null;
20
+ const write = () => {
21
+ const t = audio.currentTime;
22
+ if (Math.abs(t - last) < 0.5) return;
23
+ last = t;
24
+ el.textContent = formatTime(t);
25
+ };
26
+ write();
27
+ const onSeek = () => write();
28
+ audio.addEventListener('seeked', onSeek);
29
+ audio.addEventListener('timeupdate', onSeek);
30
+ // Backup poll when timeupdate is throttled (e.g. background tab → visible).
31
+ timer = setInterval(() => {
32
+ if (!audio.paused) write();
33
+ }, READ_INTERVAL_MS);
34
+ return () => {
35
+ cancelAnimationFrame(raf);
36
+ audio.removeEventListener('seeked', onSeek);
37
+ audio.removeEventListener('timeupdate', onSeek);
38
+ if (timer) clearInterval(timer);
39
+ };
40
+ }, [audio]);
41
+
42
+ return (
43
+ <span className="tabular-nums text-xs text-muted-foreground">
44
+ <span ref={currentRef}>{formatTime(audio.currentTime)}</span>
45
+ {' / '}
46
+ {formatTime(duration)}
47
+ </span>
48
+ );
49
+ }
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+
3
+ import { usePlayerMeta } from '../../context/selectors';
4
+
5
+ export function Title() {
6
+ const { title } = usePlayerMeta();
7
+ if (!title) return null;
8
+ return (
9
+ <p className="truncate text-sm font-medium text-foreground" title={title}>
10
+ {title}
11
+ </p>
12
+ );
13
+ }
@@ -0,0 +1,3 @@
1
+ export { Title } from './Title';
2
+ export { Artist } from './Artist';
3
+ export { TimeDisplay } from './TimeDisplay';
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+
3
+ type Props = { size: number };
4
+
5
+ export function CoverSkeleton({ size }: Props) {
6
+ return (
7
+ <div
8
+ className="audioplayer-shimmer rounded-md bg-muted"
9
+ style={{ width: size, height: size }}
10
+ aria-hidden="true"
11
+ />
12
+ );
13
+ }
@@ -0,0 +1,10 @@
1
+ 'use client';
2
+
3
+ export function MetaSkeleton() {
4
+ return (
5
+ <div className="flex flex-col gap-1.5" aria-hidden="true">
6
+ <div className="audioplayer-shimmer h-3.5 w-2/3 rounded bg-muted" />
7
+ <div className="audioplayer-shimmer h-3 w-1/3 rounded bg-muted/70" />
8
+ </div>
9
+ );
10
+ }
@@ -0,0 +1,2 @@
1
+ export { MetaSkeleton } from './MetaSkeleton';
2
+ export { CoverSkeleton } from './CoverSkeleton';
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ // Decorative CSS-only equalizer. No audio coupling. Use when waveform shape
4
+ // doesn't matter (notification bells, generic "audio playing" affordance).
5
+
6
+ import { useMemo } from 'react';
7
+ import { usePlayerPaused } from '../../context/selectors';
8
+
9
+ type Props = {
10
+ height: number;
11
+ barWidth: number;
12
+ barGap: number;
13
+ bars?: number;
14
+ };
15
+
16
+ const BAR_COUNT = 28;
17
+
18
+ export function BarsWaveform({ height, barWidth, barGap, bars = BAR_COUNT }: Props) {
19
+ const paused = usePlayerPaused();
20
+ const items = useMemo(() => {
21
+ return Array.from({ length: bars }, (_, i) => ({
22
+ delay: `${(i % 7) * 90}ms`,
23
+ duration: `${800 + (i % 5) * 120}ms`,
24
+ }));
25
+ }, [bars]);
26
+
27
+ return (
28
+ <div
29
+ className="audioplayer-bars flex w-full items-center justify-between"
30
+ style={{ height, gap: barGap }}
31
+ data-mode="bars"
32
+ aria-hidden="true"
33
+ >
34
+ {items.map((it, i) => (
35
+ <span
36
+ key={i}
37
+ className="rounded-sm bg-primary"
38
+ style={{
39
+ width: barWidth,
40
+ height: '40%',
41
+ animation: paused ? undefined : `audioplayer-bar ${it.duration} ${it.delay} ease-in-out infinite`,
42
+ transformOrigin: 'center',
43
+ }}
44
+ />
45
+ ))}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { alpha, useThemeColor } from '@djangocfg/ui-core/styles/palette';
5
+ import { useAnalyser } from '../../hooks/useAnalyser';
6
+ import { useElementWidth } from '../../hooks/useResizeObserver';
7
+ import { usePlayerAudio, usePlayerControls, usePlayerLevels } from '../../context/selectors';
8
+ import { attachHover, attachSeek } from './waveformInteraction';
9
+ import { paintLive } from './waveformRenderer';
10
+
11
+ type Props = {
12
+ height: number;
13
+ barWidth: number;
14
+ barGap: number;
15
+ seekStartsPlayback?: boolean;
16
+ };
17
+
18
+ export function LiveWaveform({ height, barWidth, barGap, seekStartsPlayback }: Props) {
19
+ const audio = usePlayerAudio();
20
+ const controls = usePlayerControls();
21
+ const store = usePlayerLevels();
22
+ const [container, setContainer] = useState<HTMLDivElement | null>(null);
23
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
24
+ const fgHex = useThemeColor('primary');
25
+ const mutedHex = useThemeColor('muted-foreground');
26
+ const colorRef = useRef(fgHex);
27
+ colorRef.current = fgHex;
28
+ useElementWidth(container);
29
+
30
+ useAnalyser(audio, store, true);
31
+
32
+ // rAF paint loop reads from the imperative store. Zero React renders.
33
+ useEffect(() => {
34
+ const canvas = canvasRef.current;
35
+ if (!canvas) return;
36
+ let raf = 0;
37
+ const tick = () => {
38
+ if (typeof document !== 'undefined' && document.hidden) {
39
+ raf = requestAnimationFrame(tick);
40
+ return;
41
+ }
42
+ paintLive(canvas, store.getCurrent(), {
43
+ color: colorRef.current,
44
+ barWidth,
45
+ barGap,
46
+ minBarHeight: 1,
47
+ });
48
+ raf = requestAnimationFrame(tick);
49
+ };
50
+ raf = requestAnimationFrame(tick);
51
+ return () => cancelAnimationFrame(raf);
52
+ }, [store, barWidth, barGap]);
53
+
54
+ useEffect(() => {
55
+ if (!container) return;
56
+ const detachSeek = attachSeek(container, audio, {
57
+ startsPlayback: seekStartsPlayback,
58
+ onPlayRequest: () => void controls.play(),
59
+ });
60
+ const detachHover = attachHover(container, audio);
61
+ return () => {
62
+ detachSeek();
63
+ detachHover();
64
+ };
65
+ }, [audio, container, controls, seekStartsPlayback]);
66
+
67
+ return (
68
+ <div
69
+ ref={setContainer}
70
+ className="audioplayer-waveform relative w-full select-none cursor-pointer"
71
+ style={{ height }}
72
+ data-mode="live"
73
+ >
74
+ <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" aria-hidden="true" />
75
+ <div
76
+ className="audioplayer-hover pointer-events-none absolute top-0 bottom-0 w-px transition-opacity"
77
+ style={{
78
+ left: 'var(--hp, -10px)',
79
+ opacity: 'var(--ho, 0)',
80
+ backgroundColor: alpha(mutedHex, 0.5),
81
+ }}
82
+ aria-hidden="true"
83
+ />
84
+ <div
85
+ data-audioplayer-time-tip
86
+ className="audioplayer-tip pointer-events-none absolute -top-7 -translate-x-1/2 rounded bg-foreground px-1.5 py-0.5 text-[10px] tabular-nums text-background transition-opacity"
87
+ style={{
88
+ left: 'var(--hp, -100px)',
89
+ opacity: 'var(--ht, 0)',
90
+ }}
91
+ aria-hidden="true"
92
+ />
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { alpha, useThemeColor } from '@djangocfg/ui-core/styles/palette';
5
+ import { usePlayerAudio, usePlayerControls } from '../../context/selectors';
6
+ import { useElementWidth } from '../../hooks/useResizeObserver';
7
+ import { usePlayheadLoop } from '../../hooks/usePlayheadLoop';
8
+ import { useThemeWatcher } from '../../hooks/useThemeWatcher';
9
+ import { attachHover, attachSeek } from './waveformInteraction';
10
+ import { paintPeaks } from './waveformRenderer';
11
+
12
+ type Props = {
13
+ peaks: Float32Array;
14
+ height: number;
15
+ barWidth: number;
16
+ barGap: number;
17
+ seekStartsPlayback?: boolean;
18
+ };
19
+
20
+ export function PeaksWaveform({ peaks, height, barWidth, barGap, seekStartsPlayback }: Props) {
21
+ const audio = usePlayerAudio();
22
+ const controls = usePlayerControls();
23
+ // State-backed ref so child hooks re-run once the DOM node attaches.
24
+ const [container, setContainer] = useState<HTMLDivElement | null>(null);
25
+ const bgCanvasRef = useRef<HTMLCanvasElement | null>(null);
26
+ const fgCanvasRef = useRef<HTMLCanvasElement | null>(null);
27
+ const width = useElementWidth(container);
28
+
29
+ const fgHex = useThemeColor('primary');
30
+ const mutedHex = useThemeColor('muted-foreground');
31
+
32
+ const repaint = useCallback(() => {
33
+ const bg = bgCanvasRef.current;
34
+ const fg = fgCanvasRef.current;
35
+ if (!bg || !fg) return;
36
+ paintPeaks(bg, peaks, { color: alpha(mutedHex, 0.4), barWidth, barGap, minBarHeight: 1 });
37
+ paintPeaks(fg, peaks, { color: fgHex, barWidth, barGap, minBarHeight: 1 });
38
+ }, [peaks, barWidth, barGap, fgHex, mutedHex]);
39
+
40
+ useEffect(repaint, [repaint, width]);
41
+ useThemeWatcher(repaint);
42
+
43
+ usePlayheadLoop(audio, container, true);
44
+
45
+ useEffect(() => {
46
+ if (!container) return;
47
+ const detachSeek = attachSeek(container, audio, {
48
+ startsPlayback: seekStartsPlayback,
49
+ onPlayRequest: () => void controls.play(),
50
+ });
51
+ const detachHover = attachHover(container, audio);
52
+ return () => {
53
+ detachSeek();
54
+ detachHover();
55
+ };
56
+ }, [audio, container, controls, seekStartsPlayback]);
57
+
58
+ return (
59
+ <div
60
+ ref={setContainer}
61
+ className="audioplayer-waveform relative w-full select-none cursor-pointer"
62
+ style={{ height, ['--p' as string]: '0%' }}
63
+ data-mode="peaks"
64
+ >
65
+ <canvas ref={bgCanvasRef} className="absolute inset-0 h-full w-full" aria-hidden="true" />
66
+ <div
67
+ className="audioplayer-fg-clip absolute inset-0"
68
+ style={{
69
+ clipPath: 'polygon(0 0, var(--p) 0, var(--p) 100%, 0 100%)',
70
+ willChange: 'clip-path',
71
+ }}
72
+ >
73
+ <canvas ref={fgCanvasRef} className="absolute inset-0 h-full w-full" aria-hidden="true" />
74
+ </div>
75
+ <div
76
+ className="audioplayer-cursor pointer-events-none absolute top-0 bottom-0 w-px"
77
+ style={{ left: 'var(--p)', backgroundColor: alpha(fgHex, 0.7) }}
78
+ aria-hidden="true"
79
+ />
80
+ <div
81
+ className="audioplayer-hover pointer-events-none absolute top-0 bottom-0 w-px transition-opacity"
82
+ style={{
83
+ left: 'var(--hp, -10px)',
84
+ opacity: 'var(--ho, 0)',
85
+ backgroundColor: alpha(mutedHex, 0.5),
86
+ }}
87
+ aria-hidden="true"
88
+ />
89
+ <div
90
+ data-audioplayer-time-tip
91
+ className="audioplayer-tip pointer-events-none absolute -top-7 -translate-x-1/2 rounded bg-foreground px-1.5 py-0.5 text-[10px] tabular-nums text-background transition-opacity"
92
+ style={{
93
+ left: 'var(--hp, -100px)',
94
+ opacity: 'var(--ht, 0)',
95
+ }}
96
+ aria-hidden="true"
97
+ />
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ // Plain progress bar — no waveform, no animation. Same playhead mechanics as
4
+ // the canvas modes (clip-path via --p, click/drag to seek). Use when you want
5
+ // a track scrubber without amplitude visualisation.
6
+
7
+ import { useEffect, useState } from 'react';
8
+ import { alpha, useThemeColor } from '@djangocfg/ui-core/styles/palette';
9
+ import { usePlayerAudio, usePlayerControls } from '../../context/selectors';
10
+ import { usePlayheadLoop } from '../../hooks/usePlayheadLoop';
11
+ import { attachHover, attachSeek } from './waveformInteraction';
12
+
13
+ type Props = { height?: number; seekStartsPlayback?: boolean };
14
+
15
+ export function ProgressBar({ height = 4, seekStartsPlayback }: Props) {
16
+ const audio = usePlayerAudio();
17
+ const controls = usePlayerControls();
18
+ const [container, setContainer] = useState<HTMLDivElement | null>(null);
19
+ const fgHex = useThemeColor('primary');
20
+ const mutedHex = useThemeColor('muted-foreground');
21
+
22
+ usePlayheadLoop(audio, container, true);
23
+
24
+ useEffect(() => {
25
+ if (!container) return;
26
+ const detachSeek = attachSeek(container, audio, {
27
+ startsPlayback: seekStartsPlayback,
28
+ onPlayRequest: () => void controls.play(),
29
+ });
30
+ const detachHover = attachHover(container, audio);
31
+ return () => {
32
+ detachSeek();
33
+ detachHover();
34
+ };
35
+ }, [audio, container, controls, seekStartsPlayback]);
36
+
37
+ return (
38
+ <div
39
+ ref={setContainer}
40
+ className="audioplayer-waveform relative w-full select-none cursor-pointer"
41
+ style={{ ['--p' as string]: '0%' }}
42
+ data-mode="progress"
43
+ >
44
+ <div
45
+ className="rounded-full"
46
+ style={{ height, backgroundColor: alpha(mutedHex, 0.25) }}
47
+ />
48
+ <div
49
+ className="absolute inset-y-0 left-0 rounded-full"
50
+ style={{
51
+ width: 'var(--p)',
52
+ backgroundColor: fgHex,
53
+ }}
54
+ aria-hidden="true"
55
+ />
56
+ <div
57
+ className="audioplayer-hover pointer-events-none absolute -top-1 -bottom-1 w-px transition-opacity"
58
+ style={{
59
+ left: 'var(--hp, -10px)',
60
+ opacity: 'var(--ho, 0)',
61
+ backgroundColor: alpha(mutedHex, 0.5),
62
+ }}
63
+ aria-hidden="true"
64
+ />
65
+ <div
66
+ data-audioplayer-time-tip
67
+ className="audioplayer-tip pointer-events-none absolute -top-7 -translate-x-1/2 rounded bg-foreground px-1.5 py-0.5 text-[10px] tabular-nums text-background transition-opacity"
68
+ style={{
69
+ left: 'var(--hp, -100px)',
70
+ opacity: 'var(--ht, 0)',
71
+ }}
72
+ aria-hidden="true"
73
+ />
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ import { useRef } from 'react';
4
+ import { usePlayerMeta } from '../../context/selectors';
5
+ import { usePeaks } from '../../hooks/usePeaks';
6
+ import type { WaveformConfig } from '../../types';
7
+ import { BarsWaveform } from './BarsWaveform';
8
+ import { LiveWaveform } from './LiveWaveform';
9
+ import { PeaksWaveform } from './PeaksWaveform';
10
+ import { ProgressBar } from './ProgressBar';
11
+ import { WaveformSkeleton } from './WaveformSkeleton';
12
+
13
+ type Props = {
14
+ config?: WaveformConfig;
15
+ height: number;
16
+ seekStartsPlayback?: boolean;
17
+ };
18
+
19
+ export function Waveform({ config, height, seekStartsPlayback }: Props) {
20
+ const meta = usePlayerMeta();
21
+ const triggerRef = useRef<HTMLDivElement | null>(null);
22
+
23
+ const mode = config?.mode ?? 'peaks';
24
+ const barWidth = config?.barWidth ?? 2;
25
+ const barGap = config?.barGap ?? 1;
26
+
27
+ const peaksEnabled = mode === 'peaks';
28
+ const { peaks, loading, error } = usePeaks({
29
+ src: meta.src,
30
+ enabled: peaksEnabled,
31
+ triggerRef,
32
+ decodeOnMount: config?.decodeOnMount,
33
+ });
34
+
35
+ if (mode === 'none') return null;
36
+ if (mode === 'progress')
37
+ return <ProgressBar height={Math.min(height, 6)} seekStartsPlayback={seekStartsPlayback} />;
38
+ if (mode === 'bars') return <BarsWaveform height={height} barWidth={barWidth} barGap={barGap} />;
39
+ if (mode === 'live')
40
+ return (
41
+ <LiveWaveform
42
+ height={height}
43
+ barWidth={barWidth}
44
+ barGap={barGap}
45
+ seekStartsPlayback={seekStartsPlayback}
46
+ />
47
+ );
48
+
49
+ // peaks mode
50
+ if (loading || (!peaks && !error)) {
51
+ return (
52
+ <div ref={triggerRef}>
53
+ <WaveformSkeleton height={height} />
54
+ </div>
55
+ );
56
+ }
57
+ if (!peaks) {
58
+ // Decode failed — degrade to a plain scrubber. Playback works; user can
59
+ // still seek; we just can't show amplitude. Per ADR-002 we never surface
60
+ // decode failure as a hard error.
61
+ return <ProgressBar height={Math.min(height, 6)} seekStartsPlayback={seekStartsPlayback} />;
62
+ }
63
+ return (
64
+ <div ref={triggerRef}>
65
+ <PeaksWaveform
66
+ peaks={peaks}
67
+ height={height}
68
+ barWidth={barWidth}
69
+ barGap={barGap}
70
+ seekStartsPlayback={seekStartsPlayback}
71
+ />
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+
3
+ type Props = { height: number };
4
+
5
+ export function WaveformSkeleton({ height }: Props) {
6
+ return (
7
+ <div
8
+ className="audioplayer-skeleton relative w-full overflow-hidden"
9
+ style={{ height }}
10
+ aria-hidden="true"
11
+ >
12
+ <div className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-muted-foreground/30" />
13
+ <div className="absolute inset-0 audioplayer-shimmer" />
14
+ </div>
15
+ );
16
+ }
@@ -0,0 +1,8 @@
1
+ export { Waveform } from './Waveform';
2
+ export { PeaksWaveform } from './PeaksWaveform';
3
+ export { LiveWaveform } from './LiveWaveform';
4
+ export { BarsWaveform } from './BarsWaveform';
5
+ export { ProgressBar } from './ProgressBar';
6
+ export { WaveformSkeleton } from './WaveformSkeleton';
7
+ export { paintPeaks, paintLive } from './waveformRenderer';
8
+ export { attachSeek, attachHover } from './waveformInteraction';