@fanvue/ui 3.15.0 → 3.17.0
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/dist/cjs/components/AudioPlayer/AudioPlayer.cjs +16 -89
- package/dist/cjs/components/AudioPlayer/AudioPlayer.cjs.map +1 -1
- package/dist/cjs/components/AudioRecordButton/AudioRecordButton.cjs +54 -0
- package/dist/cjs/components/AudioRecordButton/AudioRecordButton.cjs.map +1 -0
- package/dist/cjs/components/AudioUpload/AudioUpload.cjs +5 -5
- package/dist/cjs/components/AudioUpload/AudioUpload.cjs.map +1 -1
- package/dist/cjs/components/ChatInput/ChatInput.cjs +6 -11
- package/dist/cjs/components/ChatInput/ChatInput.cjs.map +1 -1
- package/dist/cjs/components/ChatMessage/ChatMessage.cjs +228 -0
- package/dist/cjs/components/ChatMessage/ChatMessage.cjs.map +1 -0
- package/dist/cjs/components/DropdownMenu/DropdownMenu.cjs +160 -71
- package/dist/cjs/components/DropdownMenu/DropdownMenu.cjs.map +1 -1
- package/dist/cjs/components/Icons/DenseGridViewIcon.cjs +52 -0
- package/dist/cjs/components/Icons/DenseGridViewIcon.cjs.map +1 -0
- package/dist/cjs/components/MediaStatusIndicator/MediaStatusIndicator.cjs +66 -0
- package/dist/cjs/components/MediaStatusIndicator/MediaStatusIndicator.cjs.map +1 -0
- package/dist/cjs/components/Select/Select.cjs +61 -17
- package/dist/cjs/components/Select/Select.cjs.map +1 -1
- package/dist/cjs/components/SubscribeButton/SubscribeButton.cjs +53 -0
- package/dist/cjs/components/SubscribeButton/SubscribeButton.cjs.map +1 -0
- package/dist/cjs/components/VoiceNote/VoiceNote.cjs +305 -0
- package/dist/cjs/components/VoiceNote/VoiceNote.cjs.map +1 -0
- package/dist/cjs/index.cjs +12 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/utils/audioWaveform.cjs +76 -0
- package/dist/cjs/utils/audioWaveform.cjs.map +1 -0
- package/dist/cjs/utils/useAudioPlayback.cjs +123 -0
- package/dist/cjs/utils/useAudioPlayback.cjs.map +1 -0
- package/dist/cjs/utils/useFittedBarCount.cjs +42 -0
- package/dist/cjs/utils/useFittedBarCount.cjs.map +1 -0
- package/dist/cjs/utils/useWaveformPeaks.cjs +48 -0
- package/dist/cjs/utils/useWaveformPeaks.cjs.map +1 -0
- package/dist/cjs/utils/useWaveformSeek.cjs +100 -0
- package/dist/cjs/utils/useWaveformSeek.cjs.map +1 -0
- package/dist/components/AudioPlayer/AudioPlayer.mjs +7 -80
- package/dist/components/AudioPlayer/AudioPlayer.mjs.map +1 -1
- package/dist/components/AudioRecordButton/AudioRecordButton.mjs +37 -0
- package/dist/components/AudioRecordButton/AudioRecordButton.mjs.map +1 -0
- package/dist/components/AudioUpload/AudioUpload.mjs +5 -5
- package/dist/components/AudioUpload/AudioUpload.mjs.map +1 -1
- package/dist/components/ChatInput/ChatInput.mjs +6 -11
- package/dist/components/ChatInput/ChatInput.mjs.map +1 -1
- package/dist/components/ChatMessage/ChatMessage.mjs +211 -0
- package/dist/components/ChatMessage/ChatMessage.mjs.map +1 -0
- package/dist/components/DropdownMenu/DropdownMenu.mjs +161 -72
- package/dist/components/DropdownMenu/DropdownMenu.mjs.map +1 -1
- package/dist/components/Icons/DenseGridViewIcon.mjs +35 -0
- package/dist/components/Icons/DenseGridViewIcon.mjs.map +1 -0
- package/dist/components/MediaStatusIndicator/MediaStatusIndicator.mjs +49 -0
- package/dist/components/MediaStatusIndicator/MediaStatusIndicator.mjs.map +1 -0
- package/dist/components/Select/Select.mjs +61 -17
- package/dist/components/Select/Select.mjs.map +1 -1
- package/dist/components/SubscribeButton/SubscribeButton.mjs +36 -0
- package/dist/components/SubscribeButton/SubscribeButton.mjs.map +1 -0
- package/dist/components/VoiceNote/VoiceNote.mjs +288 -0
- package/dist/components/VoiceNote/VoiceNote.mjs.map +1 -0
- package/dist/index.d.ts +321 -4
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +1 -1
- package/dist/styles/base.css +11 -2
- package/dist/styles/theme.css +1 -1
- package/dist/utils/audioWaveform.mjs +76 -0
- package/dist/utils/audioWaveform.mjs.map +1 -0
- package/dist/utils/useAudioPlayback.mjs +106 -0
- package/dist/utils/useAudioPlayback.mjs.map +1 -0
- package/dist/utils/useFittedBarCount.mjs +25 -0
- package/dist/utils/useFittedBarCount.mjs.map +1 -0
- package/dist/utils/useWaveformPeaks.mjs +31 -0
- package/dist/utils/useWaveformPeaks.mjs.map +1 -0
- package/dist/utils/useWaveformSeek.mjs +83 -0
- package/dist/utils/useWaveformSeek.mjs.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
function useAudioPlayback({
|
|
4
|
+
src,
|
|
5
|
+
duration,
|
|
6
|
+
playing: controlledPlaying,
|
|
7
|
+
defaultPlaying = false,
|
|
8
|
+
onPlay,
|
|
9
|
+
onPause,
|
|
10
|
+
onEnded
|
|
11
|
+
}) {
|
|
12
|
+
const audioRef = React.useRef(null);
|
|
13
|
+
const [internalPlaying, setInternalPlaying] = React.useState(defaultPlaying);
|
|
14
|
+
const isControlled = controlledPlaying !== void 0;
|
|
15
|
+
const isPlaying = isControlled ? controlledPlaying : internalPlaying;
|
|
16
|
+
const [currentTime, setCurrentTime] = React.useState(0);
|
|
17
|
+
const [mediaDuration, setMediaDuration] = React.useState(void 0);
|
|
18
|
+
const seekingRef = React.useRef(false);
|
|
19
|
+
const displayDuration = mediaDuration ?? duration;
|
|
20
|
+
const hasStarted = isPlaying || currentTime > 0;
|
|
21
|
+
const playingRef = React.useRef(isPlaying);
|
|
22
|
+
playingRef.current = isPlaying;
|
|
23
|
+
const isControlledRef = React.useRef(isControlled);
|
|
24
|
+
isControlledRef.current = isControlled;
|
|
25
|
+
const onPauseRef = React.useRef(onPause);
|
|
26
|
+
onPauseRef.current = onPause;
|
|
27
|
+
const isFirstSrcRenderRef = React.useRef(true);
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (isFirstSrcRenderRef.current) {
|
|
30
|
+
isFirstSrcRenderRef.current = false;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
setCurrentTime(0);
|
|
34
|
+
setMediaDuration(void 0);
|
|
35
|
+
if (playingRef.current) {
|
|
36
|
+
if (!isControlledRef.current) setInternalPlaying(false);
|
|
37
|
+
onPauseRef.current?.();
|
|
38
|
+
}
|
|
39
|
+
}, [src]);
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
const audio = audioRef.current;
|
|
42
|
+
if (!audio) return;
|
|
43
|
+
if (isPlaying) {
|
|
44
|
+
audio.play().catch(() => {
|
|
45
|
+
if (!isControlled) setInternalPlaying(false);
|
|
46
|
+
onPauseRef.current?.();
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
audio.pause();
|
|
50
|
+
}
|
|
51
|
+
}, [isPlaying, isControlled]);
|
|
52
|
+
const toggle = React.useCallback(() => {
|
|
53
|
+
const next = !playingRef.current;
|
|
54
|
+
if (!isControlledRef.current) setInternalPlaying(next);
|
|
55
|
+
if (next) onPlay?.();
|
|
56
|
+
else onPause?.();
|
|
57
|
+
}, [onPlay, onPause]);
|
|
58
|
+
const seekTo = React.useCallback(
|
|
59
|
+
(time) => {
|
|
60
|
+
if (displayDuration === void 0) return;
|
|
61
|
+
const clamped = Math.min(Math.max(time, 0), displayDuration);
|
|
62
|
+
setCurrentTime(clamped);
|
|
63
|
+
const audio = audioRef.current;
|
|
64
|
+
if (audio) audio.currentTime = clamped;
|
|
65
|
+
},
|
|
66
|
+
[displayDuration]
|
|
67
|
+
);
|
|
68
|
+
const setSeeking = React.useCallback((seeking) => {
|
|
69
|
+
seekingRef.current = seeking;
|
|
70
|
+
}, []);
|
|
71
|
+
const progress = displayDuration && displayDuration > 0 ? Math.min(Math.max(currentTime / displayDuration, 0), 1) : 0;
|
|
72
|
+
const audioHandlers = React.useMemo(
|
|
73
|
+
() => ({
|
|
74
|
+
onLoadedMetadata: (event) => {
|
|
75
|
+
const nativeDuration = event.currentTarget.duration;
|
|
76
|
+
setMediaDuration(Number.isFinite(nativeDuration) ? nativeDuration : void 0);
|
|
77
|
+
},
|
|
78
|
+
onTimeUpdate: (event) => {
|
|
79
|
+
if (seekingRef.current) return;
|
|
80
|
+
setCurrentTime(event.currentTarget.currentTime);
|
|
81
|
+
},
|
|
82
|
+
onEnded: () => {
|
|
83
|
+
setCurrentTime(0);
|
|
84
|
+
if (!isControlledRef.current) setInternalPlaying(false);
|
|
85
|
+
onEnded?.();
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
[onEnded]
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
audioRef,
|
|
92
|
+
isPlaying,
|
|
93
|
+
toggle,
|
|
94
|
+
currentTime,
|
|
95
|
+
displayDuration,
|
|
96
|
+
progress,
|
|
97
|
+
hasStarted,
|
|
98
|
+
seekTo,
|
|
99
|
+
setSeeking,
|
|
100
|
+
audioHandlers
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
useAudioPlayback
|
|
105
|
+
};
|
|
106
|
+
//# sourceMappingURL=useAudioPlayback.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useAudioPlayback.mjs","sources":["../../src/utils/useAudioPlayback.ts"],"sourcesContent":["import * as React from \"react\";\n\n/** Options for {@link useAudioPlayback}. */\nexport interface UseAudioPlaybackOptions {\n /** URL of the audio to play. When absent, the hook stays idle (no media element attached). */\n src?: string;\n /** Fallback total duration (seconds) used until the media's own metadata loads. */\n duration?: number;\n /** Whether playback is active (controlled). */\n playing?: boolean;\n /** Initial playing state (uncontrolled). @default false */\n defaultPlaying?: boolean;\n /** Called when playback starts (via the toggle). */\n onPlay?: () => void;\n /** Called when playback pauses (via the toggle, a failed play(), or a src swap). */\n onPause?: () => void;\n /** Called when playback reaches the end of the media. */\n onEnded?: () => void;\n}\n\n/** Handlers to spread onto the backing `<audio>` element. */\nexport interface AudioElementHandlers {\n onLoadedMetadata: (event: React.SyntheticEvent<HTMLAudioElement>) => void;\n onTimeUpdate: (event: React.SyntheticEvent<HTMLAudioElement>) => void;\n onEnded: () => void;\n}\n\n/** Result of {@link useAudioPlayback}. */\nexport interface UseAudioPlaybackResult {\n /** Attach to the backing `<audio>` element. */\n audioRef: React.RefObject<HTMLAudioElement | null>;\n /** Current play/pause state (respects controlled `playing`). */\n isPlaying: boolean;\n /** Toggle play/pause, firing `onPlay`/`onPause`. */\n toggle: () => void;\n /** Current playback position in seconds. */\n currentTime: number;\n /** Media duration once known, else the `duration` fallback. */\n displayDuration: number | undefined;\n /** Playback progress, 0–1. */\n progress: number;\n /** Whether playback has begun (playing or scrubbed past 0). */\n hasStarted: boolean;\n /** Seek to a time in seconds (clamped to the duration). */\n seekTo: (time: number) => void;\n /** Suppress `timeupdate`-driven position updates while a manual scrub is in progress. */\n setSeeking: (seeking: boolean) => void;\n /** Handlers to spread onto the `<audio>` element. */\n audioHandlers: AudioElementHandlers;\n}\n\n/**\n * Drives a single `<audio>` element: controlled/uncontrolled play/pause, live\n * position + duration, seeking, and src-swap resets. Extracted from\n * {@link AudioPlayer} so waveform players (e.g. {@link VoiceNote}) can share the\n * same playback engine without duplicating the effect wiring.\n */\nexport function useAudioPlayback({\n src,\n duration,\n playing: controlledPlaying,\n defaultPlaying = false,\n onPlay,\n onPause,\n onEnded,\n}: UseAudioPlaybackOptions): UseAudioPlaybackResult {\n const audioRef = React.useRef<HTMLAudioElement>(null);\n\n const [internalPlaying, setInternalPlaying] = React.useState(defaultPlaying);\n const isControlled = controlledPlaying !== undefined;\n const isPlaying = isControlled ? controlledPlaying : internalPlaying;\n\n const [currentTime, setCurrentTime] = React.useState(0);\n const [mediaDuration, setMediaDuration] = React.useState<number | undefined>(undefined);\n const seekingRef = React.useRef(false);\n\n const displayDuration = mediaDuration ?? duration;\n const hasStarted = isPlaying || currentTime > 0;\n\n // Latest-value refs so the src-change effect can react to a src swap without\n // re-running whenever playing/isControlled/onPause change.\n const playingRef = React.useRef(isPlaying);\n playingRef.current = isPlaying;\n const isControlledRef = React.useRef(isControlled);\n isControlledRef.current = isControlled;\n const onPauseRef = React.useRef(onPause);\n onPauseRef.current = onPause;\n\n // Reset transient playback state on source change; skip the initial mount and,\n // if playback was active, stop it (the browser drops playback on a src swap).\n const isFirstSrcRenderRef = React.useRef(true);\n // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally keyed on src to reset transient state on a source swap\n React.useEffect(() => {\n if (isFirstSrcRenderRef.current) {\n isFirstSrcRenderRef.current = false;\n return;\n }\n setCurrentTime(0);\n setMediaDuration(undefined);\n if (playingRef.current) {\n if (!isControlledRef.current) setInternalPlaying(false);\n onPauseRef.current?.();\n }\n }, [src]);\n\n // Sync the controlled/uncontrolled playing state to the media element.\n React.useEffect(() => {\n const audio = audioRef.current;\n if (!audio) return;\n if (isPlaying) {\n audio.play().catch(() => {\n if (!isControlled) setInternalPlaying(false);\n onPauseRef.current?.();\n });\n } else {\n audio.pause();\n }\n }, [isPlaying, isControlled]);\n\n const toggle = React.useCallback(() => {\n const next = !playingRef.current;\n if (!isControlledRef.current) setInternalPlaying(next);\n if (next) onPlay?.();\n else onPause?.();\n }, [onPlay, onPause]);\n\n const seekTo = React.useCallback(\n (time: number) => {\n if (displayDuration === undefined) return;\n const clamped = Math.min(Math.max(time, 0), displayDuration);\n setCurrentTime(clamped);\n const audio = audioRef.current;\n if (audio) audio.currentTime = clamped;\n },\n [displayDuration],\n );\n\n const setSeeking = React.useCallback((seeking: boolean) => {\n seekingRef.current = seeking;\n }, []);\n\n const progress =\n displayDuration && displayDuration > 0\n ? Math.min(Math.max(currentTime / displayDuration, 0), 1)\n : 0;\n\n const audioHandlers = React.useMemo<AudioElementHandlers>(\n () => ({\n onLoadedMetadata: (event) => {\n const nativeDuration = event.currentTarget.duration;\n setMediaDuration(Number.isFinite(nativeDuration) ? nativeDuration : undefined);\n },\n onTimeUpdate: (event) => {\n if (seekingRef.current) return;\n setCurrentTime(event.currentTarget.currentTime);\n },\n onEnded: () => {\n setCurrentTime(0);\n if (!isControlledRef.current) setInternalPlaying(false);\n onEnded?.();\n },\n }),\n [onEnded],\n );\n\n return {\n audioRef,\n isPlaying,\n toggle,\n currentTime,\n displayDuration,\n progress,\n hasStarted,\n seekTo,\n setSeeking,\n audioHandlers,\n };\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
function useFittedBarCount(trackRef, { barWidthPx, gapPx, fallback }) {
|
|
4
|
+
const [barCount, setBarCount] = React.useState(fallback);
|
|
5
|
+
React.useEffect(() => {
|
|
6
|
+
const track = trackRef.current;
|
|
7
|
+
if (!track) return;
|
|
8
|
+
const updateFromWidth = (width) => {
|
|
9
|
+
if (width <= 0) return;
|
|
10
|
+
setBarCount(Math.max(1, Math.floor((width + gapPx) / (barWidthPx + gapPx))));
|
|
11
|
+
};
|
|
12
|
+
updateFromWidth(track.getBoundingClientRect().width);
|
|
13
|
+
const observer = new ResizeObserver((entries) => {
|
|
14
|
+
const entry = entries[0];
|
|
15
|
+
if (entry) updateFromWidth(entry.contentRect.width);
|
|
16
|
+
});
|
|
17
|
+
observer.observe(track);
|
|
18
|
+
return () => observer.disconnect();
|
|
19
|
+
}, [trackRef, barWidthPx, gapPx]);
|
|
20
|
+
return barCount;
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
useFittedBarCount
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=useFittedBarCount.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useFittedBarCount.mjs","sources":["../../src/utils/useFittedBarCount.ts"],"sourcesContent":["import * as React from \"react\";\n\n/** Options for {@link useFittedBarCount}. */\nexport interface UseFittedBarCountOptions {\n /** Rendered width of a single bar, in pixels. */\n barWidthPx: number;\n /** Gap between bars, in pixels. */\n gapPx: number;\n /** Bar count used before the track is measured (SSR, jsdom, or zero-width layout). */\n fallback: number;\n}\n\n/**\n * Measures a waveform track and returns how many fixed-width bars fit across it,\n * updating on resize. Lets callers resample their peaks to the *visible* bar\n * count so overflow bars aren't rendered (and, crucially, aren't counted when\n * computing playback progress).\n */\nexport function useFittedBarCount(\n trackRef: React.RefObject<HTMLElement | null>,\n { barWidthPx, gapPx, fallback }: UseFittedBarCountOptions,\n): number {\n const [barCount, setBarCount] = React.useState(fallback);\n\n React.useEffect(() => {\n const track = trackRef.current;\n if (!track) return;\n\n const updateFromWidth = (width: number) => {\n if (width <= 0) return;\n setBarCount(Math.max(1, Math.floor((width + gapPx) / (barWidthPx + gapPx))));\n };\n\n updateFromWidth(track.getBoundingClientRect().width);\n\n const observer = new ResizeObserver((entries) => {\n const entry = entries[0];\n if (entry) updateFromWidth(entry.contentRect.width);\n });\n observer.observe(track);\n return () => observer.disconnect();\n }, [trackRef, barWidthPx, gapPx]);\n\n return barCount;\n}\n"],"names":[],"mappings":";;AAkBO,SAAS,kBACd,UACA,EAAE,YAAY,OAAO,YACb;AACR,QAAM,CAAC,UAAU,WAAW,IAAI,MAAM,SAAS,QAAQ;AAEvD,QAAM,UAAU,MAAM;AACpB,UAAM,QAAQ,SAAS;AACvB,QAAI,CAAC,MAAO;AAEZ,UAAM,kBAAkB,CAAC,UAAkB;AACzC,UAAI,SAAS,EAAG;AAChB,kBAAY,KAAK,IAAI,GAAG,KAAK,OAAO,QAAQ,UAAU,aAAa,MAAM,CAAC,CAAC;AAAA,IAC7E;AAEA,oBAAgB,MAAM,sBAAA,EAAwB,KAAK;AAEnD,UAAM,WAAW,IAAI,eAAe,CAAC,YAAY;AAC/C,YAAM,QAAQ,QAAQ,CAAC;AACvB,UAAI,MAAO,iBAAgB,MAAM,YAAY,KAAK;AAAA,IACpD,CAAC;AACD,aAAS,QAAQ,KAAK;AACtB,WAAO,MAAM,SAAS,WAAA;AAAA,EACxB,GAAG,CAAC,UAAU,YAAY,KAAK,CAAC;AAEhC,SAAO;AACT;"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { RAW_PEAK_COUNT, generateFallbackPeaks, decodeAudioPeaks } from "./audioWaveform.mjs";
|
|
4
|
+
function useWaveformPeaks(src, count = RAW_PEAK_COUNT) {
|
|
5
|
+
const [peaks, setPeaks] = React.useState(
|
|
6
|
+
() => src ? generateFallbackPeaks(src, count) : []
|
|
7
|
+
);
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
if (!src) {
|
|
10
|
+
setPeaks([]);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
setPeaks(generateFallbackPeaks(src, count));
|
|
14
|
+
let cancelled = false;
|
|
15
|
+
const abortController = new AbortController();
|
|
16
|
+
decodeAudioPeaks(src, abortController.signal).then((decoded) => {
|
|
17
|
+
if (!cancelled) setPeaks(decoded);
|
|
18
|
+
}).catch(() => {
|
|
19
|
+
if (!cancelled) setPeaks(generateFallbackPeaks(src, count));
|
|
20
|
+
});
|
|
21
|
+
return () => {
|
|
22
|
+
cancelled = true;
|
|
23
|
+
abortController.abort();
|
|
24
|
+
};
|
|
25
|
+
}, [src, count]);
|
|
26
|
+
return peaks;
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
useWaveformPeaks
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=useWaveformPeaks.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useWaveformPeaks.mjs","sources":["../../src/utils/useWaveformPeaks.ts"],"sourcesContent":["import * as React from \"react\";\nimport { decodeAudioPeaks, generateFallbackPeaks, RAW_PEAK_COUNT } from \"./audioWaveform\";\n\n/**\n * Decodes `src` into waveform peak amplitudes (0–1). Seeds a deterministic\n * fallback immediately (stable across renders/SSR) and swaps in the real\n * decoded peaks once WebAudio resolves, falling back again on any failure.\n * Returns an empty array when `src` is absent.\n */\nexport function useWaveformPeaks(\n src: string | undefined,\n count: number = RAW_PEAK_COUNT,\n): number[] {\n const [peaks, setPeaks] = React.useState<number[]>(() =>\n src ? generateFallbackPeaks(src, count) : [],\n );\n\n React.useEffect(() => {\n if (!src) {\n setPeaks([]);\n return;\n }\n\n setPeaks(generateFallbackPeaks(src, count));\n\n let cancelled = false;\n const abortController = new AbortController();\n decodeAudioPeaks(src, abortController.signal)\n .then((decoded) => {\n if (!cancelled) setPeaks(decoded);\n })\n .catch(() => {\n if (!cancelled) setPeaks(generateFallbackPeaks(src, count));\n });\n\n return () => {\n cancelled = true;\n abortController.abort();\n };\n }, [src, count]);\n\n return peaks;\n}\n"],"names":[],"mappings":";;;AASO,SAAS,iBACd,KACA,QAAgB,gBACN;AACV,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM;AAAA,IAAmB,MACjD,MAAM,sBAAsB,KAAK,KAAK,IAAI,CAAA;AAAA,EAAC;AAG7C,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,KAAK;AACR,eAAS,CAAA,CAAE;AACX;AAAA,IACF;AAEA,aAAS,sBAAsB,KAAK,KAAK,CAAC;AAE1C,QAAI,YAAY;AAChB,UAAM,kBAAkB,IAAI,gBAAA;AAC5B,qBAAiB,KAAK,gBAAgB,MAAM,EACzC,KAAK,CAAC,YAAY;AACjB,UAAI,CAAC,UAAW,UAAS,OAAO;AAAA,IAClC,CAAC,EACA,MAAM,MAAM;AACX,UAAI,CAAC,UAAW,UAAS,sBAAsB,KAAK,KAAK,CAAC;AAAA,IAC5D,CAAC;AAEH,WAAO,MAAM;AACX,kBAAY;AACZ,sBAAgB,MAAA;AAAA,IAClB;AAAA,EACF,GAAG,CAAC,KAAK,KAAK,CAAC;AAEf,SAAO;AACT;"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { formatTime } from "./audioWaveform.mjs";
|
|
4
|
+
const SEEK_STEP_SECONDS = 1;
|
|
5
|
+
const SEEK_STEP_LARGE_SECONDS = 5;
|
|
6
|
+
const STEP_BY_KEY = {
|
|
7
|
+
ArrowRight: SEEK_STEP_SECONDS,
|
|
8
|
+
ArrowUp: SEEK_STEP_SECONDS,
|
|
9
|
+
ArrowLeft: -SEEK_STEP_SECONDS,
|
|
10
|
+
ArrowDown: -SEEK_STEP_SECONDS,
|
|
11
|
+
PageUp: SEEK_STEP_LARGE_SECONDS,
|
|
12
|
+
PageDown: -SEEK_STEP_LARGE_SECONDS
|
|
13
|
+
};
|
|
14
|
+
function useWaveformSeek({
|
|
15
|
+
trackRef,
|
|
16
|
+
displayDuration,
|
|
17
|
+
currentTime,
|
|
18
|
+
seekTo,
|
|
19
|
+
setSeeking,
|
|
20
|
+
label = "Seek"
|
|
21
|
+
}) {
|
|
22
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
23
|
+
const seekFromClientX = React.useCallback(
|
|
24
|
+
(clientX) => {
|
|
25
|
+
const track = trackRef.current;
|
|
26
|
+
if (!track || displayDuration === void 0) return;
|
|
27
|
+
const rect = track.getBoundingClientRect();
|
|
28
|
+
if (rect.width <= 0) return;
|
|
29
|
+
const fraction = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
|
|
30
|
+
seekTo(fraction * displayDuration);
|
|
31
|
+
},
|
|
32
|
+
[trackRef, displayDuration, seekTo]
|
|
33
|
+
);
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
if (!isDragging) return;
|
|
36
|
+
const handleMove = (event) => seekFromClientX(event.clientX);
|
|
37
|
+
const handleUp = () => {
|
|
38
|
+
setIsDragging(false);
|
|
39
|
+
setSeeking(false);
|
|
40
|
+
};
|
|
41
|
+
window.addEventListener("pointermove", handleMove);
|
|
42
|
+
window.addEventListener("pointerup", handleUp);
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener("pointermove", handleMove);
|
|
45
|
+
window.removeEventListener("pointerup", handleUp);
|
|
46
|
+
};
|
|
47
|
+
}, [isDragging, seekFromClientX, setSeeking]);
|
|
48
|
+
const onPointerDown = (event) => {
|
|
49
|
+
seekFromClientX(event.clientX);
|
|
50
|
+
setSeeking(true);
|
|
51
|
+
setIsDragging(true);
|
|
52
|
+
};
|
|
53
|
+
const onKeyDown = (event) => {
|
|
54
|
+
if (displayDuration === void 0) return;
|
|
55
|
+
const delta = STEP_BY_KEY[event.key];
|
|
56
|
+
if (delta !== void 0) {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
seekTo(currentTime + delta);
|
|
59
|
+
} else if (event.key === "Home") {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
seekTo(0);
|
|
62
|
+
} else if (event.key === "End") {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
seekTo(displayDuration);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
role: "slider",
|
|
69
|
+
tabIndex: 0,
|
|
70
|
+
"aria-label": label,
|
|
71
|
+
"aria-orientation": "horizontal",
|
|
72
|
+
"aria-valuemin": 0,
|
|
73
|
+
"aria-valuemax": displayDuration,
|
|
74
|
+
"aria-valuenow": Math.round(currentTime),
|
|
75
|
+
"aria-valuetext": displayDuration !== void 0 ? `${formatTime(currentTime)} of ${formatTime(displayDuration)}` : void 0,
|
|
76
|
+
onPointerDown,
|
|
77
|
+
onKeyDown
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export {
|
|
81
|
+
useWaveformSeek
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=useWaveformSeek.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useWaveformSeek.mjs","sources":["../../src/utils/useWaveformSeek.ts"],"sourcesContent":["import * as React from \"react\";\nimport { formatTime } from \"./audioWaveform\";\n\n/** Seconds moved per Arrow key press while seeking. */\nconst SEEK_STEP_SECONDS = 1;\n/** Seconds moved per Page Up/Down press while seeking. */\nconst SEEK_STEP_LARGE_SECONDS = 5;\n\n/** Signed seek delta (seconds) for each stepping key. */\nconst STEP_BY_KEY: Record<string, number | undefined> = {\n ArrowRight: SEEK_STEP_SECONDS,\n ArrowUp: SEEK_STEP_SECONDS,\n ArrowLeft: -SEEK_STEP_SECONDS,\n ArrowDown: -SEEK_STEP_SECONDS,\n PageUp: SEEK_STEP_LARGE_SECONDS,\n PageDown: -SEEK_STEP_LARGE_SECONDS,\n};\n\n/** Options for {@link useWaveformSeek}. */\nexport interface UseWaveformSeekOptions {\n /** Ref to the seekable track element (its width maps pointer position → time). */\n trackRef: React.RefObject<HTMLElement | null>;\n /** Total duration in seconds, or `undefined` until known. */\n displayDuration: number | undefined;\n /** Current playback position in seconds. */\n currentTime: number;\n /** Seek to a time in seconds. */\n seekTo: (time: number) => void;\n /** Suppress position updates while a manual scrub is in progress. */\n setSeeking: (seeking: boolean) => void;\n /** Accessible name for the slider. @default \"Seek\" */\n label?: string;\n}\n\n/** ARIA + event props to spread onto the seekable track element. */\nexport interface WaveformSeekProps {\n role: \"slider\";\n tabIndex: 0;\n \"aria-label\": string;\n \"aria-orientation\": \"horizontal\";\n \"aria-valuemin\": 0;\n \"aria-valuemax\": number | undefined;\n \"aria-valuenow\": number;\n \"aria-valuetext\": string | undefined;\n onPointerDown: (event: React.PointerEvent<HTMLElement>) => void;\n onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void;\n}\n\n/**\n * Turns a waveform track into a keyboard- and pointer-seekable slider. Handles\n * click/drag position math (via window listeners so drags can leave the element)\n * and Arrow/Page/Home/End stepping, and returns ARIA slider props to spread onto\n * the track. Shared by waveform players so seek behaviour isn't reimplemented.\n */\nexport function useWaveformSeek({\n trackRef,\n displayDuration,\n currentTime,\n seekTo,\n setSeeking,\n label = \"Seek\",\n}: UseWaveformSeekOptions): WaveformSeekProps {\n const [isDragging, setIsDragging] = React.useState(false);\n\n const seekFromClientX = React.useCallback(\n (clientX: number) => {\n const track = trackRef.current;\n if (!track || displayDuration === undefined) return;\n const rect = track.getBoundingClientRect();\n if (rect.width <= 0) return;\n const fraction = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);\n seekTo(fraction * displayDuration);\n },\n [trackRef, displayDuration, seekTo],\n );\n\n React.useEffect(() => {\n if (!isDragging) return;\n const handleMove = (event: PointerEvent) => seekFromClientX(event.clientX);\n const handleUp = () => {\n setIsDragging(false);\n setSeeking(false);\n };\n window.addEventListener(\"pointermove\", handleMove);\n window.addEventListener(\"pointerup\", handleUp);\n return () => {\n window.removeEventListener(\"pointermove\", handleMove);\n window.removeEventListener(\"pointerup\", handleUp);\n };\n }, [isDragging, seekFromClientX, setSeeking]);\n\n const onPointerDown = (event: React.PointerEvent<HTMLElement>) => {\n seekFromClientX(event.clientX);\n setSeeking(true);\n setIsDragging(true);\n };\n\n const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n if (displayDuration === undefined) return;\n const delta = STEP_BY_KEY[event.key];\n if (delta !== undefined) {\n event.preventDefault();\n seekTo(currentTime + delta);\n } else if (event.key === \"Home\") {\n event.preventDefault();\n seekTo(0);\n } else if (event.key === \"End\") {\n event.preventDefault();\n seekTo(displayDuration);\n }\n };\n\n return {\n role: \"slider\",\n tabIndex: 0,\n \"aria-label\": label,\n \"aria-orientation\": \"horizontal\",\n \"aria-valuemin\": 0,\n \"aria-valuemax\": displayDuration,\n \"aria-valuenow\": Math.round(currentTime),\n \"aria-valuetext\":\n displayDuration !== undefined\n ? `${formatTime(currentTime)} of ${formatTime(displayDuration)}`\n : undefined,\n onPointerDown,\n onKeyDown,\n };\n}\n"],"names":[],"mappings":";;;AAIA,MAAM,oBAAoB;AAE1B,MAAM,0BAA0B;AAGhC,MAAM,cAAkD;AAAA,EACtD,YAAY;AAAA,EACZ,SAAS;AAAA,EACT,WAAW,CAAC;AAAA,EACZ,WAAW,CAAC;AAAA,EACZ,QAAQ;AAAA,EACR,UAAU,CAAC;AACb;AAsCO,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AACV,GAA8C;AAC5C,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAAS,KAAK;AAExD,QAAM,kBAAkB,MAAM;AAAA,IAC5B,CAAC,YAAoB;AACnB,YAAM,QAAQ,SAAS;AACvB,UAAI,CAAC,SAAS,oBAAoB,OAAW;AAC7C,YAAM,OAAO,MAAM,sBAAA;AACnB,UAAI,KAAK,SAAS,EAAG;AACrB,YAAM,WAAW,KAAK,IAAI,KAAK,KAAK,UAAU,KAAK,QAAQ,KAAK,OAAO,CAAC,GAAG,CAAC;AAC5E,aAAO,WAAW,eAAe;AAAA,IACnC;AAAA,IACA,CAAC,UAAU,iBAAiB,MAAM;AAAA,EAAA;AAGpC,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,WAAY;AACjB,UAAM,aAAa,CAAC,UAAwB,gBAAgB,MAAM,OAAO;AACzE,UAAM,WAAW,MAAM;AACrB,oBAAc,KAAK;AACnB,iBAAW,KAAK;AAAA,IAClB;AACA,WAAO,iBAAiB,eAAe,UAAU;AACjD,WAAO,iBAAiB,aAAa,QAAQ;AAC7C,WAAO,MAAM;AACX,aAAO,oBAAoB,eAAe,UAAU;AACpD,aAAO,oBAAoB,aAAa,QAAQ;AAAA,IAClD;AAAA,EACF,GAAG,CAAC,YAAY,iBAAiB,UAAU,CAAC;AAE5C,QAAM,gBAAgB,CAAC,UAA2C;AAChE,oBAAgB,MAAM,OAAO;AAC7B,eAAW,IAAI;AACf,kBAAc,IAAI;AAAA,EACpB;AAEA,QAAM,YAAY,CAAC,UAA4C;AAC7D,QAAI,oBAAoB,OAAW;AACnC,UAAM,QAAQ,YAAY,MAAM,GAAG;AACnC,QAAI,UAAU,QAAW;AACvB,YAAM,eAAA;AACN,aAAO,cAAc,KAAK;AAAA,IAC5B,WAAW,MAAM,QAAQ,QAAQ;AAC/B,YAAM,eAAA;AACN,aAAO,CAAC;AAAA,IACV,WAAW,MAAM,QAAQ,OAAO;AAC9B,YAAM,eAAA;AACN,aAAO,eAAe;AAAA,IACxB;AAAA,EACF;AAEA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,UAAU;AAAA,IACV,cAAc;AAAA,IACd,oBAAoB;AAAA,IACpB,iBAAiB;AAAA,IACjB,iBAAiB;AAAA,IACjB,iBAAiB,KAAK,MAAM,WAAW;AAAA,IACvC,kBACE,oBAAoB,SAChB,GAAG,WAAW,WAAW,CAAC,OAAO,WAAW,eAAe,CAAC,KAC5D;AAAA,IACN;AAAA,IACA;AAAA,EAAA;AAEJ;"}
|