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 MIN_DECODED_FRAME_QUEUE = 24;
|
||||||
const IMAGE_DECODE_TIMEOUT_MS = 3000;
|
const IMAGE_DECODE_TIMEOUT_MS = 3000;
|
||||||
const FRAME_STALL_CHECK_MS = 1000;
|
const FRAME_STALL_CHECK_MS = 1000;
|
||||||
const FRAME_STARTUP_STALL_RESET_MS = 10000;
|
const FRAME_STARTUP_STALL_RESET_MS = 15000;
|
||||||
const FRAME_STALL_RESET_MS = 6000;
|
const FRAME_STALL_RESET_MS = 12000;
|
||||||
const PLAYBACK_RESTART_COOLDOWN_MS = 8000;
|
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 PLAYBACK_RESTART_DELAY_MS = 750;
|
||||||
|
const MIN_RECOVERY_RESUME_SECONDS = 2;
|
||||||
const METADATA_REFRESH_ATTEMPTS = 20;
|
const METADATA_REFRESH_ATTEMPTS = 20;
|
||||||
const METADATA_REFRESH_INTERVAL_MS = 650;
|
const METADATA_REFRESH_INTERVAL_MS = 650;
|
||||||
|
|
||||||
@@ -82,6 +85,7 @@ const state = {
|
|||||||
seekable: false,
|
seekable: false,
|
||||||
isSeeking: false,
|
isSeeking: false,
|
||||||
seekPointerId: null,
|
seekPointerId: null,
|
||||||
|
isRestartingPlayback: false,
|
||||||
seekPreviewTime: 0,
|
seekPreviewTime: 0,
|
||||||
lastSeekCommitTime: null,
|
lastSeekCommitTime: null,
|
||||||
lastSeekCommitAt: 0,
|
lastSeekCommitAt: 0,
|
||||||
@@ -385,7 +389,7 @@ window.addEventListener('online', () => {
|
|||||||
const stallReason = getFrameStallReason();
|
const stallReason = getFrameStallReason();
|
||||||
|
|
||||||
if (state.session && !elements.audio.paused && stallReason) {
|
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();
|
const stallReason = getFrameStallReason();
|
||||||
|
|
||||||
if (!document.hidden && state.session && !elements.audio.paused && stallReason) {
|
if (!document.hidden && state.session && !elements.audio.paused && stallReason) {
|
||||||
restartPlaybackStreams(`visible_${stallReason}`);
|
void restartPlaybackStreams(`visible_${stallReason}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,7 +509,7 @@ function clearFrameWatchdog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkFrameWatchdog() {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,13 +519,13 @@ function checkFrameWatchdog() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
restartPlaybackStreams(stallReason);
|
void restartPlaybackStreams(stallReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFrameStallReason() {
|
function getFrameStallReason() {
|
||||||
const now = Date.now();
|
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 '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,6 +533,21 @@ function getFrameStallReason() {
|
|||||||
return '';
|
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) {
|
if (state.lastFramePaintedAt < state.lastPlaybackRestartAt) {
|
||||||
return now - state.lastPlaybackRestartAt >= FRAME_STARTUP_STALL_RESET_MS ? 'frame_startup_stalled' : '';
|
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' : '';
|
return now - state.lastFramePaintedAt >= FRAME_STALL_RESET_MS ? 'frame_paint_stalled' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function restartPlaybackStreams(reason = 'frame_stalled') {
|
async function restartPlaybackStreams(reason = 'frame_stalled') {
|
||||||
if (!state.session) {
|
if (!state.session || state.isRestartingPlayback) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,9 +564,27 @@ function restartPlaybackStreams(reason = 'frame_stalled') {
|
|||||||
noteClientTelemetry('playbackRestarts');
|
noteClientTelemetry('playbackRestarts');
|
||||||
sendClientTelemetry({ force: true, reason });
|
sendClientTelemetry({ force: true, reason });
|
||||||
|
|
||||||
if (state.seekable && Number.isFinite(state.duration) && state.duration > 0) {
|
state.isRestartingPlayback = true;
|
||||||
state.lastPlaybackRestartAt = Date.now();
|
|
||||||
void seekTo(getVisiblePlaybackTime());
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +615,67 @@ function restartPlaybackStreams(reason = 'frame_stalled') {
|
|||||||
}, PLAYBACK_RESTART_DELAY_MS);
|
}, 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) {
|
function handleFramePacket(packet, streamGeneration) {
|
||||||
if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
|
if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
|
||||||
return;
|
return;
|
||||||
@@ -990,6 +1088,7 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
|
|||||||
state.seekable = false;
|
state.seekable = false;
|
||||||
state.isSeeking = false;
|
state.isSeeking = false;
|
||||||
state.seekPointerId = null;
|
state.seekPointerId = null;
|
||||||
|
state.isRestartingPlayback = false;
|
||||||
state.seekPreviewTime = 0;
|
state.seekPreviewTime = 0;
|
||||||
state.lastSeekCommitTime = null;
|
state.lastSeekCommitTime = null;
|
||||||
state.lastSeekCommitAt = 0;
|
state.lastSeekCommitAt = 0;
|
||||||
|
|||||||
103
server/index.js
103
server/index.js
@@ -46,6 +46,20 @@ const MAX_YT_DLP_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|||||||
const RELAY_BRANCH_PAUSE_BYTES = Math.floor(MAX_RELAY_BRANCH_QUEUE_BYTES / 2);
|
const RELAY_BRANCH_PAUSE_BYTES = Math.floor(MAX_RELAY_BRANCH_QUEUE_BYTES / 2);
|
||||||
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
|
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
|
||||||
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
|
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
|
||||||
|
const BEST_EFFORT_RESUME_MAX_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
const RECORDED_MEDIA_EXTENSIONS = new Set([
|
||||||
|
'.avi',
|
||||||
|
'.flv',
|
||||||
|
'.m4v',
|
||||||
|
'.mkv',
|
||||||
|
'.mov',
|
||||||
|
'.mp4',
|
||||||
|
'.mpeg',
|
||||||
|
'.mpg',
|
||||||
|
'.ogv',
|
||||||
|
'.webm',
|
||||||
|
'.wmv',
|
||||||
|
]);
|
||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
fps: clampInteger(process.env.DEFAULT_FPS, 24, 1, 30),
|
fps: clampInteger(process.env.DEFAULT_FPS, 24, 1, 30),
|
||||||
@@ -169,8 +183,10 @@ app.post('/api/session', async (request, response) => {
|
|||||||
options,
|
options,
|
||||||
duration: null,
|
duration: null,
|
||||||
metadataStatus,
|
metadataStatus,
|
||||||
|
metadataProbePromise: null,
|
||||||
seekSeconds: 0,
|
seekSeconds: 0,
|
||||||
seekGeneration: 0,
|
seekGeneration: 0,
|
||||||
|
forceSplitPlayback: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
lastUsedAt: Date.now(),
|
lastUsedAt: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -201,7 +217,7 @@ app.get('/api/session/:sessionId', (request, response) => {
|
|||||||
response.json(formatSessionPayload(session));
|
response.json(formatSessionPayload(session));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/session/:sessionId/seek', (request, response) => {
|
app.post('/api/session/:sessionId/seek', async (request, response) => {
|
||||||
const session = getSession(request.params.sessionId);
|
const session = getSession(request.params.sessionId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -209,11 +225,6 @@ app.post('/api/session/:sessionId/seek', (request, response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSessionSeekable(session)) {
|
|
||||||
response.status(409).json({ error: 'This stream is not seekable.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedSeconds = Number(request.body?.time);
|
const requestedSeconds = Number(request.body?.time);
|
||||||
|
|
||||||
if (!Number.isFinite(requestedSeconds)) {
|
if (!Number.isFinite(requestedSeconds)) {
|
||||||
@@ -221,21 +232,42 @@ app.post('/api/session/:sessionId/seek', (request, response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekSeconds = clampNumber(requestedSeconds, 0, session.duration);
|
const allowBestEffort = request.body?.allowBestEffort === true;
|
||||||
|
|
||||||
|
if (!isSessionSeekable(session) && allowBestEffort && session.metadataStatus !== 'unavailable') {
|
||||||
|
await ensureSessionDuration(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSeekExactly = isSessionSeekable(session);
|
||||||
|
const canResumeBestEffort = allowBestEffort && canBestEffortResumeWithoutDuration(session);
|
||||||
|
|
||||||
|
if (!canSeekExactly && !canResumeBestEffort) {
|
||||||
|
response.status(409).json({ error: 'This stream is not seekable.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekSeconds = canSeekExactly
|
||||||
|
? clampNumber(requestedSeconds, 0, session.duration)
|
||||||
|
: clampNumber(requestedSeconds, 0, BEST_EFFORT_RESUME_MAX_SECONDS);
|
||||||
const playback = playbacks.get(session.id);
|
const playback = playbacks.get(session.id);
|
||||||
|
const stopReason = allowBestEffort ? 'client_recovery_seek' : 'client_seek';
|
||||||
|
|
||||||
session.seekSeconds = seekSeconds;
|
session.seekSeconds = seekSeconds;
|
||||||
session.seekGeneration += 1;
|
session.seekGeneration += 1;
|
||||||
session.lastUsedAt = Date.now();
|
session.lastUsedAt = Date.now();
|
||||||
|
|
||||||
|
if (!canSeekExactly && canResumeBestEffort && PLAYBACK_CONNECTION_MODE === 'relay') {
|
||||||
|
session.forceSplitPlayback = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (playback && !playback.closed) {
|
if (playback && !playback.closed) {
|
||||||
playback.stop('client_seek');
|
playback.stop(stopReason);
|
||||||
if (playbacks.get(session.id) === playback) {
|
if (playbacks.get(session.id) === playback) {
|
||||||
playbacks.delete(session.id);
|
playbacks.delete(session.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logInfo(`session seeked id=${shortId(session.id)} time=${seekSeconds.toFixed(3)} duration=${session.duration.toFixed(3)}`);
|
logInfo(`session seeked id=${shortId(session.id)} reason=${stopReason} time=${seekSeconds.toFixed(3)} duration=${Number.isFinite(session.duration) ? session.duration.toFixed(3) : 'unknown'} exact=${canSeekExactly}`);
|
||||||
response.json(formatSessionPayload(session));
|
response.json(formatSessionPayload(session));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -701,7 +733,26 @@ function hasRecordedDuration(session) {
|
|||||||
return Number.isFinite(session.duration) && session.duration > 0;
|
return Number.isFinite(session.duration) && session.duration > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canBestEffortResumeWithoutDuration(session) {
|
||||||
|
return session.sourceKind === 'youtube'
|
||||||
|
|| isLikelyRecordedMediaUrl(session.originalUrl)
|
||||||
|
|| isLikelyRecordedMediaUrl(session.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyRecordedMediaUrl(value) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return RECORDED_MEDIA_EXTENSIONS.has(path.extname(parsed.pathname).toLowerCase());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionPlaybackConnectionMode(session) {
|
function getSessionPlaybackConnectionMode(session) {
|
||||||
|
if (session.forceSplitPlayback) {
|
||||||
|
return 'split';
|
||||||
|
}
|
||||||
|
|
||||||
if (PLAYBACK_CONNECTION_MODE === 'relay' && hasRecordedDuration(session)) {
|
if (PLAYBACK_CONNECTION_MODE === 'relay' && hasRecordedDuration(session)) {
|
||||||
return 'split';
|
return 'split';
|
||||||
}
|
}
|
||||||
@@ -710,26 +761,48 @@ function getSessionPlaybackConnectionMode(session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startSessionMetadataProbe(session) {
|
function startSessionMetadataProbe(session) {
|
||||||
void probeSessionDuration(session).then((duration) => {
|
void ensureSessionDuration(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSessionDuration(session) {
|
||||||
|
if (hasRecordedDuration(session)) {
|
||||||
|
return Promise.resolve(session.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.metadataProbePromise) {
|
||||||
|
return session.metadataProbePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.metadataStatus = 'pending';
|
||||||
|
|
||||||
|
const probePromise = probeSessionDuration(session).then((duration) => {
|
||||||
const current = sessions.get(session.id);
|
const current = sessions.get(session.id);
|
||||||
|
|
||||||
if (current !== session) {
|
if (current !== session) {
|
||||||
return;
|
return duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.duration = duration;
|
session.duration = duration;
|
||||||
session.metadataStatus = Number.isFinite(duration) ? 'ready' : 'unavailable';
|
session.metadataStatus = Number.isFinite(duration) ? 'ready' : 'unavailable';
|
||||||
|
return duration;
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
const current = sessions.get(session.id);
|
const current = sessions.get(session.id);
|
||||||
|
|
||||||
if (current !== session) {
|
if (current === session) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.duration = null;
|
session.duration = null;
|
||||||
session.metadataStatus = 'unavailable';
|
session.metadataStatus = 'unavailable';
|
||||||
logWarn(`duration probe failed id=${shortId(session.id)} error=${oneLine(error.message)}`);
|
logWarn(`duration probe failed id=${shortId(session.id)} error=${oneLine(error.message)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}).finally(() => {
|
||||||
|
if (session.metadataProbePromise === probePromise) {
|
||||||
|
session.metadataProbePromise = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
session.metadataProbePromise = probePromise;
|
||||||
|
return probePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeSessionDuration(session) {
|
async function probeSessionDuration(session) {
|
||||||
|
|||||||
Reference in New Issue
Block a user