Preserve playback position on watchdog recovery
This commit is contained in:
125
public/app.js
125
public/app.js
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user