Enable recorded stream seeking

This commit is contained in:
2026-05-26 19:11:19 -07:00
parent 5e2a2e1de7
commit 8b74d25954
5 changed files with 56 additions and 13 deletions

View File

@@ -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`.

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;
},