Unify clip row playback
This commit is contained in:
192
frontend/src/hooks/useClipPlayback.ts
Normal file
192
frontend/src/hooks/useClipPlayback.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { API_BASE } from "../api/client";
|
||||
|
||||
type PlayClipArgs = {
|
||||
key: string;
|
||||
url?: string | null;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
};
|
||||
|
||||
export function useClipPlayback() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const mediaSourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
||||
const gainNodeRef = useRef<GainNode | null>(null);
|
||||
const playbackRangeRef = useRef<{ endSeconds: number } | null>(null);
|
||||
const fadeOutTimerRef = useRef<number | null>(null);
|
||||
const activeKeyRef = useRef<string | null>(null);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTimeMs, setCurrentTimeMs] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => stopClip(), []);
|
||||
|
||||
function ensureAudio() {
|
||||
const audio = audioRef.current ?? new Audio();
|
||||
if (!audioRef.current) {
|
||||
audio.onplay = () => {
|
||||
setIsPlaying(true);
|
||||
};
|
||||
audio.onpause = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
audio.onended = () => {
|
||||
stopClip();
|
||||
};
|
||||
audio.ontimeupdate = () => {
|
||||
const range = playbackRangeRef.current;
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
setCurrentTimeMs(Math.round(audio.currentTime * 1000));
|
||||
if (audio.currentTime >= range.endSeconds) {
|
||||
stopClip();
|
||||
}
|
||||
};
|
||||
|
||||
const AudioContextCtor =
|
||||
window.AudioContext ?? (window as Window & typeof globalThis & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextCtor && !audioContextRef.current) {
|
||||
const context = new AudioContextCtor();
|
||||
const source = context.createMediaElementSource(audio);
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = 1;
|
||||
source.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
audioContextRef.current = context;
|
||||
mediaSourceRef.current = source;
|
||||
gainNodeRef.current = gain;
|
||||
}
|
||||
}
|
||||
|
||||
audioRef.current = audio;
|
||||
return audio;
|
||||
}
|
||||
|
||||
function setPlaybackGain(value: number) {
|
||||
const gainNode = gainNodeRef.current;
|
||||
if (!gainNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
gainNode.gain.cancelScheduledValues(gainNode.context.currentTime);
|
||||
gainNode.gain.setValueAtTime(value, gainNode.context.currentTime);
|
||||
}
|
||||
|
||||
function clearFadeOutTimer() {
|
||||
if (fadeOutTimerRef.current !== null) {
|
||||
window.clearTimeout(fadeOutTimerRef.current);
|
||||
fadeOutTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopClip(resetGain = true) {
|
||||
clearFadeOutTimer();
|
||||
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
}
|
||||
|
||||
if (resetGain) {
|
||||
setPlaybackGain(1);
|
||||
}
|
||||
|
||||
activeKeyRef.current = null;
|
||||
playbackRangeRef.current = null;
|
||||
setActiveKey(null);
|
||||
setIsPlaying(false);
|
||||
setCurrentTimeMs(null);
|
||||
}
|
||||
|
||||
function fadeOutClip(durationMs = 1000) {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || audio.paused) {
|
||||
stopClip();
|
||||
return;
|
||||
}
|
||||
|
||||
clearFadeOutTimer();
|
||||
const gainNode = gainNodeRef.current;
|
||||
if (!gainNode) {
|
||||
stopClip();
|
||||
return;
|
||||
}
|
||||
|
||||
const safeDuration = Math.max(1, durationMs);
|
||||
const now = gainNode.context.currentTime;
|
||||
const currentGain = gainNode.gain.value;
|
||||
|
||||
gainNode.gain.cancelScheduledValues(now);
|
||||
gainNode.gain.setValueAtTime(currentGain, now);
|
||||
gainNode.gain.linearRampToValueAtTime(0, now + safeDuration / 1000);
|
||||
|
||||
fadeOutTimerRef.current = window.setTimeout(() => {
|
||||
stopClip(false);
|
||||
}, safeDuration);
|
||||
}
|
||||
|
||||
async function playClip({ key, url, startMs, endMs }: PlayClipArgs) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = ensureAudio();
|
||||
if (activeKeyRef.current === key && !audio.paused) {
|
||||
stopClip();
|
||||
return;
|
||||
}
|
||||
|
||||
stopClip();
|
||||
const nextAudio = ensureAudio();
|
||||
activeKeyRef.current = key;
|
||||
setActiveKey(key);
|
||||
setIsPlaying(false);
|
||||
nextAudio.pause();
|
||||
setPlaybackGain(1);
|
||||
|
||||
const audioContext = audioContextRef.current;
|
||||
if (audioContext?.state === "suspended") {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
const startSeconds = startMs / 1000;
|
||||
const endSeconds = endMs / 1000;
|
||||
playbackRangeRef.current = { endSeconds };
|
||||
setCurrentTimeMs(startMs);
|
||||
|
||||
const metadataReady = new Promise<void>((resolve) => {
|
||||
nextAudio.onloadedmetadata = () => {
|
||||
if (activeKeyRef.current === key) {
|
||||
nextAudio.currentTime = startSeconds;
|
||||
setCurrentTimeMs(startMs);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
nextAudio.src = `${API_BASE}${url}`;
|
||||
await metadataReady;
|
||||
|
||||
try {
|
||||
await nextAudio.play();
|
||||
} catch (error) {
|
||||
stopClip();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeKey,
|
||||
currentTimeMs,
|
||||
fadeOutClip,
|
||||
isPlaying,
|
||||
playClip,
|
||||
stopClip,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user