Files
walkup/frontend/src/hooks/useClipPlayback.ts
2026-04-23 11:21:52 -05:00

207 lines
5.4 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { API_BASE } from "../api/client";
type PlayClipArgs = {
key: string;
url?: string | null;
startMs: number;
endMs: number;
title?: string;
subtitle?: string;
};
export type ClipPlaybackDetails = {
key: string;
title?: string;
subtitle?: string;
};
export function useClipPlayback() {
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioContextRef = useRef<AudioContext | 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 [activeDetails, setActiveDetails] = useState<ClipPlaybackDetails | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTimeMs, setCurrentTimeMs] = useState<number | null>(null);
useEffect(() => stopClip(), []);
const setPlaybackGain = useCallback((value: number) => {
const gainNode = gainNodeRef.current;
if (!gainNode) {
return;
}
gainNode.gain.cancelScheduledValues(gainNode.context.currentTime);
gainNode.gain.setValueAtTime(value, gainNode.context.currentTime);
}, []);
const clearFadeOutTimer = useCallback(() => {
if (fadeOutTimerRef.current !== null) {
window.clearTimeout(fadeOutTimerRef.current);
fadeOutTimerRef.current = null;
}
}, []);
const stopClip = useCallback((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);
setActiveDetails(null);
setIsPlaying(false);
setCurrentTimeMs(null);
}, [clearFadeOutTimer, setPlaybackGain]);
const ensureAudio = useCallback(() => {
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;
gainNodeRef.current = gain;
}
}
audioRef.current = audio;
return audio;
}, [stopClip]);
const fadeOutClip = useCallback((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);
}, [clearFadeOutTimer, stopClip]);
const playClip = useCallback(async ({ key, url, startMs, endMs, title, subtitle }: PlayClipArgs) => {
if (!url) {
return;
}
const audio = ensureAudio();
if (activeKeyRef.current === key && !audio.paused) {
stopClip();
return;
}
stopClip();
const nextAudio = ensureAudio();
activeKeyRef.current = key;
setActiveKey(key);
setActiveDetails({
key,
title,
subtitle,
});
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;
}
}, [ensureAudio, setPlaybackGain, stopClip]);
return {
activeKey,
activeDetails,
currentTimeMs,
fadeOutClip,
isPlaying,
playClip,
stopClip,
};
}