@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Pointer interaction for the waveform: click-to-seek, drag-to-scrub,
|
|
2
|
+
// hover indicator + tooltip with time-at-cursor. CSS variables only — no
|
|
3
|
+
// React state, no canvas redraws.
|
|
4
|
+
|
|
5
|
+
import { formatTime } from '../../utils/formatTime';
|
|
6
|
+
|
|
7
|
+
const HOVER_X = '--hp';
|
|
8
|
+
const HOVER_OPACITY = '--ho';
|
|
9
|
+
const TOOLTIP_LABEL = '--ht';
|
|
10
|
+
|
|
11
|
+
export type AttachSeekOptions = {
|
|
12
|
+
// When the user clicks while paused/idle, also start playback. Drag scrub
|
|
13
|
+
// never auto-starts (already playing or user is scrubbing without intent).
|
|
14
|
+
startsPlayback?: boolean;
|
|
15
|
+
onPlayRequest?: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function attachSeek(
|
|
19
|
+
container: HTMLElement,
|
|
20
|
+
audio: HTMLAudioElement,
|
|
21
|
+
options: AttachSeekOptions = {},
|
|
22
|
+
): () => void {
|
|
23
|
+
let dragging = false;
|
|
24
|
+
let movedDuringDrag = false;
|
|
25
|
+
const { startsPlayback = true, onPlayRequest } = options;
|
|
26
|
+
|
|
27
|
+
const ratioFor = (clientX: number): number => {
|
|
28
|
+
const rect = container.getBoundingClientRect();
|
|
29
|
+
if (rect.width === 0) return 0;
|
|
30
|
+
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const seekTo = (clientX: number) => {
|
|
34
|
+
const dur = Number.isFinite(audio.duration) ? audio.duration : 0;
|
|
35
|
+
if (dur > 0) audio.currentTime = ratioFor(clientX) * dur;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
39
|
+
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
40
|
+
dragging = true;
|
|
41
|
+
movedDuringDrag = false;
|
|
42
|
+
container.setPointerCapture?.(e.pointerId);
|
|
43
|
+
seekTo(e.clientX);
|
|
44
|
+
};
|
|
45
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
46
|
+
if (!dragging) return;
|
|
47
|
+
movedDuringDrag = true;
|
|
48
|
+
seekTo(e.clientX);
|
|
49
|
+
};
|
|
50
|
+
const onPointerEnd = (e: PointerEvent) => {
|
|
51
|
+
if (!dragging) return;
|
|
52
|
+
const wasDrag = movedDuringDrag;
|
|
53
|
+
dragging = false;
|
|
54
|
+
movedDuringDrag = false;
|
|
55
|
+
try {
|
|
56
|
+
container.releasePointerCapture?.(e.pointerId);
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore
|
|
59
|
+
}
|
|
60
|
+
// Click (not drag) while paused → start playback at the seek target.
|
|
61
|
+
if (!wasDrag && startsPlayback && (audio.paused || audio.ended)) {
|
|
62
|
+
onPlayRequest?.();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
container.addEventListener('pointerdown', onPointerDown);
|
|
67
|
+
container.addEventListener('pointermove', onPointerMove);
|
|
68
|
+
container.addEventListener('pointerup', onPointerEnd);
|
|
69
|
+
container.addEventListener('pointercancel', onPointerEnd);
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
container.removeEventListener('pointerdown', onPointerDown);
|
|
73
|
+
container.removeEventListener('pointermove', onPointerMove);
|
|
74
|
+
container.removeEventListener('pointerup', onPointerEnd);
|
|
75
|
+
container.removeEventListener('pointercancel', onPointerEnd);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function attachHover(container: HTMLElement, audio: HTMLAudioElement): () => void {
|
|
80
|
+
const tooltip = container.querySelector<HTMLElement>('[data-audioplayer-time-tip]');
|
|
81
|
+
|
|
82
|
+
const onMove = (e: PointerEvent) => {
|
|
83
|
+
const rect = container.getBoundingClientRect();
|
|
84
|
+
if (rect.width === 0) return;
|
|
85
|
+
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
|
|
86
|
+
container.style.setProperty(HOVER_X, `${x}px`);
|
|
87
|
+
container.style.setProperty(HOVER_OPACITY, '1');
|
|
88
|
+
if (tooltip) {
|
|
89
|
+
const dur = Number.isFinite(audio.duration) ? audio.duration : 0;
|
|
90
|
+
const t = (x / rect.width) * dur;
|
|
91
|
+
tooltip.textContent = formatTime(t);
|
|
92
|
+
tooltip.style.setProperty(TOOLTIP_LABEL, '1');
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const onLeave = () => {
|
|
96
|
+
container.style.setProperty(HOVER_OPACITY, '0');
|
|
97
|
+
if (tooltip) tooltip.style.setProperty(TOOLTIP_LABEL, '0');
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
container.addEventListener('pointermove', onMove);
|
|
101
|
+
container.addEventListener('pointerleave', onLeave);
|
|
102
|
+
return () => {
|
|
103
|
+
container.removeEventListener('pointermove', onMove);
|
|
104
|
+
container.removeEventListener('pointerleave', onLeave);
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Pure canvas painters. No React, no DOM events. Re-painted only when peaks
|
|
2
|
+
// change, the container resizes, or theme tokens change. See ADR-003.
|
|
3
|
+
|
|
4
|
+
import { backingHeight, backingWidth, getDpr } from '../../utils/dpr';
|
|
5
|
+
|
|
6
|
+
export type PaintPeaksOptions = {
|
|
7
|
+
color: string;
|
|
8
|
+
background?: string;
|
|
9
|
+
barWidth: number;
|
|
10
|
+
barGap: number;
|
|
11
|
+
minBarHeight?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function resizeCanvas(canvas: HTMLCanvasElement): { ctx: CanvasRenderingContext2D; cssW: number; cssH: number } | null {
|
|
15
|
+
const cssW = canvas.clientWidth;
|
|
16
|
+
const cssH = canvas.clientHeight;
|
|
17
|
+
if (cssW === 0 || cssH === 0) return null;
|
|
18
|
+
const dpr = getDpr();
|
|
19
|
+
const w = backingWidth(cssW, dpr);
|
|
20
|
+
const h = backingHeight(cssH, dpr);
|
|
21
|
+
if (canvas.width !== w) canvas.width = w;
|
|
22
|
+
if (canvas.height !== h) canvas.height = h;
|
|
23
|
+
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
|
24
|
+
if (!ctx) return null;
|
|
25
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
26
|
+
return { ctx, cssW, cssH };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function paintPeaks(
|
|
30
|
+
canvas: HTMLCanvasElement,
|
|
31
|
+
peaks: Float32Array,
|
|
32
|
+
opts: PaintPeaksOptions,
|
|
33
|
+
): void {
|
|
34
|
+
const sized = resizeCanvas(canvas);
|
|
35
|
+
if (!sized) return;
|
|
36
|
+
const { ctx, cssW, cssH } = sized;
|
|
37
|
+
ctx.clearRect(0, 0, cssW, cssH);
|
|
38
|
+
if (opts.background) {
|
|
39
|
+
ctx.fillStyle = opts.background;
|
|
40
|
+
ctx.fillRect(0, 0, cssW, cssH);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (peaks.length === 0) return;
|
|
44
|
+
const step = Math.max(1, opts.barWidth + opts.barGap);
|
|
45
|
+
const numBars = Math.max(1, Math.floor(cssW / step));
|
|
46
|
+
const mid = cssH / 2;
|
|
47
|
+
const minH = opts.minBarHeight ?? 1;
|
|
48
|
+
|
|
49
|
+
ctx.fillStyle = opts.color;
|
|
50
|
+
for (let i = 0; i < numBars; i++) {
|
|
51
|
+
const peakIdx = Math.min(peaks.length - 1, Math.floor((i / numBars) * peaks.length));
|
|
52
|
+
const amp = peaks[peakIdx];
|
|
53
|
+
const h = Math.max(minH, amp * cssH);
|
|
54
|
+
const x = i * step;
|
|
55
|
+
ctx.fillRect(x, mid - h / 2, opts.barWidth, h);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type PaintLiveOptions = {
|
|
60
|
+
color: string;
|
|
61
|
+
barWidth: number;
|
|
62
|
+
barGap: number;
|
|
63
|
+
minBarHeight?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function paintLive(
|
|
67
|
+
canvas: HTMLCanvasElement,
|
|
68
|
+
levels: Float32Array,
|
|
69
|
+
opts: PaintLiveOptions,
|
|
70
|
+
): void {
|
|
71
|
+
const sized = resizeCanvas(canvas);
|
|
72
|
+
if (!sized) return;
|
|
73
|
+
const { ctx, cssW, cssH } = sized;
|
|
74
|
+
ctx.clearRect(0, 0, cssW, cssH);
|
|
75
|
+
if (levels.length === 0) return;
|
|
76
|
+
|
|
77
|
+
const step = Math.max(1, opts.barWidth + opts.barGap);
|
|
78
|
+
const numBars = Math.max(1, Math.floor(cssW / step));
|
|
79
|
+
const mid = cssH / 2;
|
|
80
|
+
const minH = opts.minBarHeight ?? 1;
|
|
81
|
+
// Concentrate visible energy in the lower frequency bands.
|
|
82
|
+
const usable = Math.floor(levels.length * 0.7);
|
|
83
|
+
|
|
84
|
+
ctx.fillStyle = opts.color;
|
|
85
|
+
for (let i = 0; i < numBars; i++) {
|
|
86
|
+
const idx = Math.min(usable - 1, Math.floor((i / numBars) * usable));
|
|
87
|
+
const v = levels[idx] ?? 0;
|
|
88
|
+
const h = Math.max(minH, v * cssH);
|
|
89
|
+
ctx.fillRect(i * step, mid - h / 2, opts.barWidth, h);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Single-active-player coordination, with optional cross-tab sync.
|
|
2
|
+
// Each Player instance registers an id and a `pause()` callback. When another
|
|
3
|
+
// player becomes active, all others get paused. Cross-tab via BroadcastChannel.
|
|
4
|
+
|
|
5
|
+
const CHANNEL = 'djangocfg-audioplayer:active';
|
|
6
|
+
|
|
7
|
+
type Listener = (activeId: string | null) => void;
|
|
8
|
+
|
|
9
|
+
let activeId: string | null = null;
|
|
10
|
+
let lastActiveId: string | null = null;
|
|
11
|
+
const listeners = new Set<Listener>();
|
|
12
|
+
const pausers = new Map<string, () => void>();
|
|
13
|
+
|
|
14
|
+
let channel: BroadcastChannel | null = null;
|
|
15
|
+
function getChannel(): BroadcastChannel | null {
|
|
16
|
+
if (typeof BroadcastChannel === 'undefined') return null;
|
|
17
|
+
if (channel) return channel;
|
|
18
|
+
channel = new BroadcastChannel(CHANNEL);
|
|
19
|
+
channel.addEventListener('message', (e) => {
|
|
20
|
+
const next = typeof e.data === 'string' ? e.data : null;
|
|
21
|
+
if (next === activeId) return;
|
|
22
|
+
activeId = next;
|
|
23
|
+
if (next) lastActiveId = next;
|
|
24
|
+
for (const [id, pause] of pausers) {
|
|
25
|
+
if (id !== next) pause();
|
|
26
|
+
}
|
|
27
|
+
for (const l of listeners) l(next);
|
|
28
|
+
});
|
|
29
|
+
return channel;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function registerPlayer(id: string, pause: () => void): () => void {
|
|
33
|
+
pausers.set(id, pause);
|
|
34
|
+
return () => {
|
|
35
|
+
pausers.delete(id);
|
|
36
|
+
if (activeId === id) activeId = null;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setActivePlayer(id: string | null): void {
|
|
41
|
+
if (activeId === id) return;
|
|
42
|
+
activeId = id;
|
|
43
|
+
if (id) lastActiveId = id;
|
|
44
|
+
for (const [pid, pause] of pausers) {
|
|
45
|
+
if (pid !== id) pause();
|
|
46
|
+
}
|
|
47
|
+
for (const l of listeners) l(id);
|
|
48
|
+
getChannel()?.postMessage(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getActivePlayer(): string | null {
|
|
52
|
+
return activeId;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getLastActivePlayer(): string | null {
|
|
56
|
+
return lastActiveId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function subscribeActivePlayer(cb: Listener): () => void {
|
|
60
|
+
listeners.add(cb);
|
|
61
|
+
getChannel(); // ensure channel hot
|
|
62
|
+
return () => listeners.delete(cb);
|
|
63
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Imperative store for high-frequency audio levels.
|
|
2
|
+
// React never re-renders on `set()`; consumers read via getCurrent() in their
|
|
3
|
+
// own rAF (typically the live waveform canvas). See ADR-001 §LevelsCtx.
|
|
4
|
+
|
|
5
|
+
export type LevelsStore = {
|
|
6
|
+
subscribe: (cb: () => void) => () => void;
|
|
7
|
+
getCurrent: () => Float32Array;
|
|
8
|
+
set: (next: Float32Array) => void;
|
|
9
|
+
setActive: (active: boolean) => void;
|
|
10
|
+
isActive: () => boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createLevelsStore(initial?: Float32Array): LevelsStore {
|
|
14
|
+
let current = initial ?? new Float32Array(0);
|
|
15
|
+
let active = false;
|
|
16
|
+
const listeners = new Set<() => void>();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
subscribe(cb) {
|
|
20
|
+
listeners.add(cb);
|
|
21
|
+
return () => listeners.delete(cb);
|
|
22
|
+
},
|
|
23
|
+
getCurrent() {
|
|
24
|
+
return current;
|
|
25
|
+
},
|
|
26
|
+
set(next) {
|
|
27
|
+
current = next;
|
|
28
|
+
for (const cb of listeners) cb();
|
|
29
|
+
},
|
|
30
|
+
setActive(value) {
|
|
31
|
+
active = value;
|
|
32
|
+
},
|
|
33
|
+
isActive() {
|
|
34
|
+
return active;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { createLevelsStore } from './createLevelsStore';
|
|
2
|
+
export type { LevelsStore } from './createLevelsStore';
|
|
3
|
+
export {
|
|
4
|
+
registerPlayer,
|
|
5
|
+
setActivePlayer,
|
|
6
|
+
getActivePlayer,
|
|
7
|
+
getLastActivePlayer,
|
|
8
|
+
subscribeActivePlayer,
|
|
9
|
+
} from './activePlayerBus';
|
|
10
|
+
export {
|
|
11
|
+
getPreferences,
|
|
12
|
+
setStoredVolume,
|
|
13
|
+
setStoredMuted,
|
|
14
|
+
subscribePreferences,
|
|
15
|
+
} from './preferencesStore';
|
|
16
|
+
export type { PlayerPreferences } from './preferencesStore';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Persistent user preferences (volume, muted). Single localStorage entry,
|
|
2
|
+
// cross-tab sync via the native `storage` event. SSR-safe.
|
|
3
|
+
//
|
|
4
|
+
// Why not Zustand: two fields. Adding a dep + persist middleware is overkill
|
|
5
|
+
// for ~50 LOC of plain code that already matches the activePlayerBus pattern.
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'djangocfg-audioplayer:prefs';
|
|
8
|
+
const DEFAULT_VOLUME = 1;
|
|
9
|
+
const DEFAULT_MUTED = false;
|
|
10
|
+
|
|
11
|
+
export type PlayerPreferences = {
|
|
12
|
+
volume: number;
|
|
13
|
+
muted: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Listener = (prefs: PlayerPreferences) => void;
|
|
17
|
+
|
|
18
|
+
let cached: PlayerPreferences | null = null;
|
|
19
|
+
const listeners = new Set<Listener>();
|
|
20
|
+
let storageBound = false;
|
|
21
|
+
|
|
22
|
+
function clamp01(v: number): number {
|
|
23
|
+
if (!Number.isFinite(v)) return DEFAULT_VOLUME;
|
|
24
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readFromStorage(): PlayerPreferences {
|
|
28
|
+
if (typeof window === 'undefined') {
|
|
29
|
+
return { volume: DEFAULT_VOLUME, muted: DEFAULT_MUTED };
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
33
|
+
if (!raw) return { volume: DEFAULT_VOLUME, muted: DEFAULT_MUTED };
|
|
34
|
+
const parsed = JSON.parse(raw) as Partial<PlayerPreferences> | null;
|
|
35
|
+
return {
|
|
36
|
+
volume: typeof parsed?.volume === 'number' ? clamp01(parsed.volume) : DEFAULT_VOLUME,
|
|
37
|
+
muted: typeof parsed?.muted === 'boolean' ? parsed.muted : DEFAULT_MUTED,
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
return { volume: DEFAULT_VOLUME, muted: DEFAULT_MUTED };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeToStorage(prefs: PlayerPreferences): void {
|
|
45
|
+
if (typeof window === 'undefined') return;
|
|
46
|
+
try {
|
|
47
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
|
48
|
+
} catch {
|
|
49
|
+
// quota exceeded / disabled — silently ignore.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function bindStorage(): void {
|
|
54
|
+
if (storageBound || typeof window === 'undefined') return;
|
|
55
|
+
storageBound = true;
|
|
56
|
+
window.addEventListener('storage', (e) => {
|
|
57
|
+
if (e.key !== STORAGE_KEY) return;
|
|
58
|
+
cached = readFromStorage();
|
|
59
|
+
for (const cb of listeners) cb(cached);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getPreferences(): PlayerPreferences {
|
|
64
|
+
if (!cached) cached = readFromStorage();
|
|
65
|
+
bindStorage();
|
|
66
|
+
return cached;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function update(next: PlayerPreferences, persist = true): void {
|
|
70
|
+
cached = next;
|
|
71
|
+
if (persist) writeToStorage(next);
|
|
72
|
+
for (const cb of listeners) cb(next);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function setStoredVolume(volume: number): void {
|
|
76
|
+
const current = getPreferences();
|
|
77
|
+
const v = clamp01(volume);
|
|
78
|
+
if (v === current.volume) return;
|
|
79
|
+
update({ ...current, volume: v });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function setStoredMuted(muted: boolean): void {
|
|
83
|
+
const current = getPreferences();
|
|
84
|
+
if (muted === current.muted) return;
|
|
85
|
+
update({ ...current, muted });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function subscribePreferences(cb: Listener): () => void {
|
|
89
|
+
listeners.add(cb);
|
|
90
|
+
bindStorage();
|
|
91
|
+
return () => listeners.delete(cb);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Test-only.
|
|
95
|
+
export function _resetPreferencesForTesting(): void {
|
|
96
|
+
cached = null;
|
|
97
|
+
if (typeof window !== 'undefined') {
|
|
98
|
+
try {
|
|
99
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* AudioPlayer v6 — WebView-safe baseline. Compositor-only animations.
|
|
2
|
+
* Kept under 80 LOC per folder-structure budget.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
.audioplayer-press {
|
|
6
|
+
transition: transform 80ms cubic-bezier(0.4, 0, 0.6, 1);
|
|
7
|
+
}
|
|
8
|
+
.audioplayer-press:active {
|
|
9
|
+
transform: scale(0.97);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.audioplayer-shimmer {
|
|
13
|
+
position: relative;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
}
|
|
16
|
+
.audioplayer-shimmer::after {
|
|
17
|
+
content: '';
|
|
18
|
+
position: absolute;
|
|
19
|
+
inset: 0;
|
|
20
|
+
background: linear-gradient(
|
|
21
|
+
90deg,
|
|
22
|
+
transparent 0%,
|
|
23
|
+
color-mix(in oklab, var(--color-foreground, #fff) 8%, transparent) 50%,
|
|
24
|
+
transparent 100%
|
|
25
|
+
);
|
|
26
|
+
background-size: 200% 100%;
|
|
27
|
+
animation: audioplayer-shimmer 1400ms linear infinite;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@keyframes audioplayer-shimmer {
|
|
31
|
+
from {
|
|
32
|
+
background-position: 200% 0;
|
|
33
|
+
}
|
|
34
|
+
to {
|
|
35
|
+
background-position: -200% 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@keyframes audioplayer-bar {
|
|
40
|
+
0%, 100% {
|
|
41
|
+
height: 25%;
|
|
42
|
+
}
|
|
43
|
+
50% {
|
|
44
|
+
height: 95%;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@media (prefers-reduced-motion: reduce) {
|
|
49
|
+
.audioplayer-press,
|
|
50
|
+
.audioplayer-shimmer::after,
|
|
51
|
+
.audioplayer-bars span {
|
|
52
|
+
animation: none !important;
|
|
53
|
+
transition: none !important;
|
|
54
|
+
transform: none !important;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.audioplayer canvas {
|
|
59
|
+
image-rendering: auto;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* No hover on touch — also no time-tooltip nor hover-cursor on the waveform.
|
|
63
|
+
* Avoids a stuck tip after a tap-and-release. */
|
|
64
|
+
@media (hover: none), (pointer: coarse) {
|
|
65
|
+
.audioplayer .audioplayer-tip,
|
|
66
|
+
.audioplayer .audioplayer-hover {
|
|
67
|
+
display: none !important;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Bigger tap targets on touch + small viewports (Apple HIG ≥ 44px). */
|
|
72
|
+
@media (pointer: coarse) {
|
|
73
|
+
.audioplayer .audioplayer-press[aria-label] {
|
|
74
|
+
min-width: 40px;
|
|
75
|
+
min-height: 40px;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Public types for the AudioPlayer v6.
|
|
2
|
+
// See packages/ui-tools/@dev/@refactoring6-audioplayer/04-implementation/api.md
|
|
3
|
+
|
|
4
|
+
export type WaveformMode = 'peaks' | 'live' | 'bars' | 'progress' | 'none';
|
|
5
|
+
|
|
6
|
+
export type WaveformConfig = {
|
|
7
|
+
mode?: WaveformMode;
|
|
8
|
+
peaks?: Float32Array;
|
|
9
|
+
height?: number;
|
|
10
|
+
barWidth?: number;
|
|
11
|
+
barGap?: number;
|
|
12
|
+
bgColor?: string;
|
|
13
|
+
fgColor?: string;
|
|
14
|
+
decodeOnMount?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ReactiveCoverMode = false | 'subtle';
|
|
18
|
+
export type PlayerVariant = 'default' | 'compact' | 'auto';
|
|
19
|
+
export type PlayerErrorReason = 'network' | 'decode' | 'unsupported' | 'unknown';
|
|
20
|
+
|
|
21
|
+
export type PlayerProps = {
|
|
22
|
+
src: string;
|
|
23
|
+
peaks?: Float32Array;
|
|
24
|
+
|
|
25
|
+
title?: string;
|
|
26
|
+
artist?: string;
|
|
27
|
+
album?: string;
|
|
28
|
+
cover?: string;
|
|
29
|
+
|
|
30
|
+
variant?: PlayerVariant;
|
|
31
|
+
className?: string;
|
|
32
|
+
|
|
33
|
+
waveform?: WaveformConfig;
|
|
34
|
+
reactiveCover?: ReactiveCoverMode;
|
|
35
|
+
|
|
36
|
+
autoplay?: boolean;
|
|
37
|
+
loop?: boolean;
|
|
38
|
+
initialVolume?: number;
|
|
39
|
+
muted?: boolean;
|
|
40
|
+
preload?: 'none' | 'metadata' | 'auto';
|
|
41
|
+
exclusive?: boolean;
|
|
42
|
+
|
|
43
|
+
onPrev?: () => void;
|
|
44
|
+
onNext?: () => void;
|
|
45
|
+
|
|
46
|
+
onPlay?: () => void;
|
|
47
|
+
onPause?: () => void;
|
|
48
|
+
onEnded?: () => void;
|
|
49
|
+
onError?: (reason: PlayerErrorReason, e: unknown) => void;
|
|
50
|
+
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
|
51
|
+
|
|
52
|
+
ariaLabel?: string;
|
|
53
|
+
enableKeyboardShortcuts?: boolean;
|
|
54
|
+
|
|
55
|
+
// When the user clicks on the waveform while paused, also start playback.
|
|
56
|
+
// Default true — clicking on a time mark almost always means "play here".
|
|
57
|
+
// Set false for embeds where stray clicks shouldn't trigger sound.
|
|
58
|
+
seekStartsPlayback?: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type PlayerStateKind =
|
|
62
|
+
| 'idle'
|
|
63
|
+
| 'loading'
|
|
64
|
+
| 'decoding'
|
|
65
|
+
| 'ready'
|
|
66
|
+
| 'playing'
|
|
67
|
+
| 'paused'
|
|
68
|
+
| 'ended'
|
|
69
|
+
| 'error';
|
|
70
|
+
|
|
71
|
+
export type PlayerState =
|
|
72
|
+
| { kind: 'idle' | 'loading' }
|
|
73
|
+
| { kind: 'decoding'; duration: number }
|
|
74
|
+
| { kind: 'ready' | 'playing' | 'paused' | 'ended'; duration: number; peaks?: Float32Array }
|
|
75
|
+
| { kind: 'error'; reason: PlayerErrorReason; duration: number };
|
|
76
|
+
|
|
77
|
+
export type PlayerControls = {
|
|
78
|
+
play: () => Promise<void>;
|
|
79
|
+
pause: () => void;
|
|
80
|
+
toggle: () => Promise<void>;
|
|
81
|
+
seek: (seconds: number) => void;
|
|
82
|
+
seekTo: (ratio: number) => void;
|
|
83
|
+
setVolume: (v: number) => void;
|
|
84
|
+
toggleMute: () => void;
|
|
85
|
+
toggleLoop: () => void;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type PlayerHandle = {
|
|
89
|
+
audio: HTMLAudioElement;
|
|
90
|
+
play: () => Promise<void>;
|
|
91
|
+
pause: () => void;
|
|
92
|
+
seek: (seconds: number) => void;
|
|
93
|
+
getCurrentTime: () => number;
|
|
94
|
+
getDuration: () => number;
|
|
95
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Reduce an audio channel down to `buckets` peak amplitude samples in [0, 1].
|
|
2
|
+
// Used by decodePeaks; pure.
|
|
3
|
+
|
|
4
|
+
export function bucketize(channel: Float32Array, buckets: number): Float32Array {
|
|
5
|
+
const out = new Float32Array(buckets);
|
|
6
|
+
if (buckets <= 0 || channel.length === 0) return out;
|
|
7
|
+
const samplesPerBucket = Math.max(1, Math.floor(channel.length / buckets));
|
|
8
|
+
let peakMax = 0;
|
|
9
|
+
for (let b = 0; b < buckets; b++) {
|
|
10
|
+
const start = b * samplesPerBucket;
|
|
11
|
+
const end = Math.min(start + samplesPerBucket, channel.length);
|
|
12
|
+
let max = 0;
|
|
13
|
+
for (let i = start; i < end; i++) {
|
|
14
|
+
const v = channel[i];
|
|
15
|
+
const abs = v < 0 ? -v : v;
|
|
16
|
+
if (abs > max) max = abs;
|
|
17
|
+
}
|
|
18
|
+
out[b] = max;
|
|
19
|
+
if (max > peakMax) peakMax = max;
|
|
20
|
+
}
|
|
21
|
+
// Normalize so the loudest bucket reaches 1.0 — keeps quiet tracks visible.
|
|
22
|
+
if (peakMax > 0 && peakMax < 1) {
|
|
23
|
+
const scale = 1 / peakMax;
|
|
24
|
+
for (let i = 0; i < out.length; i++) out[i] *= scale;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Device pixel ratio helpers. Clamped to 2 to keep canvases under iOS 15 GPU
|
|
2
|
+
// process limits (research 01 §canvas).
|
|
3
|
+
|
|
4
|
+
const MAX_DPR = 2;
|
|
5
|
+
const MAX_BACKING_WIDTH = 2048;
|
|
6
|
+
|
|
7
|
+
export function getDpr(): number {
|
|
8
|
+
if (typeof window === 'undefined') return 1;
|
|
9
|
+
const dpr = window.devicePixelRatio ?? 1;
|
|
10
|
+
return Math.min(Math.max(dpr, 1), MAX_DPR);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function backingWidth(cssWidth: number, dpr = getDpr()): number {
|
|
14
|
+
return Math.min(Math.round(cssWidth * dpr), MAX_BACKING_WIDTH);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function backingHeight(cssHeight: number, dpr = getDpr()): number {
|
|
18
|
+
return Math.round(cssHeight * dpr);
|
|
19
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
* formatTime - Format seconds to mm:ss string
|
|
3
|
-
*/
|
|
4
|
-
|
|
1
|
+
// Formats seconds as `m:ss` or `h:mm:ss`. NaN/Infinity → "0:00".
|
|
5
2
|
export function formatTime(seconds: number): string {
|
|
6
|
-
if (!
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
3
|
+
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
|
4
|
+
const total = Math.floor(seconds);
|
|
5
|
+
const s = total % 60;
|
|
6
|
+
const m = Math.floor(total / 60) % 60;
|
|
7
|
+
const h = Math.floor(total / 3600);
|
|
8
|
+
const ss = s.toString().padStart(2, '0');
|
|
9
|
+
if (h > 0) {
|
|
10
|
+
const mm = m.toString().padStart(2, '0');
|
|
11
|
+
return `${h}:${mm}:${ss}`;
|
|
12
|
+
}
|
|
13
|
+
return `${m}:${ss}`;
|
|
10
14
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
* AudioPlayer utilities - Public API
|
|
3
|
-
*/
|
|
4
|
-
|
|
1
|
+
export { clamp } from './clamp';
|
|
5
2
|
export { formatTime } from './formatTime';
|
|
6
|
-
export {
|
|
3
|
+
export { bucketize } from './bucketize';
|
|
4
|
+
export { getDpr, backingWidth, backingHeight } from './dpr';
|
|
5
|
+
export { readCssVar } from './readCssVar';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Resolve a CSS custom property on an element via getComputedStyle.
|
|
2
|
+
// Returns the trimmed value, or `fallback` if undefined / empty.
|
|
3
|
+
export function readCssVar(el: Element, name: string, fallback = ''): string {
|
|
4
|
+
if (typeof window === 'undefined') return fallback;
|
|
5
|
+
const value = getComputedStyle(el).getPropertyValue(name).trim();
|
|
6
|
+
return value || fallback;
|
|
7
|
+
}
|