fix: unblock speaker page audio when loadedmetadata never fires on opus blob
All checks were successful
Build and Push / build (push) Successful in 1m20s
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:
@@ -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) {
|
||||||
|
setAudioError("Audio decode failed");
|
||||||
|
setAudioLoading(false);
|
||||||
|
} else {
|
||||||
|
useDirectAudio();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
audio.src = blobUrl;
|
audio.src = blobUrl;
|
||||||
audio.load();
|
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,11 +142,28 @@ 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;
|
||||||
|
const seek = () => {
|
||||||
audio.currentTime = t;
|
audio.currentTime = t;
|
||||||
const p = audio.play();
|
const p = audio.play();
|
||||||
if (p) p.catch(() => {});
|
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) => {
|
||||||
const next = new Set(selected);
|
const next = new Set(selected);
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user