frame watchdog in case it gets stuck
This commit is contained in:
168
public/app.js
168
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();
|
||||
|
||||
Reference in New Issue
Block a user