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(null); const audioContextRef = useRef(null); const gainNodeRef = useRef(null); const playbackRangeRef = useRef<{ endSeconds: number } | null>(null); const fadeOutTimerRef = useRef(null); const activeKeyRef = useRef(null); const [activeKey, setActiveKey] = useState(null); const [activeDetails, setActiveDetails] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTimeMs, setCurrentTimeMs] = useState(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((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, }; }