Enable recorded stream seeking
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user