Preserve playback position on watchdog recovery

This commit is contained in:
2026-06-12 19:47:02 -07:00
parent c4fb23d971
commit 53487087a2
2 changed files with 201 additions and 29 deletions

View File

@@ -53,10 +53,13 @@ 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 FRAME_STARTUP_STALL_RESET_MS = 15000;
const FRAME_STALL_RESET_MS = 12000;
const FRAME_PACKET_ACTIVITY_GRACE_MS = 5000;
const FRAME_UNPAINTED_PACKET_RESET_MS = 20000;
const PLAYBACK_RESTART_COOLDOWN_MS = 15000;
const PLAYBACK_RESTART_DELAY_MS = 750;
const MIN_RECOVERY_RESUME_SECONDS = 2;
const METADATA_REFRESH_ATTEMPTS = 20;
const METADATA_REFRESH_INTERVAL_MS = 650;
@@ -82,6 +85,7 @@ const state = {
seekable: false,
isSeeking: false,
seekPointerId: null,
isRestartingPlayback: false,
seekPreviewTime: 0,
lastSeekCommitTime: null,
lastSeekCommitAt: 0,
@@ -385,7 +389,7 @@ window.addEventListener('online', () => {
const stallReason = getFrameStallReason();
if (state.session && !elements.audio.paused && stallReason) {
restartPlaybackStreams(`network_recovered_${stallReason}`);
void restartPlaybackStreams(`network_recovered_${stallReason}`);
}
});
@@ -393,7 +397,7 @@ document.addEventListener('visibilitychange', () => {
const stallReason = getFrameStallReason();
if (!document.hidden && state.session && !elements.audio.paused && stallReason) {
restartPlaybackStreams(`visible_${stallReason}`);
void restartPlaybackStreams(`visible_${stallReason}`);
}
});
@@ -505,7 +509,7 @@ function clearFrameWatchdog() {
}
function checkFrameWatchdog() {
if (!state.session || state.isSeeking || document.hidden || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) {
if (!state.session || state.isSeeking || state.isRestartingPlayback || document.hidden || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) {
return;
}
@@ -515,13 +519,13 @@ function checkFrameWatchdog() {
return;
}
restartPlaybackStreams(stallReason);
void restartPlaybackStreams(stallReason);
}
function getFrameStallReason() {
const now = Date.now();
if (!state.session || state.isSeeking || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) {
if (!state.session || state.isSeeking || state.isRestartingPlayback || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) {
return '';
}
@@ -529,6 +533,21 @@ function getFrameStallReason() {
return '';
}
const hasRecentPackets = state.lastFramePacketAt > state.lastFramePaintedAt
&& now - state.lastFramePacketAt < FRAME_PACKET_ACTIVITY_GRACE_MS;
if (hasRecentPackets) {
const unpaintedSince = state.lastFramePaintedAt > state.lastPlaybackRestartAt
? state.lastFramePaintedAt
: state.lastPlaybackRestartAt;
if (now - unpaintedSince < FRAME_UNPAINTED_PACKET_RESET_MS) {
return '';
}
return 'frame_packets_unpainted';
}
if (state.lastFramePaintedAt < state.lastPlaybackRestartAt) {
return now - state.lastPlaybackRestartAt >= FRAME_STARTUP_STALL_RESET_MS ? 'frame_startup_stalled' : '';
}
@@ -536,8 +555,8 @@ function getFrameStallReason() {
return now - state.lastFramePaintedAt >= FRAME_STALL_RESET_MS ? 'frame_paint_stalled' : '';
}
function restartPlaybackStreams(reason = 'frame_stalled') {
if (!state.session) {
async function restartPlaybackStreams(reason = 'frame_stalled') {
if (!state.session || state.isRestartingPlayback) {
return;
}
@@ -545,9 +564,27 @@ function restartPlaybackStreams(reason = 'frame_stalled') {
noteClientTelemetry('playbackRestarts');
sendClientTelemetry({ force: true, reason });
if (state.seekable && Number.isFinite(state.duration) && state.duration > 0) {
state.lastPlaybackRestartAt = Date.now();
void seekTo(getVisiblePlaybackTime());
state.isRestartingPlayback = true;
try {
const resumeTime = getVisiblePlaybackTime();
if (resumeTime >= MIN_RECOVERY_RESUME_SECONDS) {
const resumed = await resumePlaybackAt(resumeTime, reason);
if (resumed) {
return;
}
}
restartPlaybackFromCurrentSource(reason);
} finally {
state.isRestartingPlayback = false;
}
}
function restartPlaybackFromCurrentSource(reason) {
if (!state.session) {
return;
}
@@ -578,6 +615,67 @@ function restartPlaybackStreams(reason = 'frame_stalled') {
}, PLAYBACK_RESTART_DELAY_MS);
}
async function resumePlaybackAt(value, reason) {
const sessionId = state.session?.id;
if (!sessionId) {
return false;
}
const duration = state.duration;
const targetTime = clampNumber(value, 0, Number.isFinite(duration) ? duration : Number.MAX_SAFE_INTEGER);
try {
const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}/seek`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time: targetTime, allowBestEffort: true, reason }),
});
const payload = await response.json();
if (response.status === 409) {
return false;
}
if (!response.ok) {
throw new Error(payload.error ?? 'Recovery seek failed.');
}
if (state.session?.id !== sessionId) {
return true;
}
state.generation += 1;
state.streamGeneration += 1;
state.session = payload;
updateSessionMetadata(payload);
state.playbackOffset = Number.isFinite(payload.seekSeconds) ? payload.seekSeconds : targetTime;
state.seekPreviewTime = state.playbackOffset;
state.isSeeking = false;
state.frameCount = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
elements.loader.hidden = false;
clearPlayerMessage();
clearFrameQueue({ keepCurrent: true });
syncPlayhead();
if (state.websocket) {
state.websocket.close(1000, 'client recovery seek');
state.websocket = null;
}
elements.audio.pause();
elements.audio.removeAttribute('src');
elements.audio.load();
connectPlaybackStreams();
return true;
} catch (error) {
console.warn('Playback recovery seek failed', error);
return false;
}
}
function handleFramePacket(packet, streamGeneration) {
if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
return;
@@ -990,6 +1088,7 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.seekable = false;
state.isSeeking = false;
state.seekPointerId = null;
state.isRestartingPlayback = false;
state.seekPreviewTime = 0;
state.lastSeekCommitTime = null;
state.lastSeekCommitAt = 0;