207 lines
5.4 KiB
TypeScript
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,
|
|
};
|
|
}
|