fix: unblock speaker page audio when loadedmetadata never fires on opus blob
All checks were successful
Build and Push / build (push) Successful in 1m20s

Stop gating the UI on the blob's loadedmetadata (Firefox never fires it for
large Ogg/Opus blobs). Release audioLoading right after load(), seed duration
from getSession().audio_duration / max turn end, and fall back to the direct
audio URL (HTTP Range streaming) via an 8s readyState check or blob onerror.
playAt waits for loadeddata/canplay instead of audioLoading.
This commit is contained in:
KansaiGaijin
2026-08-04 21:50:42 +12:00
parent e2e7adc8af
commit 72df3f0471

View File

@@ -18,6 +18,8 @@ export default function Speakers() {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const paletteRef = useRef(new Map<string, string>()); const paletteRef = useRef(new Map<string, string>());
const blobUrlRef = useRef<string | null>(null); const blobUrlRef = useRef<string | null>(null);
const directRef = useRef(false);
const fallbackTimerRef = useRef<number | null>(null);
const [audioLoading, setAudioLoading] = useState(true); const [audioLoading, setAudioLoading] = useState(true);
const [audioProgress, setAudioProgress] = useState(0); const [audioProgress, setAudioProgress] = useState(0);
@@ -31,16 +33,35 @@ export default function Speakers() {
sp.forEach((s: any) => (n[s.raw_label] = s.display_name || "")); sp.forEach((s: any) => (n[s.raw_label] = s.display_name || ""));
setNames(n); setNames(n);
}); });
api.getTurns(id!).then(setTurns); api.getTurns(id!).then((tr) => {
setTurns(tr);
setDuration((d) => d || tr.reduce((m, t: any) => Math.max(m, t.end || 0), 0));
});
api.getSession(id!).then((s) => {
if (s?.audio_duration) setDuration((d) => (d > 0 ? d : s.audio_duration));
});
}; };
useEffect(load, [id]); useEffect(load, [id]);
const useDirectAudio = () => {
const audio = audioRef.current;
if (!audio) return;
directRef.current = true;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
audio.src = api.audioUrl(id!);
audio.load();
};
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
setAudioLoading(true); setAudioLoading(true);
setAudioProgress(0); setAudioProgress(0);
setTotalKnown(false); setTotalKnown(false);
setAudioError(null); setAudioError(null);
directRef.current = false;
let cancelled = false; let cancelled = false;
const url = api.audioUrl(id); const url = api.audioUrl(id);
@@ -52,7 +73,7 @@ export default function Speakers() {
const total = parseInt(res.headers.get("Content-Length") || "0", 10); const total = parseInt(res.headers.get("Content-Length") || "0", 10);
setTotalKnown(total > 0); setTotalKnown(total > 0);
const reader = res.body!.getReader(); const reader = res.body!.getReader();
const chunks: Uint8Array[] = []; const chunks: BlobPart[] = [];
let received = 0; let received = 0;
while (true) { while (true) {
@@ -71,17 +92,29 @@ export default function Speakers() {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio || cancelled) { if (!audio || cancelled) {
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
blobUrlRef.current = null;
return; return;
} }
await new Promise<void>((resolve, reject) => { audio.onerror = () => {
audio.onloadedmetadata = () => resolve(); if (cancelled) return;
audio.onerror = () => reject(new Error("Audio decode failed")); if (directRef.current) {
audio.src = blobUrl; setAudioError("Audio decode failed");
audio.load(); setAudioLoading(false);
}); } else {
useDirectAudio();
}
};
audio.src = blobUrl;
audio.load();
if (!cancelled) setAudioLoading(false); if (!cancelled) setAudioLoading(false);
fallbackTimerRef.current = window.setTimeout(() => {
if (cancelled || directRef.current) return;
if (audio.readyState >= 2) return;
useDirectAudio();
}, 8000);
} catch (err: any) { } catch (err: any) {
if (!cancelled) { if (!cancelled) {
setAudioError(err.message || "Failed to load audio"); setAudioError(err.message || "Failed to load audio");
@@ -92,6 +125,10 @@ export default function Speakers() {
return () => { return () => {
cancelled = true; cancelled = true;
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
if (blobUrlRef.current) { if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current); URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null; blobUrlRef.current = null;
@@ -105,10 +142,27 @@ export default function Speakers() {
const playAt = (t: number) => { const playAt = (t: number) => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio || audioLoading) return; if (!audio) return;
audio.currentTime = t; const seek = () => {
const p = audio.play(); audio.currentTime = t;
if (p) p.catch(() => {}); const p = audio.play();
if (p) p.catch(() => {});
};
if (audio.readyState >= 1) {
seek();
return;
}
const done = () => {
audio.removeEventListener("loadeddata", done);
audio.removeEventListener("canplay", done);
seek();
};
audio.addEventListener("loadeddata", done);
audio.addEventListener("canplay", done);
window.setTimeout(() => {
audio.removeEventListener("loadeddata", done);
audio.removeEventListener("canplay", done);
}, 2000);
}; };
const toggleSelected = (label: string) => { const toggleSelected = (label: string) => {
@@ -190,7 +244,7 @@ export default function Speakers() {
duration={duration} duration={duration}
currentTime={currentTime} currentTime={currentTime}
onSeek={playAt} onSeek={playAt}
disabled={audioLoading || !!audioError} disabled={!!audioError}
/> />
</div> </div>
@@ -244,7 +298,7 @@ export default function Speakers() {
<button <button
key={i} key={i}
onClick={() => playAt(s.start)} onClick={() => playAt(s.start)}
disabled={audioLoading} disabled={!!audioError}
className="block text-left text-sm text-ink/60 hover:text-ink w-full truncate disabled:opacity-30 disabled:pointer-events-none" className="block text-left text-sm text-ink/60 hover:text-ink w-full truncate disabled:opacity-30 disabled:pointer-events-none"
> >
{s.text} {s.text}