@djangocfg/ui-tools 2.1.312 → 2.1.313
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -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,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,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,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';
|