frame watchdog in case it gets stuck

This commit is contained in:
2026-05-04 20:22:21 -07:00
parent 81d9cfc1c2
commit 47dae48673

View File

@@ -28,9 +28,16 @@ const MAX_PENDING_FRAME_QUEUE_SECONDS = 2;
const MAX_DECODED_FRAME_QUEUE_SECONDS = 3; const MAX_DECODED_FRAME_QUEUE_SECONDS = 3;
const MIN_PENDING_FRAME_QUEUE = 12; const MIN_PENDING_FRAME_QUEUE = 12;
const MIN_DECODED_FRAME_QUEUE = 24; const MIN_DECODED_FRAME_QUEUE = 24;
const IMAGE_DECODE_TIMEOUT_MS = 3000;
const FRAME_STALL_CHECK_MS = 1000;
const FRAME_STARTUP_STALL_RESET_MS = 10000;
const FRAME_STALL_RESET_MS = 6000;
const PLAYBACK_RESTART_COOLDOWN_MS = 8000;
const PLAYBACK_RESTART_DELAY_MS = 750;
const state = { const state = {
generation: 0, generation: 0,
streamGeneration: 0,
session: null, session: null,
websocket: null, websocket: null,
pendingFrames: [], pendingFrames: [],
@@ -47,6 +54,10 @@ const state = {
seekable: false, seekable: false,
isSeeking: false, isSeeking: false,
seekPreviewTime: 0, seekPreviewTime: 0,
frameWatchdogTimer: 0,
lastFramePacketAt: 0,
lastFramePaintedAt: 0,
lastPlaybackRestartAt: 0,
}; };
void loadRecentUrls(); void loadRecentUrls();
@@ -156,6 +167,7 @@ elements.seek.addEventListener('change', () => {
elements.audio.addEventListener('play', () => { elements.audio.addEventListener('play', () => {
startRenderLoop(); startRenderLoop();
startFrameWatchdog();
syncControlLabels(); syncControlLabels();
scheduleControlsHide(); scheduleControlsHide();
syncPlayhead(); syncPlayhead();
@@ -195,6 +207,12 @@ elements.audio.addEventListener('error', () => {
} }
}); });
window.addEventListener('online', () => {
if (state.session && !elements.audio.paused && isFrameStreamStale(FRAME_STALL_RESET_MS)) {
restartPlaybackStreams();
}
});
function startSession(session) { function startSession(session) {
state.session = session; state.session = session;
state.playbackOffset = Number(session.seekSeconds) || 0; state.playbackOffset = Number(session.seekSeconds) || 0;
@@ -203,6 +221,8 @@ function startSession(session) {
state.isSeeking = false; state.isSeeking = false;
state.seekPreviewTime = state.playbackOffset; state.seekPreviewTime = state.playbackOffset;
state.frameCount = 0; state.frameCount = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
clearFrameQueue(); clearFrameQueue();
syncControlLabels(); syncControlLabels();
syncPlayhead(); syncPlayhead();
@@ -211,6 +231,7 @@ function startSession(session) {
clearPlayerMessage(); clearPlayerMessage();
connectPlaybackStreams(); connectPlaybackStreams();
startFrameWatchdog();
void refreshSessionMetadata(session.id); void refreshSessionMetadata(session.id);
} }
@@ -219,8 +240,12 @@ function connectPlaybackStreams() {
return; return;
} }
const generation = state.generation; const streamGeneration = state.streamGeneration + 1;
const session = state.session; const session = state.session;
const now = Date.now();
state.streamGeneration = streamGeneration;
state.lastPlaybackRestartAt = now;
state.lastFramePacketAt = now;
if (state.websocket) { if (state.websocket) {
state.websocket.close(1000, 'client reconnecting'); state.websocket.close(1000, 'client reconnecting');
@@ -234,7 +259,7 @@ function connectPlaybackStreams() {
state.websocket = websocket; state.websocket = websocket;
websocket.addEventListener('message', (event) => { websocket.addEventListener('message', (event) => {
if (generation !== state.generation) { if (streamGeneration !== state.streamGeneration) {
return; return;
} }
@@ -243,18 +268,18 @@ function connectPlaybackStreams() {
return; return;
} }
void handleFramePacket(event.data, generation); handleFramePacket(event.data, streamGeneration);
}); });
websocket.addEventListener('close', () => { websocket.addEventListener('close', () => {
if (generation === state.generation && state.session?.id === session.id) { if (streamGeneration === state.streamGeneration && state.session?.id === session.id) {
showPlayerMessage('Stream ended'); showPlayerMessage('Stream ended');
setControlsVisible(true); setControlsVisible(true);
} }
}); });
websocket.addEventListener('error', () => { websocket.addEventListener('error', () => {
if (generation === state.generation && state.session?.id === session.id) { if (streamGeneration === state.streamGeneration && state.session?.id === session.id) {
showPlayerMessage('Stream failed'); showPlayerMessage('Stream failed');
setControlsVisible(true); setControlsVisible(true);
} }
@@ -277,18 +302,94 @@ async function playAudio() {
} }
} }
function handleFramePacket(packet, generation) { function startFrameWatchdog() {
if (state.frameWatchdogTimer) {
return;
}
state.frameWatchdogTimer = window.setInterval(checkFrameWatchdog, FRAME_STALL_CHECK_MS);
}
function clearFrameWatchdog() {
if (!state.frameWatchdogTimer) {
return;
}
window.clearInterval(state.frameWatchdogTimer);
state.frameWatchdogTimer = 0;
}
function checkFrameWatchdog() {
if (!state.session || state.isSeeking || document.hidden || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) {
return;
}
const resetAfterMs = state.lastFramePaintedAt > 0 ? FRAME_STALL_RESET_MS : FRAME_STARTUP_STALL_RESET_MS;
if (!isFrameStreamStale(resetAfterMs)) {
return;
}
restartPlaybackStreams();
}
function isFrameStreamStale(resetAfterMs) {
const now = Date.now();
const lastFrameAt = state.lastFramePacketAt || state.lastPlaybackRestartAt || now;
return now - lastFrameAt >= resetAfterMs && now - state.lastPlaybackRestartAt >= PLAYBACK_RESTART_COOLDOWN_MS;
}
function restartPlaybackStreams() {
if (!state.session) {
return;
}
if (state.seekable && Number.isFinite(state.duration) && state.duration > 0) {
state.lastPlaybackRestartAt = Date.now();
void seekTo(getVisiblePlaybackTime());
return;
}
const streamGeneration = state.streamGeneration + 1;
const sessionId = state.session.id;
const now = Date.now();
state.streamGeneration = streamGeneration;
state.lastPlaybackRestartAt = now;
state.lastFramePacketAt = now;
elements.loader.hidden = false;
clearPlayerMessage();
clearFrameQueue({ keepCurrent: true });
if (state.websocket) {
state.websocket.close(1000, 'frame stream stalled');
state.websocket = null;
}
elements.audio.pause();
elements.audio.removeAttribute('src');
elements.audio.load();
window.setTimeout(() => {
if (state.session?.id === sessionId && state.streamGeneration === streamGeneration) {
connectPlaybackStreams();
}
}, PLAYBACK_RESTART_DELAY_MS);
}
function handleFramePacket(packet, streamGeneration) {
if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) { if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
return; return;
} }
const timestamp = new DataView(packet, 0, 8).getFloat64(0, true); const timestamp = new DataView(packet, 0, 8).getFloat64(0, true);
if (generation !== state.generation || isLateFrame(timestamp)) { if (streamGeneration !== state.streamGeneration || isLateFrame(timestamp)) {
return; return;
} }
state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), generation }); state.lastFramePacketAt = Date.now();
state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), streamGeneration });
trimPendingFrameQueue(); trimPendingFrameQueue();
void pumpFrameDecodeQueue(); void pumpFrameDecodeQueue();
} }
@@ -362,6 +463,7 @@ function drawReadyFrames() {
state.currentBitmap = frameToDraw.bitmap; state.currentBitmap = frameToDraw.bitmap;
drawBitmap(frameToDraw.bitmap); drawBitmap(frameToDraw.bitmap);
state.lastFramePaintedAt = Date.now();
elements.loader.hidden = true; elements.loader.hidden = true;
clearPlayerMessage(); clearPlayerMessage();
} }
@@ -395,19 +497,19 @@ async function pumpFrameDecodeQueue() {
return; return;
} }
if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) { if (frame.streamGeneration !== state.streamGeneration || isLateFrame(frame.timestamp)) {
continue; continue;
} }
let bitmap; let bitmap;
try { try {
bitmap = await decodeImage(new Blob([frame.jpeg], { type: 'image/jpeg' })); bitmap = await decodeImageWithTimeout(new Blob([frame.jpeg], { type: 'image/jpeg' }));
} catch { } catch {
continue; continue;
} }
if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) { if (frame.streamGeneration !== state.streamGeneration || isLateFrame(frame.timestamp)) {
releaseImage(bitmap); releaseImage(bitmap);
continue; continue;
} }
@@ -501,6 +603,7 @@ function isLateFrame(timestamp) {
function stopSession({ showEntry: shouldShowEntry = true } = {}) { function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.generation += 1; state.generation += 1;
state.streamGeneration += 1;
state.session = null; state.session = null;
state.frameCount = 0; state.frameCount = 0;
state.playbackOffset = 0; state.playbackOffset = 0;
@@ -508,7 +611,10 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.seekable = false; state.seekable = false;
state.isSeeking = false; state.isSeeking = false;
state.seekPreviewTime = 0; state.seekPreviewTime = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
clearHideControlsTimer(); clearHideControlsTimer();
clearFrameWatchdog();
if (state.websocket) { if (state.websocket) {
state.websocket.close(1000, 'client stopped'); state.websocket.close(1000, 'client stopped');
@@ -537,7 +643,7 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
} }
} }
function clearFrameQueue() { function clearFrameQueue({ keepCurrent = false } = {}) {
state.pendingFrames = []; state.pendingFrames = [];
for (const frame of state.frames) { for (const frame of state.frames) {
@@ -546,12 +652,45 @@ function clearFrameQueue() {
state.frames = []; state.frames = [];
if (state.currentBitmap) { if (!keepCurrent && state.currentBitmap) {
releaseImage(state.currentBitmap); releaseImage(state.currentBitmap);
state.currentBitmap = null; state.currentBitmap = null;
} }
} }
async function decodeImageWithTimeout(blob) {
let timedOut = false;
let timeoutId = 0;
const decodePromise = decodeImage(blob).then(
(image) => {
if (timedOut) {
releaseImage(image);
return null;
}
return image;
},
() => null,
);
const timeoutPromise = new Promise((resolve) => {
timeoutId = window.setTimeout(() => {
timedOut = true;
resolve(null);
}, IMAGE_DECODE_TIMEOUT_MS);
});
const image = await Promise.race([decodePromise, timeoutPromise]);
window.clearTimeout(timeoutId);
if (!image) {
throw new Error('Image decode failed.');
}
return image;
}
async function decodeImage(blob) { async function decodeImage(blob) {
if ('createImageBitmap' in window) { if ('createImageBitmap' in window) {
return createImageBitmap(blob); return createImageBitmap(blob);
@@ -596,10 +735,13 @@ async function seekTo(value) {
const sessionId = state.session.id; const sessionId = state.session.id;
state.generation = generation; state.generation = generation;
state.streamGeneration += 1;
state.playbackOffset = targetTime; state.playbackOffset = targetTime;
state.seekPreviewTime = targetTime; state.seekPreviewTime = targetTime;
state.isSeeking = false; state.isSeeking = false;
state.frameCount = 0; state.frameCount = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
elements.loader.hidden = false; elements.loader.hidden = false;
clearPlayerMessage(); clearPlayerMessage();
clearFrameQueue(); clearFrameQueue();