From 47dae48673acf827f981c41063b39c2c2d8769f4 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Mon, 4 May 2026 20:22:21 -0700 Subject: [PATCH] frame watchdog in case it gets stuck --- public/app.js | 168 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 155 insertions(+), 13 deletions(-) diff --git a/public/app.js b/public/app.js index 94ca6c2..f9bfec9 100644 --- a/public/app.js +++ b/public/app.js @@ -28,9 +28,16 @@ const MAX_PENDING_FRAME_QUEUE_SECONDS = 2; const MAX_DECODED_FRAME_QUEUE_SECONDS = 3; const MIN_PENDING_FRAME_QUEUE = 12; 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 = { generation: 0, + streamGeneration: 0, session: null, websocket: null, pendingFrames: [], @@ -47,6 +54,10 @@ const state = { seekable: false, isSeeking: false, seekPreviewTime: 0, + frameWatchdogTimer: 0, + lastFramePacketAt: 0, + lastFramePaintedAt: 0, + lastPlaybackRestartAt: 0, }; void loadRecentUrls(); @@ -156,6 +167,7 @@ elements.seek.addEventListener('change', () => { elements.audio.addEventListener('play', () => { startRenderLoop(); + startFrameWatchdog(); syncControlLabels(); scheduleControlsHide(); 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) { state.session = session; state.playbackOffset = Number(session.seekSeconds) || 0; @@ -203,6 +221,8 @@ function startSession(session) { state.isSeeking = false; state.seekPreviewTime = state.playbackOffset; state.frameCount = 0; + state.lastFramePacketAt = 0; + state.lastFramePaintedAt = 0; clearFrameQueue(); syncControlLabels(); syncPlayhead(); @@ -211,6 +231,7 @@ function startSession(session) { clearPlayerMessage(); connectPlaybackStreams(); + startFrameWatchdog(); void refreshSessionMetadata(session.id); } @@ -219,8 +240,12 @@ function connectPlaybackStreams() { return; } - const generation = state.generation; + const streamGeneration = state.streamGeneration + 1; const session = state.session; + const now = Date.now(); + state.streamGeneration = streamGeneration; + state.lastPlaybackRestartAt = now; + state.lastFramePacketAt = now; if (state.websocket) { state.websocket.close(1000, 'client reconnecting'); @@ -234,7 +259,7 @@ function connectPlaybackStreams() { state.websocket = websocket; websocket.addEventListener('message', (event) => { - if (generation !== state.generation) { + if (streamGeneration !== state.streamGeneration) { return; } @@ -243,18 +268,18 @@ function connectPlaybackStreams() { return; } - void handleFramePacket(event.data, generation); + handleFramePacket(event.data, streamGeneration); }); websocket.addEventListener('close', () => { - if (generation === state.generation && state.session?.id === session.id) { + if (streamGeneration === state.streamGeneration && state.session?.id === session.id) { showPlayerMessage('Stream ended'); setControlsVisible(true); } }); websocket.addEventListener('error', () => { - if (generation === state.generation && state.session?.id === session.id) { + if (streamGeneration === state.streamGeneration && state.session?.id === session.id) { showPlayerMessage('Stream failed'); 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) { return; } const timestamp = new DataView(packet, 0, 8).getFloat64(0, true); - if (generation !== state.generation || isLateFrame(timestamp)) { + if (streamGeneration !== state.streamGeneration || isLateFrame(timestamp)) { return; } - state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), generation }); + state.lastFramePacketAt = Date.now(); + state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), streamGeneration }); trimPendingFrameQueue(); void pumpFrameDecodeQueue(); } @@ -362,6 +463,7 @@ function drawReadyFrames() { state.currentBitmap = frameToDraw.bitmap; drawBitmap(frameToDraw.bitmap); + state.lastFramePaintedAt = Date.now(); elements.loader.hidden = true; clearPlayerMessage(); } @@ -395,19 +497,19 @@ async function pumpFrameDecodeQueue() { return; } - if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) { + if (frame.streamGeneration !== state.streamGeneration || isLateFrame(frame.timestamp)) { continue; } let bitmap; try { - bitmap = await decodeImage(new Blob([frame.jpeg], { type: 'image/jpeg' })); + bitmap = await decodeImageWithTimeout(new Blob([frame.jpeg], { type: 'image/jpeg' })); } catch { continue; } - if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) { + if (frame.streamGeneration !== state.streamGeneration || isLateFrame(frame.timestamp)) { releaseImage(bitmap); continue; } @@ -501,6 +603,7 @@ function isLateFrame(timestamp) { function stopSession({ showEntry: shouldShowEntry = true } = {}) { state.generation += 1; + state.streamGeneration += 1; state.session = null; state.frameCount = 0; state.playbackOffset = 0; @@ -508,7 +611,10 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) { state.seekable = false; state.isSeeking = false; state.seekPreviewTime = 0; + state.lastFramePacketAt = 0; + state.lastFramePaintedAt = 0; clearHideControlsTimer(); + clearFrameWatchdog(); if (state.websocket) { state.websocket.close(1000, 'client stopped'); @@ -537,7 +643,7 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) { } } -function clearFrameQueue() { +function clearFrameQueue({ keepCurrent = false } = {}) { state.pendingFrames = []; for (const frame of state.frames) { @@ -546,12 +652,45 @@ function clearFrameQueue() { state.frames = []; - if (state.currentBitmap) { + if (!keepCurrent && state.currentBitmap) { releaseImage(state.currentBitmap); 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) { if ('createImageBitmap' in window) { return createImageBitmap(blob); @@ -596,10 +735,13 @@ async function seekTo(value) { const sessionId = state.session.id; state.generation = generation; + state.streamGeneration += 1; state.playbackOffset = targetTime; state.seekPreviewTime = targetTime; state.isSeeking = false; state.frameCount = 0; + state.lastFramePacketAt = 0; + state.lastFramePaintedAt = 0; elements.loader.hidden = false; clearPlayerMessage(); clearFrameQueue();