diff --git a/public/app.js b/public/app.js index f685136..af09d34 100644 --- a/public/app.js +++ b/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; diff --git a/server/index.js b/server/index.js index c11bd61..78e563a 100644 --- a/server/index.js +++ b/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 JPEG_SOI = Buffer.from([0xff, 0xd8]); 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 = { fps: clampInteger(process.env.DEFAULT_FPS, 24, 1, 30), @@ -169,8 +183,10 @@ app.post('/api/session', async (request, response) => { options, duration: null, metadataStatus, + metadataProbePromise: null, seekSeconds: 0, seekGeneration: 0, + forceSplitPlayback: false, createdAt: Date.now(), lastUsedAt: Date.now(), }); @@ -201,7 +217,7 @@ app.get('/api/session/:sessionId', (request, response) => { 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); if (!session) { @@ -209,11 +225,6 @@ app.post('/api/session/:sessionId/seek', (request, response) => { return; } - if (!isSessionSeekable(session)) { - response.status(409).json({ error: 'This stream is not seekable.' }); - return; - } - const requestedSeconds = Number(request.body?.time); if (!Number.isFinite(requestedSeconds)) { @@ -221,21 +232,42 @@ app.post('/api/session/:sessionId/seek', (request, response) => { 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 stopReason = allowBestEffort ? 'client_recovery_seek' : 'client_seek'; session.seekSeconds = seekSeconds; session.seekGeneration += 1; session.lastUsedAt = Date.now(); + if (!canSeekExactly && canResumeBestEffort && PLAYBACK_CONNECTION_MODE === 'relay') { + session.forceSplitPlayback = true; + } + if (playback && !playback.closed) { - playback.stop('client_seek'); + playback.stop(stopReason); if (playbacks.get(session.id) === playback) { 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)); }); @@ -701,7 +733,26 @@ function hasRecordedDuration(session) { 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) { + if (session.forceSplitPlayback) { + return 'split'; + } + if (PLAYBACK_CONNECTION_MODE === 'relay' && hasRecordedDuration(session)) { return 'split'; } @@ -710,26 +761,48 @@ function getSessionPlaybackConnectionMode(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); if (current !== session) { - return; + return duration; } session.duration = duration; session.metadataStatus = Number.isFinite(duration) ? 'ready' : 'unavailable'; + return duration; }).catch((error) => { const current = sessions.get(session.id); - if (current !== session) { - return; + if (current === session) { + session.duration = null; + session.metadataStatus = 'unavailable'; + logWarn(`duration probe failed id=${shortId(session.id)} error=${oneLine(error.message)}`); } - session.duration = null; - session.metadataStatus = 'unavailable'; - 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) {