diff --git a/AGENTS.md b/AGENTS.md index 4088170..a556b6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,8 @@ The regression history matters: Default code behavior is `split` when no mode is set. The Compose example uses `relay` because it is the mode to try for IPTV streams. +Finite-duration sessions are treated as recorded video and seekable. When the configured mode is `relay`, recorded sessions switch to the seek-capable `split` path once duration metadata is known; live/unknown-duration streams stay in relay mode. + ## ffmpeg Pipelines All ffmpeg command builders live near the bottom of `server/index.js`. diff --git a/README.md b/README.md index e41081d..e83c91d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ Available playback modes: - `relay`: One source connection from the backend, then the compressed input bytes are teed into separate audio and frame `ffmpeg` workers. This is intended for IPTV hosts that stop early or reject multiple active connections. - `single`: One source connection and one `ffmpeg` worker with both audio and frame outputs. This is the simplest one-connection fallback, but audio and frame delivery can affect each other. +Finite-duration streams are treated as recorded video and become seekable once metadata is available. If the app is configured for `relay`, recorded streams switch to the seek-capable `split` path; live or unknown-duration streams stay in relay mode. + Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `16777216`. ## Tuning diff --git a/public/app.js b/public/app.js index 2d08b3a..e578859 100644 --- a/public/app.js +++ b/public/app.js @@ -46,6 +46,8 @@ 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 METADATA_REFRESH_ATTEMPTS = 20; +const METADATA_REFRESH_INTERVAL_MS = 650; const state = { generation: 0, @@ -866,7 +868,7 @@ async function seekTo(value) { async function refreshSessionMetadata(sessionId) { const generation = state.generation; - for (let attempt = 0; attempt < 10; attempt += 1) { + for (let attempt = 0; attempt < METADATA_REFRESH_ATTEMPTS; attempt += 1) { if (generation !== state.generation || state.session?.id !== sessionId) { return; } @@ -894,7 +896,7 @@ async function refreshSessionMetadata(sessionId) { return; } - await delay(650); + await delay(METADATA_REFRESH_INTERVAL_MS); } } diff --git a/public/styles.css b/public/styles.css index 5f52d47..244a04b 100644 --- a/public/styles.css +++ b/public/styles.css @@ -286,12 +286,13 @@ canvas { top: -1.75rem; right: 1.1rem; left: 1.1rem; - height: 2.2rem; + height: 2.45rem; } .time-badge { position: absolute; top: 0; + pointer-events: none; min-width: 3.6rem; padding: 0.22rem 0.45rem; border: 1px solid var(--line); @@ -318,10 +319,10 @@ canvas { .seek-slider { position: absolute; right: 0; - bottom: 0.18rem; + bottom: 0; left: 0; width: 100%; - height: 1rem; + height: 1.35rem; margin: 0; appearance: none; background: transparent; diff --git a/server/index.js b/server/index.js index 36c8841..021cfe8 100644 --- a/server/index.js +++ b/server/index.js @@ -269,11 +269,14 @@ app.get('/audio/:sessionId', (request, response) => { return; } - if (PLAYBACK_CONNECTION_MODE === 'single' || PLAYBACK_CONNECTION_MODE === 'relay') { - getOrCreatePlayback(session).attachAudio(request, response); + const playbackMode = getSessionPlaybackConnectionMode(session); + + if (playbackMode === 'single' || playbackMode === 'relay') { + getOrCreatePlayback(session, playbackMode).attachAudio(request, response); return; } + stopSharedPlaybackIfNeeded(session, playbackMode); streamSplitAudio(request, response, session); }); @@ -301,11 +304,14 @@ wss.on('connection', (websocket, _request, sessionId) => { return; } - if (PLAYBACK_CONNECTION_MODE === 'single' || PLAYBACK_CONNECTION_MODE === 'relay') { - getOrCreatePlayback(session).attachFrames(websocket); + const playbackMode = getSessionPlaybackConnectionMode(session); + + if (playbackMode === 'single' || playbackMode === 'relay') { + getOrCreatePlayback(session, playbackMode).attachFrames(websocket); return; } + stopSharedPlaybackIfNeeded(session, playbackMode); streamSplitFrames(websocket, session); }); @@ -413,7 +419,19 @@ function formatSessionPayload(session) { } function isSessionSeekable(session) { - return PLAYBACK_CONNECTION_MODE !== 'relay' && Number.isFinite(session.duration) && session.duration > 0; + return hasRecordedDuration(session); +} + +function hasRecordedDuration(session) { + return Number.isFinite(session.duration) && session.duration > 0; +} + +function getSessionPlaybackConnectionMode(session) { + if (PLAYBACK_CONNECTION_MODE === 'relay' && hasRecordedDuration(session)) { + return 'split'; + } + + return PLAYBACK_CONNECTION_MODE; } function startSessionMetadataProbe(session) { @@ -666,18 +684,34 @@ function getSession(sessionId) { return session; } -function getOrCreatePlayback(session) { +function getOrCreatePlayback(session, playbackMode = getSessionPlaybackConnectionMode(session)) { const existing = playbacks.get(session.id); - if (existing && !existing.closed) { + if (existing && !existing.closed && existing.mode === playbackMode) { return existing; } - const playback = PLAYBACK_CONNECTION_MODE === 'relay' ? createRelayPlayback(session) : createPlayback(session); + stopSharedPlaybackIfNeeded(session, playbackMode); + + const playback = playbackMode === 'relay' ? createRelayPlayback(session) : createPlayback(session); playbacks.set(session.id, playback); return playback; } +function stopSharedPlaybackIfNeeded(session, playbackMode) { + const existing = playbacks.get(session.id); + + if (!existing || existing.closed || existing.mode === playbackMode) { + return; + } + + existing.stop('playback_mode_changed'); + + if (playbacks.get(session.id) === existing) { + playbacks.delete(session.id); + } +} + function streamSplitAudio(request, response, session) { const worker = createAudioWorker(session); const releaseWorker = once(worker.release); @@ -884,6 +918,7 @@ function createRelayPlayback(session) { const frameParser = createJpegFrameParser(handleJpegFrame); const playback = { + mode: 'relay', get closed() { return closed; }, @@ -1243,6 +1278,7 @@ function createPlayback(session) { const frameParser = createJpegFrameParser(handleJpegFrame); const playback = { + mode: 'single', get closed() { return closed; },