@djangocfg/ui-tools 2.1.310 → 2.1.313
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -22
- package/dist/{DocsLayout-W5JLRNSZ.mjs → DocsLayout-ESVQZO3V.mjs} +3 -3
- package/dist/{DocsLayout-W5JLRNSZ.mjs.map → DocsLayout-ESVQZO3V.mjs.map} +1 -1
- package/dist/{DocsLayout-ZXD2CUOH.cjs → DocsLayout-KUPDWJ3G.cjs} +48 -48
- package/dist/{DocsLayout-ZXD2CUOH.cjs.map → DocsLayout-KUPDWJ3G.cjs.map} +1 -1
- package/dist/Player-M3GC3VPE.mjs +4 -0
- package/dist/Player-M3GC3VPE.mjs.map +1 -0
- package/dist/Player-ZGQKKOWI.css +65 -0
- package/dist/Player-ZGQKKOWI.css.map +1 -0
- package/dist/Player-ZL2X5LGG.cjs +13 -0
- package/dist/Player-ZL2X5LGG.cjs.map +1 -0
- package/dist/{chunk-CXVGN6ZW.cjs → chunk-DFTVB66S.cjs} +7 -6
- package/dist/chunk-DFTVB66S.cjs.map +1 -0
- package/dist/{chunk-2QY3LJR6.mjs → chunk-EUADAUBQ.mjs} +5 -4
- package/dist/chunk-EUADAUBQ.mjs.map +1 -0
- package/dist/chunk-FX2QFYWF.mjs +2059 -0
- package/dist/chunk-FX2QFYWF.mjs.map +1 -0
- package/dist/{chunk-6HNAPVZ2.mjs → chunk-GBLQTHWT.mjs} +11 -13
- package/dist/chunk-GBLQTHWT.mjs.map +1 -0
- package/dist/{chunk-FYLR232K.cjs → chunk-S44PW6NK.cjs} +11 -13
- package/dist/chunk-S44PW6NK.cjs.map +1 -0
- package/dist/chunk-ZLQHUZDU.cjs +2061 -0
- package/dist/chunk-ZLQHUZDU.cjs.map +1 -0
- package/dist/components-WYEZL5TE.cjs +26 -0
- package/dist/{components-3RTH76CV.cjs.map → components-WYEZL5TE.cjs.map} +1 -1
- package/dist/components-ZAGG2PBO.mjs +5 -0
- package/dist/{components-5GVVL2Q6.mjs.map → components-ZAGG2PBO.mjs.map} +1 -1
- package/dist/index.cjs +36 -220
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +65 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +44 -500
- package/dist/index.d.ts +44 -500
- package/dist/index.mjs +16 -62
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/components/markdown/MarkdownMessage/ActionRow.tsx +48 -0
- package/src/components/markdown/MarkdownMessage/ChatMessageRow.tsx +97 -0
- package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +9 -13
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +77 -2
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +2 -3
- package/src/components/markdown/MarkdownMessage/README.md +72 -0
- package/src/components/markdown/MarkdownMessage/components.tsx +3 -3
- package/src/components/markdown/MarkdownMessage/index.ts +6 -0
- package/src/index.ts +2 -11
- package/src/tools/AudioPlayer/AudioPlayer.story.tsx +454 -107
- package/src/tools/AudioPlayer/Player.tsx +80 -0
- package/src/tools/AudioPlayer/PlayerShell.tsx +122 -0
- package/src/tools/AudioPlayer/README.md +139 -204
- package/src/tools/AudioPlayer/audio/audioContext.ts +39 -0
- package/src/tools/AudioPlayer/audio/decodePeaks.ts +36 -0
- package/src/tools/AudioPlayer/audio/index.ts +4 -0
- package/src/tools/AudioPlayer/audio/mediaElementSourceCache.ts +20 -0
- package/src/tools/AudioPlayer/audio/peaksCache.ts +37 -0
- package/src/tools/AudioPlayer/context/AudioRefContext.tsx +9 -0
- package/src/tools/AudioPlayer/context/ControlsContext.tsx +7 -0
- package/src/tools/AudioPlayer/context/LevelsContext.tsx +7 -0
- package/src/tools/AudioPlayer/context/MetaContext.tsx +16 -0
- package/src/tools/AudioPlayer/context/PlayerProvider.tsx +314 -0
- package/src/tools/AudioPlayer/context/StateContext.tsx +7 -0
- package/src/tools/AudioPlayer/context/index.ts +16 -15
- package/src/tools/AudioPlayer/context/selectors.ts +36 -0
- package/src/tools/AudioPlayer/hooks/index.ts +12 -39
- package/src/tools/AudioPlayer/hooks/useActivePlayer.ts +31 -0
- package/src/tools/AudioPlayer/hooks/useAnalyser.ts +62 -0
- package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +102 -0
- package/src/tools/AudioPlayer/hooks/useKeyboardShortcuts.ts +91 -0
- package/src/tools/AudioPlayer/hooks/useMediaSession.ts +74 -0
- package/src/tools/AudioPlayer/hooks/usePeaks.ts +83 -0
- package/src/tools/AudioPlayer/hooks/usePlayerPreferences.ts +21 -0
- package/src/tools/AudioPlayer/hooks/usePlayheadLoop.ts +77 -0
- package/src/tools/AudioPlayer/hooks/useResizeObserver.ts +20 -0
- package/src/tools/AudioPlayer/hooks/useThemeWatcher.ts +22 -0
- package/src/tools/AudioPlayer/index.ts +63 -134
- package/src/tools/AudioPlayer/lazy.tsx +8 -97
- package/src/tools/AudioPlayer/parts/Controls/ControlsRow.tsx +30 -0
- package/src/tools/AudioPlayer/parts/Controls/IconButton.tsx +62 -0
- package/src/tools/AudioPlayer/parts/Controls/LoopButton.tsx +33 -0
- package/src/tools/AudioPlayer/parts/Controls/PlayButton.tsx +86 -0
- package/src/tools/AudioPlayer/parts/Controls/SkipButton.tsx +17 -0
- package/src/tools/AudioPlayer/parts/Controls/VolumeControl.tsx +171 -0
- package/src/tools/AudioPlayer/parts/Controls/index.ts +6 -0
- package/src/tools/AudioPlayer/parts/Cover/Cover.tsx +24 -0
- package/src/tools/AudioPlayer/parts/Cover/CoverPlaceholder.tsx +27 -0
- package/src/tools/AudioPlayer/parts/Cover/ReactivePulse.tsx +66 -0
- package/src/tools/AudioPlayer/parts/Cover/index.ts +3 -0
- package/src/tools/AudioPlayer/parts/ErrorState/ErrorState.tsx +35 -0
- package/src/tools/AudioPlayer/parts/ErrorState/index.ts +1 -0
- package/src/tools/AudioPlayer/parts/Layout/CompactLayout.tsx +25 -0
- package/src/tools/AudioPlayer/parts/Layout/DefaultLayout.tsx +48 -0
- package/src/tools/AudioPlayer/parts/Layout/index.ts +2 -0
- package/src/tools/AudioPlayer/parts/Meta/Artist.tsx +14 -0
- package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +49 -0
- package/src/tools/AudioPlayer/parts/Meta/Title.tsx +13 -0
- package/src/tools/AudioPlayer/parts/Meta/index.ts +3 -0
- package/src/tools/AudioPlayer/parts/Skeleton/CoverSkeleton.tsx +13 -0
- package/src/tools/AudioPlayer/parts/Skeleton/MetaSkeleton.tsx +10 -0
- package/src/tools/AudioPlayer/parts/Skeleton/index.ts +2 -0
- package/src/tools/AudioPlayer/parts/Waveform/BarsWaveform.tsx +48 -0
- package/src/tools/AudioPlayer/parts/Waveform/LiveWaveform.tsx +95 -0
- package/src/tools/AudioPlayer/parts/Waveform/PeaksWaveform.tsx +100 -0
- package/src/tools/AudioPlayer/parts/Waveform/ProgressBar.tsx +76 -0
- package/src/tools/AudioPlayer/parts/Waveform/Waveform.tsx +74 -0
- package/src/tools/AudioPlayer/parts/Waveform/WaveformSkeleton.tsx +16 -0
- package/src/tools/AudioPlayer/parts/Waveform/index.ts +8 -0
- package/src/tools/AudioPlayer/parts/Waveform/waveformInteraction.ts +106 -0
- package/src/tools/AudioPlayer/parts/Waveform/waveformRenderer.ts +91 -0
- package/src/tools/AudioPlayer/parts/index.ts +1 -0
- package/src/tools/AudioPlayer/store/activePlayerBus.ts +63 -0
- package/src/tools/AudioPlayer/store/createLevelsStore.ts +37 -0
- package/src/tools/AudioPlayer/store/index.ts +16 -0
- package/src/tools/AudioPlayer/store/preferencesStore.ts +104 -0
- package/src/tools/AudioPlayer/styles/webview-safe.css +77 -0
- package/src/tools/AudioPlayer/types.ts +95 -0
- package/src/tools/AudioPlayer/utils/bucketize.ts +27 -0
- package/src/tools/AudioPlayer/utils/clamp.ts +5 -0
- package/src/tools/AudioPlayer/utils/dpr.ts +19 -0
- package/src/tools/AudioPlayer/utils/formatTime.ts +12 -8
- package/src/tools/AudioPlayer/utils/index.ts +4 -5
- package/src/tools/AudioPlayer/utils/readCssVar.ts +7 -0
- package/src/tools/AudioPlayer/utils/resolveCanvasColor.ts +28 -0
- package/src/tools/index.ts +5 -75
- package/dist/chunk-2QY3LJR6.mjs.map +0 -1
- package/dist/chunk-6HNAPVZ2.mjs.map +0 -1
- package/dist/chunk-CXVGN6ZW.cjs.map +0 -1
- package/dist/chunk-F2N7P5XU.cjs +0 -30
- package/dist/chunk-F2N7P5XU.cjs.map +0 -1
- package/dist/chunk-FYLR232K.cjs.map +0 -1
- package/dist/chunk-HMHIVEMS.mjs +0 -1619
- package/dist/chunk-HMHIVEMS.mjs.map +0 -1
- package/dist/chunk-JWB2EWQO.mjs +0 -5
- package/dist/chunk-JWB2EWQO.mjs.map +0 -1
- package/dist/chunk-YZX6FH3H.cjs +0 -1656
- package/dist/chunk-YZX6FH3H.cjs.map +0 -1
- package/dist/components-3RTH76CV.cjs +0 -27
- package/dist/components-5GVVL2Q6.mjs +0 -5
- package/dist/components-CPHOUQ5F.cjs +0 -46
- package/dist/components-CPHOUQ5F.cjs.map +0 -1
- package/dist/components-OTK43IMD.mjs +0 -6
- package/dist/components-OTK43IMD.mjs.map +0 -1
- package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +0 -225
- package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +0 -163
- package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +0 -284
- package/src/tools/AudioPlayer/components/HybridWaveform.tsx +0 -286
- package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +0 -151
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +0 -110
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +0 -58
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +0 -45
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +0 -82
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +0 -8
- package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +0 -6
- package/src/tools/AudioPlayer/components/index.ts +0 -23
- package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +0 -158
- package/src/tools/AudioPlayer/effects/index.ts +0 -412
- package/src/tools/AudioPlayer/hooks/useAudioBus.ts +0 -76
- package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +0 -403
- package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +0 -96
- package/src/tools/AudioPlayer/hooks/useVisualization.tsx +0 -207
- package/src/tools/AudioPlayer/types/effects.ts +0 -73
- package/src/tools/AudioPlayer/types/index.ts +0 -27
- package/src/tools/AudioPlayer/utils/debug.ts +0 -14
|
@@ -1,105 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
14
|
-
import
|
|
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: '
|
|
9
|
+
displayName: 'LazyAudioPlayer',
|
|
97
10
|
fallback: (
|
|
98
|
-
<div className="
|
|
99
|
-
|
|
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,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
|
+
}
|