@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
@@ -1,105 +1,16 @@
1
1
  'use client';
2
2
 
3
- /**
4
- * Lazy-loaded AudioPlayer Components
5
- *
6
- * Heavy WaveSurfer.js (~200KB) is loaded only when component is rendered.
7
- * Use this for automatic code-splitting with Suspense fallback.
8
- *
9
- * For direct imports without lazy loading, use:
10
- * import { HybridAudioPlayer } from '@djangocfg/ui-tools/audio'
11
- */
3
+ import { createLazyComponent } from '../../components';
4
+ import type { PlayerProps } from './types';
12
5
 
13
- import { createLazyComponent, LoadingFallback } from '../../components';
14
- import type {
15
- HybridAudioPlayerProps,
16
- HybridSimplePlayerProps,
17
- HybridCompactPlayerProps,
18
- } from './components';
19
-
20
- // ============================================================================
21
- // Re-export types
22
- // ============================================================================
23
-
24
- export type { HybridAudioPlayerProps, HybridSimplePlayerProps, HybridCompactPlayerProps };
25
-
26
- // ============================================================================
27
- // Audio Loading Fallback
28
- // ============================================================================
29
-
30
- function AudioLoadingFallback() {
31
- return (
32
- <div className="flex items-center justify-center p-6 bg-muted/30 rounded-lg">
33
- <div className="flex flex-col items-center gap-2">
34
- <div className="relative">
35
- <div className="h-10 w-10 animate-spin rounded-full border-4 border-muted border-t-primary" />
36
- <div className="absolute inset-0 flex items-center justify-center">
37
- <svg
38
- className="h-5 w-5 text-muted-foreground"
39
- fill="none"
40
- viewBox="0 0 24 24"
41
- stroke="currentColor"
42
- >
43
- <path
44
- strokeLinecap="round"
45
- strokeLinejoin="round"
46
- strokeWidth={2}
47
- d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
48
- />
49
- </svg>
50
- </div>
51
- </div>
52
- <span className="text-sm text-muted-foreground">Loading audio player...</span>
53
- </div>
54
- </div>
55
- );
56
- }
57
-
58
- // ============================================================================
59
- // Lazy Components
60
- // ============================================================================
61
-
62
- /**
63
- * LazyHybridAudioPlayer - Lazy-loaded full-featured audio player
64
- *
65
- * Automatically shows loading state while WaveSurfer loads (~200KB)
66
- */
67
- export const LazyHybridAudioPlayer = createLazyComponent<HybridAudioPlayerProps>(
68
- () => import('./components').then((mod) => ({ default: mod.HybridAudioPlayer })),
69
- {
70
- displayName: 'LazyHybridAudioPlayer',
71
- fallback: <AudioLoadingFallback />,
72
- }
73
- );
74
-
75
- /**
76
- * LazyHybridSimplePlayer - Lazy-loaded simple audio player
77
- *
78
- * Automatically shows loading state while WaveSurfer loads (~200KB)
79
- */
80
- export const LazyHybridSimplePlayer = createLazyComponent<HybridSimplePlayerProps>(
81
- () => import('./components').then((mod) => ({ default: mod.HybridSimplePlayer })),
82
- {
83
- displayName: 'LazyHybridSimplePlayer',
84
- fallback: <AudioLoadingFallback />,
85
- }
86
- );
87
-
88
- /**
89
- * LazyHybridCompactPlayer - Lazy-loaded compact single-row audio player
90
- *
91
- * Use in tight spaces: play/pause + waveform + timer in one line.
92
- */
93
- export const LazyHybridCompactPlayer = createLazyComponent<HybridCompactPlayerProps>(
94
- () => import('./components').then((mod) => ({ default: mod.HybridCompactPlayer })),
6
+ export const LazyPlayer = createLazyComponent<PlayerProps>(
7
+ () => import('./Player').then((mod) => ({ default: mod.Player })),
95
8
  {
96
- displayName: 'LazyHybridCompactPlayer',
9
+ displayName: 'LazyAudioPlayer',
97
10
  fallback: (
98
- <div className="flex items-center gap-2 h-8 px-1 animate-pulse">
99
- <div className="h-8 w-8 rounded-md bg-muted flex-shrink-0" />
100
- <div className="flex-1 h-4 rounded bg-muted" />
101
- <div className="h-3 w-12 rounded bg-muted flex-shrink-0" />
11
+ <div className="rounded-lg border border-border/60 bg-card px-4 py-6 text-sm text-muted-foreground">
12
+ Loading audio player…
102
13
  </div>
103
14
  ),
104
- }
15
+ },
105
16
  );
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { TimeDisplay } from '../Meta/TimeDisplay';
4
+ import { LoopButton } from './LoopButton';
5
+ import { PlayButton } from './PlayButton';
6
+ import { SkipButton } from './SkipButton';
7
+ import { VolumeControl } from './VolumeControl';
8
+
9
+ type Props = {
10
+ onPrev?: () => void;
11
+ onNext?: () => void;
12
+ showTime?: boolean;
13
+ };
14
+
15
+ export function ControlsRow({ onPrev, onNext, showTime = false }: Props) {
16
+ return (
17
+ <div className="flex items-center justify-between gap-3">
18
+ <div className="flex items-center gap-1">
19
+ <SkipButton direction="prev" onClick={onPrev} />
20
+ <PlayButton />
21
+ <SkipButton direction="next" onClick={onNext} />
22
+ </div>
23
+ <div className="flex items-center gap-2">
24
+ {showTime && <TimeDisplay />}
25
+ <VolumeControl />
26
+ <LoopButton />
27
+ </div>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
4
+ import {
5
+ Tooltip,
6
+ TooltipContent,
7
+ TooltipTrigger,
8
+ } from '@djangocfg/ui-core/components';
9
+
10
+ type Props = {
11
+ label: string;
12
+ // Keyboard shortcut hint shown in the tooltip (e.g. "L" for loop). Optional.
13
+ shortcut?: string;
14
+ active?: boolean;
15
+ children: ReactNode;
16
+ // Render the button without the tooltip wrapper. Useful for inline contexts
17
+ // (e.g. inside another tooltip / popover) where nesting Radix portals is
18
+ // unnecessary.
19
+ noTooltip?: boolean;
20
+ } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'aria-label'>;
21
+
22
+ export function IconButton({
23
+ label,
24
+ shortcut,
25
+ active,
26
+ children,
27
+ noTooltip,
28
+ className = '',
29
+ ...rest
30
+ }: Props) {
31
+ // Active state uses a tinted primary surface — readable, on-brand, not loud.
32
+ const stateClasses = active
33
+ ? 'bg-primary/10 text-primary hover:bg-primary/15'
34
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground';
35
+ const button = (
36
+ <button
37
+ type="button"
38
+ aria-label={label}
39
+ aria-pressed={active}
40
+ className={`audioplayer-press grid h-8 w-8 place-items-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:opacity-50 disabled:pointer-events-none ${stateClasses} ${className}`}
41
+ {...rest}
42
+ >
43
+ {children}
44
+ </button>
45
+ );
46
+ if (noTooltip) return button;
47
+ return (
48
+ <Tooltip>
49
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
50
+ <TooltipContent side="top">
51
+ <span className="flex items-center gap-1.5">
52
+ {label}
53
+ {shortcut && (
54
+ <kbd className="rounded border border-border/40 bg-muted px-1 font-mono text-[10px] text-muted-foreground">
55
+ {shortcut}
56
+ </kbd>
57
+ )}
58
+ </span>
59
+ </TooltipContent>
60
+ </Tooltip>
61
+ );
62
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { Repeat } from 'lucide-react';
4
+ import { useEffect, useState } from 'react';
5
+ import { usePlayerAudio, usePlayerControls } from '../../context/selectors';
6
+ import { IconButton } from './IconButton';
7
+
8
+ export function LoopButton() {
9
+ const audio = usePlayerAudio();
10
+ const { toggleLoop } = usePlayerControls();
11
+ const [loop, setLoop] = useState(audio.loop);
12
+
13
+ useEffect(() => {
14
+ // <audio> doesn't fire an event for loop changes, but our toggleLoop sets
15
+ // the property directly — so we sync via a small mutation observer pattern:
16
+ // poll on click only by reading audio.loop after toggleLoop runs.
17
+ setLoop(audio.loop);
18
+ }, [audio]);
19
+
20
+ return (
21
+ <IconButton
22
+ label={loop ? 'Disable loop' : 'Enable loop'}
23
+ shortcut="L"
24
+ active={loop}
25
+ onClick={() => {
26
+ toggleLoop();
27
+ setLoop(audio.loop);
28
+ }}
29
+ >
30
+ <Repeat size={16} strokeWidth={1.75} />
31
+ </IconButton>
32
+ );
33
+ }
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ import { LoaderCircle, Pause, Play, RotateCcw } from 'lucide-react';
4
+ import {
5
+ Tooltip,
6
+ TooltipContent,
7
+ TooltipTrigger,
8
+ } from '@djangocfg/ui-core/components';
9
+ import { usePlayerControls, usePlayerState } from '../../context/selectors';
10
+
11
+ type Props = { size?: 'default' | 'compact' };
12
+
13
+ export function PlayButton({ size = 'default' }: Props) {
14
+ const state = usePlayerState();
15
+ const { toggle, play } = usePlayerControls();
16
+
17
+ const dim = size === 'compact' ? 28 : 36;
18
+ const icon = size === 'compact' ? 14 : 16;
19
+
20
+ let label = 'Play';
21
+ let Icon: React.ComponentType<{ size?: number; strokeWidth?: number; className?: string }> = Play;
22
+ let onClick: () => void = () => void toggle();
23
+ let disabled = false;
24
+
25
+ switch (state.kind) {
26
+ case 'idle':
27
+ Icon = Play;
28
+ disabled = true;
29
+ break;
30
+ case 'loading':
31
+ case 'decoding':
32
+ Icon = LoaderCircle;
33
+ label = 'Loading';
34
+ disabled = true;
35
+ break;
36
+ case 'playing':
37
+ Icon = Pause;
38
+ label = 'Pause';
39
+ break;
40
+ case 'paused':
41
+ Icon = Play;
42
+ label = 'Play';
43
+ break;
44
+ case 'ended':
45
+ Icon = RotateCcw;
46
+ label = 'Replay';
47
+ onClick = () => void play();
48
+ break;
49
+ case 'error':
50
+ Icon = Play;
51
+ label = 'Retry';
52
+ onClick = () => void play();
53
+ break;
54
+ }
55
+
56
+ const button = (
57
+ <button
58
+ type="button"
59
+ onClick={onClick}
60
+ disabled={disabled}
61
+ aria-label={label}
62
+ className="audioplayer-press grid place-items-center rounded-full bg-foreground text-background transition-colors hover:bg-foreground/90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
63
+ style={{ width: dim, height: dim }}
64
+ >
65
+ <Icon
66
+ size={icon}
67
+ strokeWidth={2}
68
+ className={state.kind === 'loading' || state.kind === 'decoding' ? 'animate-spin' : ''}
69
+ />
70
+ </button>
71
+ );
72
+
73
+ return (
74
+ <Tooltip>
75
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
76
+ <TooltipContent side="top">
77
+ <span className="flex items-center gap-1.5">
78
+ {label}
79
+ <kbd className="rounded border border-border/40 bg-muted px-1 font-mono text-[10px] text-muted-foreground">
80
+ Space
81
+ </kbd>
82
+ </span>
83
+ </TooltipContent>
84
+ </Tooltip>
85
+ );
86
+ }
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+
3
+ import { SkipBack, SkipForward } from 'lucide-react';
4
+ import { IconButton } from './IconButton';
5
+
6
+ type Props = { direction: 'prev' | 'next'; onClick?: () => void };
7
+
8
+ export function SkipButton({ direction, onClick }: Props) {
9
+ if (!onClick) return null;
10
+ const Icon = direction === 'prev' ? SkipBack : SkipForward;
11
+ const label = direction === 'prev' ? 'Previous track' : 'Next track';
12
+ return (
13
+ <IconButton label={label} shortcut={direction === 'prev' ? '←' : '→'} onClick={onClick}>
14
+ <Icon size={16} strokeWidth={1.75} />
15
+ </IconButton>
16
+ );
17
+ }
@@ -0,0 +1,171 @@
1
+ 'use client';
2
+
3
+ import { Volume2, VolumeX } from 'lucide-react';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import { useMediaQuery } from '@djangocfg/ui-core/hooks';
6
+ import { usePlayerAudio, usePlayerControls } from '../../context/selectors';
7
+ import { IconButton } from './IconButton';
8
+
9
+ const CLOSE_DELAY_MS = 120;
10
+
11
+ // `audio.volume` is read-only on iOS Safari (controlled by hardware buttons),
12
+ // so a JS slider does nothing useful there. Detect once at module load.
13
+ function isIosSafari(): boolean {
14
+ if (typeof navigator === 'undefined') return false;
15
+ const ua = navigator.userAgent;
16
+ const iOS = /iPad|iPhone|iPod/.test(ua) ||
17
+ (navigator.platform === 'MacIntel' && (navigator as { maxTouchPoints?: number }).maxTouchPoints! > 1);
18
+ return iOS;
19
+ }
20
+ const HIDE_VOLUME = isIosSafari();
21
+
22
+ export function VolumeControl() {
23
+ const audio = usePlayerAudio();
24
+ const { setVolume, toggleMute } = usePlayerControls();
25
+ const [volume, setVol] = useState(audio.volume);
26
+ const [muted, setMuted] = useState(audio.muted);
27
+ const [isOpen, setOpen] = useState(false);
28
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+
30
+ // Touch devices have no hover — open the popover on click instead.
31
+ const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
32
+
33
+ useEffect(() => {
34
+ const sync = () => {
35
+ setVol(audio.volume);
36
+ setMuted(audio.muted);
37
+ };
38
+ audio.addEventListener('volumechange', sync);
39
+ return () => audio.removeEventListener('volumechange', sync);
40
+ }, [audio]);
41
+
42
+ useEffect(() => {
43
+ return () => {
44
+ if (closeTimer.current) clearTimeout(closeTimer.current);
45
+ };
46
+ }, []);
47
+
48
+ // Close on outside-click in touch mode.
49
+ const containerRef = useRef<HTMLDivElement | null>(null);
50
+ useEffect(() => {
51
+ if (!isOpen || !isTouch) return;
52
+ const onDown = (e: PointerEvent) => {
53
+ if (!containerRef.current) return;
54
+ if (!containerRef.current.contains(e.target as Node)) setOpen(false);
55
+ };
56
+ document.addEventListener('pointerdown', onDown);
57
+ return () => document.removeEventListener('pointerdown', onDown);
58
+ }, [isOpen, isTouch]);
59
+
60
+ if (HIDE_VOLUME) {
61
+ // iOS Safari can't change volume via JS — keep mute toggle only.
62
+ return (
63
+ <IconButton
64
+ label={muted ? 'Unmute' : 'Mute'}
65
+ shortcut="M"
66
+ active={muted}
67
+ onClick={() => {
68
+ toggleMute();
69
+ setMuted(audio.muted);
70
+ }}
71
+ >
72
+ {muted || volume === 0 ? <VolumeX size={16} strokeWidth={1.75} /> : <Volume2 size={16} strokeWidth={1.75} />}
73
+ </IconButton>
74
+ );
75
+ }
76
+
77
+ const cancelClose = () => {
78
+ if (closeTimer.current) {
79
+ clearTimeout(closeTimer.current);
80
+ closeTimer.current = null;
81
+ }
82
+ };
83
+ const scheduleClose = () => {
84
+ cancelClose();
85
+ closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);
86
+ };
87
+ const open = () => {
88
+ cancelClose();
89
+ setOpen(true);
90
+ };
91
+
92
+ const Icon = muted || volume === 0 ? VolumeX : Volume2;
93
+
94
+ // Hover bindings only apply on devices with real hover. On touch the popover
95
+ // is toggled by the icon click.
96
+ const hoverHandlers = isTouch
97
+ ? {}
98
+ : {
99
+ onPointerEnter: open,
100
+ onPointerLeave: scheduleClose,
101
+ onFocusCapture: open,
102
+ onBlurCapture: (e: React.FocusEvent) => {
103
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) scheduleClose();
104
+ },
105
+ };
106
+
107
+ return (
108
+ <div ref={containerRef} className="relative" {...hoverHandlers}>
109
+ <IconButton
110
+ label={isOpen ? 'Close volume' : muted ? 'Unmute' : 'Volume'}
111
+ shortcut={isTouch ? undefined : 'M'}
112
+ active={muted}
113
+ // Inside the popover wrapper — disable IconButton's tooltip when the
114
+ // popover is open (the popover already shows controls; nesting Radix
115
+ // tooltip+popover content fights for focus/dismiss).
116
+ noTooltip={isOpen}
117
+ onClick={() => {
118
+ if (isTouch) {
119
+ setOpen((v) => !v);
120
+ return;
121
+ }
122
+ toggleMute();
123
+ setMuted(audio.muted);
124
+ }}
125
+ >
126
+ <Icon size={16} strokeWidth={1.75} />
127
+ </IconButton>
128
+ {isOpen && (
129
+ <div
130
+ className="absolute bottom-full left-1/2 z-20 -translate-x-1/2 pb-2"
131
+ onPointerEnter={isTouch ? undefined : open}
132
+ onPointerLeave={isTouch ? undefined : scheduleClose}
133
+ >
134
+ <div className="flex items-center gap-2 rounded-md border border-border/60 bg-card px-3 py-2 shadow-sm">
135
+ <input
136
+ type="range"
137
+ min={0}
138
+ max={1}
139
+ step={0.01}
140
+ value={muted ? 0 : volume}
141
+ onChange={(e) => {
142
+ const v = parseFloat(e.target.value);
143
+ setVolume(v);
144
+ setVol(v);
145
+ if (v > 0) setMuted(false);
146
+ }}
147
+ className="h-1 w-32 appearance-none rounded-full bg-muted accent-foreground"
148
+ aria-label="Volume"
149
+ />
150
+ <span className="w-8 tabular-nums text-right text-[10px] text-muted-foreground">
151
+ {Math.round((muted ? 0 : volume) * 100)}
152
+ </span>
153
+ {isTouch && (
154
+ <button
155
+ type="button"
156
+ aria-label={muted ? 'Unmute' : 'Mute'}
157
+ className="audioplayer-press grid h-7 w-7 place-items-center rounded text-muted-foreground hover:bg-accent"
158
+ onClick={() => {
159
+ toggleMute();
160
+ setMuted(audio.muted);
161
+ }}
162
+ >
163
+ {muted ? <VolumeX size={14} strokeWidth={1.75} /> : <Volume2 size={14} strokeWidth={1.75} />}
164
+ </button>
165
+ )}
166
+ </div>
167
+ </div>
168
+ )}
169
+ </div>
170
+ );
171
+ }
@@ -0,0 +1,6 @@
1
+ export { PlayButton } from './PlayButton';
2
+ export { SkipButton } from './SkipButton';
3
+ export { LoopButton } from './LoopButton';
4
+ export { VolumeControl } from './VolumeControl';
5
+ export { ControlsRow } from './ControlsRow';
6
+ export { IconButton } from './IconButton';
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CoverPlaceholder } from './CoverPlaceholder';
5
+
6
+ type Props = { src?: string; alt?: string; size: number };
7
+
8
+ export function Cover({ src, alt, size }: Props) {
9
+ const [errored, setErrored] = useState(false);
10
+ if (!src || errored) return <CoverPlaceholder size={size} />;
11
+ return (
12
+ <img
13
+ src={src}
14
+ alt={alt ?? ''}
15
+ width={size}
16
+ height={size}
17
+ loading="lazy"
18
+ decoding="async"
19
+ onError={() => setErrored(true)}
20
+ className="block rounded-md object-cover"
21
+ style={{ width: size, height: size }}
22
+ />
23
+ );
24
+ }
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ import { Music } from 'lucide-react';
4
+
5
+ type Props = { size: number };
6
+
7
+ export function CoverPlaceholder({ size }: Props) {
8
+ const inset = Math.max(4, Math.round(size * 0.14));
9
+ const iconSize = Math.round(size * 0.4);
10
+ return (
11
+ <div
12
+ className="relative grid place-items-center rounded-md bg-muted"
13
+ style={{ width: size, height: size }}
14
+ aria-hidden="true"
15
+ >
16
+ <span
17
+ className="absolute rounded-sm bg-muted-foreground/10"
18
+ style={{ inset }}
19
+ />
20
+ <Music
21
+ className="relative text-muted-foreground"
22
+ style={{ width: iconSize, height: iconSize }}
23
+ strokeWidth={1.5}
24
+ />
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+
3
+ // Optional `subtle` reactive cover. Reads levels from the imperative store,
4
+ // down-mixes to a single envelope, writes a CSS variable that drives a
5
+ // compositor-only scale. No box-shadow, no glow.
6
+
7
+ import { useEffect, useRef } from 'react';
8
+ import { usePlayerLevels } from '../../context/selectors';
9
+ import type { ReactNode } from 'react';
10
+
11
+ type Props = { enabled: boolean; children: ReactNode };
12
+
13
+ const VAR = '--audioplayer-pulse';
14
+ const MAX_SCALE = 0.03;
15
+ const SMOOTH = 0.18;
16
+
17
+ export function ReactivePulse({ enabled, children }: Props) {
18
+ const ref = useRef<HTMLDivElement | null>(null);
19
+ const store = usePlayerLevels();
20
+
21
+ useEffect(() => {
22
+ if (!enabled) {
23
+ ref.current?.style.setProperty(VAR, '1');
24
+ return;
25
+ }
26
+ const el = ref.current;
27
+ if (!el) return;
28
+
29
+ if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
30
+ el.style.setProperty(VAR, '1');
31
+ return;
32
+ }
33
+
34
+ let raf = 0;
35
+ let env = 0;
36
+ const tick = () => {
37
+ const buf = store.getCurrent();
38
+ let energy = 0;
39
+ const usable = Math.min(buf.length, 32);
40
+ if (usable > 0) {
41
+ for (let i = 0; i < usable; i++) energy += buf[i];
42
+ energy /= usable;
43
+ }
44
+ env = env + (energy - env) * SMOOTH;
45
+ const scale = 1 + Math.min(MAX_SCALE, env * MAX_SCALE * 1.5);
46
+ el.style.setProperty(VAR, scale.toFixed(4));
47
+ raf = requestAnimationFrame(tick);
48
+ };
49
+ raf = requestAnimationFrame(tick);
50
+ return () => cancelAnimationFrame(raf);
51
+ }, [enabled, store]);
52
+
53
+ return (
54
+ <div
55
+ ref={ref}
56
+ className="audioplayer-pulse"
57
+ style={{
58
+ transform: 'scale(var(--audioplayer-pulse, 1))',
59
+ transformOrigin: 'center',
60
+ willChange: enabled ? 'transform' : undefined,
61
+ }}
62
+ >
63
+ {children}
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,3 @@
1
+ export { Cover } from './Cover';
2
+ export { CoverPlaceholder } from './CoverPlaceholder';
3
+ export { ReactivePulse } from './ReactivePulse';
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { AlertTriangle, RotateCcw } from 'lucide-react';
4
+ import { usePlayerControls, usePlayerState } from '../../context/selectors';
5
+
6
+ const REASONS: Record<string, string> = {
7
+ network: 'Network error while loading audio.',
8
+ decode: "We can't decode this audio.",
9
+ unsupported: 'This audio format is not supported.',
10
+ unknown: 'Audio playback failed.',
11
+ };
12
+
13
+ export function ErrorState() {
14
+ const state = usePlayerState();
15
+ const { play } = usePlayerControls();
16
+ if (state.kind !== 'error') return null;
17
+ const message = REASONS[state.reason] ?? REASONS.unknown;
18
+ return (
19
+ <div
20
+ role="alert"
21
+ className="flex items-center gap-3 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive"
22
+ >
23
+ <AlertTriangle size={14} strokeWidth={1.75} />
24
+ <span className="flex-1">{message}</span>
25
+ <button
26
+ type="button"
27
+ onClick={() => void play()}
28
+ className="audioplayer-press inline-flex items-center gap-1 rounded-md px-2 py-1 text-destructive hover:bg-destructive/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40"
29
+ >
30
+ <RotateCcw size={12} strokeWidth={2} />
31
+ Retry
32
+ </button>
33
+ </div>
34
+ );
35
+ }