Unify clip row playback

This commit is contained in:
Codex
2026-04-23 11:05:14 -05:00
parent 74de6f0d0f
commit c355b3ae26
6 changed files with 390 additions and 359 deletions

View 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,
};
}