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 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user