adds seeking support

This commit is contained in:
2026-05-02 18:53:35 -07:00
parent acd73436a7
commit a3429dee85
4 changed files with 604 additions and 16 deletions

View File

@@ -13,6 +13,10 @@ const elements = {
loader: document.querySelector('#loader'), loader: document.querySelector('#loader'),
playerMessage: document.querySelector('#player-message'), playerMessage: document.querySelector('#player-message'),
controls: document.querySelector('#controls'), controls: document.querySelector('#controls'),
playhead: document.querySelector('#playhead'),
seek: document.querySelector('#seek'),
currentTime: document.querySelector('#current-time'),
totalTime: document.querySelector('#total-time'),
back: document.querySelector('#back'), back: document.querySelector('#back'),
playPause: document.querySelector('#play-pause'), playPause: document.querySelector('#play-pause'),
mute: document.querySelector('#mute'), mute: document.querySelector('#mute'),
@@ -31,6 +35,11 @@ const state = {
controlsVisible: true, controlsVisible: true,
hideControlsTimer: 0, hideControlsTimer: 0,
recentUrls: [], recentUrls: [],
playbackOffset: 0,
duration: null,
seekable: false,
isSeeking: false,
seekPreviewTime: 0,
}; };
void loadRecentUrls(); void loadRecentUrls();
@@ -109,15 +118,46 @@ elements.mute.addEventListener('click', () => {
syncControlLabels(); syncControlLabels();
}); });
elements.seek.addEventListener('pointerdown', () => {
if (!state.seekable) {
return;
}
state.isSeeking = true;
state.seekPreviewTime = Number(elements.seek.value);
setControlsVisible(true);
syncPlayhead();
});
elements.seek.addEventListener('input', () => {
if (!state.seekable) {
return;
}
state.isSeeking = true;
state.seekPreviewTime = Number(elements.seek.value);
syncPlayhead();
});
elements.seek.addEventListener('change', () => {
if (!state.seekable) {
return;
}
void seekTo(Number(elements.seek.value));
});
elements.audio.addEventListener('play', () => { elements.audio.addEventListener('play', () => {
startRenderLoop(); startRenderLoop();
syncControlLabels(); syncControlLabels();
scheduleControlsHide(); scheduleControlsHide();
syncPlayhead();
}); });
elements.audio.addEventListener('pause', () => { elements.audio.addEventListener('pause', () => {
syncControlLabels(); syncControlLabels();
setControlsVisible(true); setControlsVisible(true);
syncPlayhead();
}); });
elements.audio.addEventListener('playing', () => { elements.audio.addEventListener('playing', () => {
@@ -132,6 +172,15 @@ elements.audio.addEventListener('waiting', () => {
} }
}); });
elements.audio.addEventListener('timeupdate', () => {
syncPlayhead();
});
elements.audio.addEventListener('ended', () => {
syncPlayhead();
setControlsVisible(true);
});
elements.audio.addEventListener('error', () => { elements.audio.addEventListener('error', () => {
if (state.session) { if (state.session) {
showPlayerMessage('Audio failed'); showPlayerMessage('Audio failed');
@@ -140,17 +189,39 @@ elements.audio.addEventListener('error', () => {
}); });
function startSession(session) { function startSession(session) {
const generation = state.generation;
state.session = session; state.session = session;
state.playbackOffset = Number(session.seekSeconds) || 0;
state.duration = normalizeDuration(session.duration);
state.seekable = Boolean(session.seekable);
state.isSeeking = false;
state.seekPreviewTime = state.playbackOffset;
state.frameCount = 0; state.frameCount = 0;
clearFrameQueue(); clearFrameQueue();
syncControlLabels(); syncControlLabels();
syncPlayhead();
setControlsVisible(true); setControlsVisible(true);
elements.loader.hidden = false; elements.loader.hidden = false;
clearPlayerMessage(); clearPlayerMessage();
connectPlaybackStreams();
void refreshSessionMetadata(session.id);
}
function connectPlaybackStreams() {
if (!state.session) {
return;
}
const generation = state.generation;
const session = state.session;
if (state.websocket) {
state.websocket.close(1000, 'client reconnecting');
state.websocket = null;
}
const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}`; const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}?g=${session.seekGeneration ?? 0}`;
const websocket = new WebSocket(websocketUrl); const websocket = new WebSocket(websocketUrl);
websocket.binaryType = 'arraybuffer'; websocket.binaryType = 'arraybuffer';
state.websocket = websocket; state.websocket = websocket;
@@ -169,20 +240,21 @@ function startSession(session) {
}); });
websocket.addEventListener('close', () => { websocket.addEventListener('close', () => {
if (state.session?.id === session.id) { if (generation === state.generation && state.session?.id === session.id) {
showPlayerMessage('Stream ended'); showPlayerMessage('Stream ended');
setControlsVisible(true); setControlsVisible(true);
} }
}); });
websocket.addEventListener('error', () => { websocket.addEventListener('error', () => {
if (state.session?.id === session.id) { if (generation === state.generation && state.session?.id === session.id) {
showPlayerMessage('Stream failed'); showPlayerMessage('Stream failed');
setControlsVisible(true); setControlsVisible(true);
} }
}); });
elements.audio.src = `/audio/${session.id}`; elements.audio.pause();
elements.audio.src = `/audio/${session.id}?g=${session.seekGeneration ?? 0}`;
elements.audio.load(); elements.audio.load();
void playAudio(); void playAudio();
startRenderLoop(); startRenderLoop();
@@ -231,6 +303,10 @@ function handleControlMessage(rawMessage) {
setControlsVisible(true); setControlsVisible(true);
} }
if (message.type === 'ready') {
updateSessionMetadata(message);
}
if (message.type === 'end') { if (message.type === 'end') {
showPlayerMessage('Stream ended'); showPlayerMessage('Stream ended');
setControlsVisible(true); setControlsVisible(true);
@@ -244,6 +320,7 @@ function startRenderLoop() {
const render = () => { const render = () => {
drawReadyFrames(); drawReadyFrames();
syncPlayhead();
state.raf = requestAnimationFrame(render); state.raf = requestAnimationFrame(render);
}; };
@@ -309,6 +386,11 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.generation += 1; state.generation += 1;
state.session = null; state.session = null;
state.frameCount = 0; state.frameCount = 0;
state.playbackOffset = 0;
state.duration = null;
state.seekable = false;
state.isSeeking = false;
state.seekPreviewTime = 0;
clearHideControlsTimer(); clearHideControlsTimer();
if (state.websocket) { if (state.websocket) {
@@ -331,6 +413,7 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
clearPlayerMessage(); clearPlayerMessage();
setControlsVisible(true); setControlsVisible(true);
syncControlLabels(); syncControlLabels();
syncPlayhead();
if (shouldShowEntry) { if (shouldShowEntry) {
showEntry(); showEntry();
@@ -374,6 +457,191 @@ function releaseImage(image) {
} }
} }
async function seekTo(value) {
if (!state.session || !state.seekable) {
state.isSeeking = false;
syncPlayhead();
return;
}
const duration = state.duration;
if (!Number.isFinite(duration) || duration <= 0) {
state.isSeeking = false;
syncPlayhead();
return;
}
const generation = state.generation + 1;
const targetTime = clampNumber(value, 0, duration);
const sessionId = state.session.id;
state.generation = generation;
state.playbackOffset = targetTime;
state.seekPreviewTime = targetTime;
state.isSeeking = false;
state.frameCount = 0;
elements.loader.hidden = false;
clearPlayerMessage();
clearFrameQueue();
context.clearRect(0, 0, elements.canvas.width, elements.canvas.height);
syncPlayhead();
if (state.websocket) {
state.websocket.close(1000, 'client seeking');
state.websocket = null;
}
elements.audio.pause();
elements.audio.removeAttribute('src');
elements.audio.load();
try {
const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}/seek`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time: targetTime }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Seek failed.');
}
if (generation !== state.generation || state.session?.id !== sessionId) {
return;
}
state.session = payload;
updateSessionMetadata(payload);
connectPlaybackStreams();
} catch (error) {
if (generation !== state.generation || state.session?.id !== sessionId) {
return;
}
showPlayerMessage(error.message);
setControlsVisible(true);
syncPlayhead();
}
}
async function refreshSessionMetadata(sessionId) {
const generation = state.generation;
for (let attempt = 0; attempt < 10; attempt += 1) {
if (generation !== state.generation || state.session?.id !== sessionId) {
return;
}
try {
const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}`, { cache: 'no-store' });
if (!response.ok) {
return;
}
const payload = await response.json();
if (generation !== state.generation || state.session?.id !== sessionId) {
return;
}
state.session = { ...state.session, ...payload };
updateSessionMetadata(payload);
if (payload.metadataStatus !== 'pending') {
return;
}
} catch {
return;
}
await delay(650);
}
}
function updateSessionMetadata(payload) {
if ('duration' in payload) {
state.duration = normalizeDuration(payload.duration);
}
if ('seekable' in payload) {
state.seekable = Boolean(payload.seekable);
}
if (Number.isFinite(payload.seekSeconds)) {
state.playbackOffset = payload.seekSeconds;
}
syncPlayhead();
}
function syncPlayhead() {
const currentTime = getVisiblePlaybackTime();
const duration = state.duration;
const hasDuration = Number.isFinite(duration) && duration > 0;
const max = hasDuration ? duration : 1;
const value = hasDuration ? clampNumber(currentTime, 0, max) : 0;
const progress = hasDuration && max > 0 ? (value / max) * 100 : 0;
elements.currentTime.textContent = formatTime(currentTime);
elements.totalTime.textContent = formatTime(duration);
elements.seek.max = String(max);
elements.seek.value = String(value);
elements.seek.disabled = !state.seekable;
elements.seek.setAttribute('aria-valuemin', '0');
elements.seek.setAttribute('aria-valuemax', String(Math.round(max)));
elements.seek.setAttribute('aria-valuenow', String(Math.round(value)));
elements.seek.style.setProperty('--progress', `${progress}%`);
}
function getVisiblePlaybackTime() {
if (state.isSeeking) {
return Number.isFinite(state.seekPreviewTime) ? state.seekPreviewTime : 0;
}
const currentTime = state.playbackOffset + (Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0);
if (Number.isFinite(state.duration)) {
return clampNumber(currentTime, 0, state.duration);
}
return Math.max(0, currentTime);
}
function normalizeDuration(value) {
const duration = Number(value);
return Number.isFinite(duration) && duration > 0 ? duration : null;
}
function formatTime(value) {
if (!Number.isFinite(value)) {
return '--:--';
}
const totalSeconds = Math.max(0, Math.floor(value));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function clampNumber(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function setControlsVisible(visible) { function setControlsVisible(visible) {
state.controlsVisible = visible; state.controlsVisible = visible;
elements.stage.classList.toggle('controls-hidden', !visible); elements.stage.classList.toggle('controls-hidden', !visible);
@@ -389,7 +657,7 @@ function setControlsVisible(visible) {
function scheduleControlsHide() { function scheduleControlsHide() {
clearHideControlsTimer(); clearHideControlsTimer();
state.hideControlsTimer = window.setTimeout(() => { state.hideControlsTimer = window.setTimeout(() => {
if (state.session && !elements.audio.paused) { if (state.session && !elements.audio.paused && !state.isSeeking) {
setControlsVisible(false); setControlsVisible(false);
} }
}, 2400); }, 2400);

View File

@@ -36,6 +36,11 @@
<div id="player-message" class="player-message" role="status" aria-live="polite"></div> <div id="player-message" class="player-message" role="status" aria-live="polite"></div>
<div id="controls" class="controls"> <div id="controls" class="controls">
<div id="playhead" class="playhead">
<output id="current-time" class="time-badge current-time" for="seek">0:00</output>
<input id="seek" class="seek-slider" type="range" min="0" max="1" step="0.1" value="0" aria-label="Playback position" disabled>
<output id="total-time" class="time-badge total-time" for="seek">--:--</output>
</div>
<button id="back" type="button" class="control-button">Back</button> <button id="back" type="button" class="control-button">Back</button>
<button id="play-pause" type="button" class="control-button primary">Pause</button> <button id="play-pause" type="button" class="control-button primary">Pause</button>
<button id="mute" type="button" class="control-button">Mute</button> <button id="mute" type="button" class="control-button">Mute</button>

View File

@@ -237,6 +237,111 @@ canvas {
transition: opacity 180ms ease, transform 180ms ease; transition: opacity 180ms ease, transform 180ms ease;
} }
.playhead {
position: absolute;
top: -1.75rem;
right: 1.1rem;
left: 1.1rem;
height: 2.2rem;
}
.time-badge {
position: absolute;
top: 0;
min-width: 3.6rem;
padding: 0.22rem 0.45rem;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--fg);
font-size: 0.76rem;
font-weight: 800;
line-height: 1;
text-align: center;
font-variant-numeric: tabular-nums;
background: var(--glass-strong);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.38);
backdrop-filter: blur(14px);
}
.current-time {
left: 0;
}
.total-time {
right: 0;
}
.seek-slider {
position: absolute;
right: 0;
bottom: 0.18rem;
left: 0;
width: 100%;
height: 1rem;
margin: 0;
appearance: none;
background: transparent;
cursor: pointer;
--progress: 0%;
}
.seek-slider:disabled {
cursor: default;
}
.seek-slider::-webkit-slider-runnable-track {
height: 0.28rem;
border-radius: 999px;
background:
linear-gradient(
to right,
var(--accent) 0%,
var(--accent) var(--progress),
rgba(246, 241, 232, 0.28) var(--progress),
rgba(246, 241, 232, 0.28) 100%
);
}
.seek-slider::-moz-range-track {
height: 0.28rem;
border-radius: 999px;
background: rgba(246, 241, 232, 0.28);
}
.seek-slider::-moz-range-progress {
height: 0.28rem;
border-radius: 999px;
background: var(--accent);
}
.seek-slider::-webkit-slider-thumb {
width: 1rem;
height: 1rem;
margin-top: -0.36rem;
appearance: none;
border: 2px solid rgba(7, 7, 7, 0.75);
border-radius: 50%;
background: var(--fg);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.48);
}
.seek-slider::-moz-range-thumb {
width: 0.86rem;
height: 0.86rem;
border: 2px solid rgba(7, 7, 7, 0.75);
border-radius: 50%;
background: var(--fg);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.48);
}
.seek-slider:disabled::-webkit-slider-thumb {
opacity: 0;
}
.seek-slider:disabled::-moz-range-thumb {
opacity: 0;
}
.video-stage.controls-hidden .controls { .video-stage.controls-hidden .controls {
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
@@ -288,6 +393,11 @@ audio {
gap: 0.5rem; gap: 0.5rem;
} }
.playhead {
right: 0.75rem;
left: 0.75rem;
}
.control-button { .control-button {
min-width: 0; min-width: 0;
flex: 1; flex: 1;

View File

@@ -18,12 +18,14 @@ const wss = new WebSocketServer({ noServer: true });
const PORT = Number(process.env.PORT ?? 3000); const PORT = Number(process.env.PORT ?? 3000);
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg'; const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
const FFPROBE_PATH = process.env.FFPROBE_PATH ?? 'ffprobe';
const FFMPEG_LOG_LEVEL = process.env.FFMPEG_LOG_LEVEL ?? 'warning'; const FFMPEG_LOG_LEVEL = process.env.FFMPEG_LOG_LEVEL ?? 'warning';
const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0'; const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0';
const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE); const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE);
const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '..', 'data', 'recent-urls.json'); const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '..', 'data', 'recent-urls.json');
const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50); const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50);
const SESSION_TTL_MS = 60 * 60 * 1000; const SESSION_TTL_MS = 60 * 60 * 1000;
const METADATA_PROBE_TIMEOUT_MS = 8 * 1000;
const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000; const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000;
const MAX_WS_BUFFER_BYTES = 12 * 1024 * 1024; const MAX_WS_BUFFER_BYTES = 12 * 1024 * 1024;
const MAX_AUDIO_QUEUE_BYTES = clampInteger(process.env.MAX_AUDIO_QUEUE_BYTES, 16 * 1024 * 1024, 256 * 1024, 128 * 1024 * 1024); const MAX_AUDIO_QUEUE_BYTES = clampInteger(process.env.MAX_AUDIO_QUEUE_BYTES, 16 * 1024 * 1024, 256 * 1024, 128 * 1024 * 1024);
@@ -80,9 +82,14 @@ app.post('/api/session', async (request, response) => {
id, id,
url, url,
options, options,
duration: null,
metadataStatus: 'pending',
seekSeconds: 0,
seekGeneration: 0,
createdAt: Date.now(), createdAt: Date.now(),
lastUsedAt: Date.now(), lastUsedAt: Date.now(),
}); });
startSessionMetadataProbe(sessions.get(id));
try { try {
await addRecentUrl(url); await addRecentUrl(url);
@@ -90,7 +97,56 @@ app.post('/api/session', async (request, response) => {
console.warn(`Failed to store recent URL: ${error.message}`); console.warn(`Failed to store recent URL: ${error.message}`);
} }
response.status(201).json({ id, options }); response.status(201).json(formatSessionPayload(sessions.get(id)));
});
app.get('/api/session/:sessionId', (request, response) => {
const session = getSession(request.params.sessionId);
if (!session) {
response.status(404).json({ error: 'Unknown or expired session.' });
return;
}
response.json(formatSessionPayload(session));
});
app.post('/api/session/:sessionId/seek', (request, response) => {
const session = getSession(request.params.sessionId);
if (!session) {
response.status(404).json({ error: 'Unknown or expired session.' });
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)) {
response.status(400).json({ error: 'A numeric seek time is required.' });
return;
}
const seekSeconds = clampNumber(requestedSeconds, 0, session.duration);
const playback = playbacks.get(session.id);
session.seekSeconds = seekSeconds;
session.seekGeneration += 1;
session.lastUsedAt = Date.now();
if (playback && !playback.closed) {
playback.stop('client_seek');
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)}`);
response.json(formatSessionPayload(session));
}); });
app.all('/_source/:token', async (request, response) => { app.all('/_source/:token', async (request, response) => {
@@ -297,6 +353,10 @@ function clampInteger(value, fallback, min, max) {
return Math.min(max, Math.max(min, Math.round(parsed))); return Math.min(max, Math.max(min, Math.round(parsed)));
} }
function clampNumber(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function parseAudioBitrate(value) { function parseAudioBitrate(value) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return defaults.audioBitrate; return defaults.audioBitrate;
@@ -305,6 +365,124 @@ function parseAudioBitrate(value) {
return /^\d{2,3}k$/i.test(value) ? value.toLowerCase() : defaults.audioBitrate; return /^\d{2,3}k$/i.test(value) ? value.toLowerCase() : defaults.audioBitrate;
} }
function formatSessionPayload(session) {
return {
id: session.id,
options: session.options,
duration: Number.isFinite(session.duration) ? session.duration : null,
metadataStatus: session.metadataStatus,
seekable: isSessionSeekable(session),
seekSeconds: session.seekSeconds,
seekGeneration: session.seekGeneration,
};
}
function isSessionSeekable(session) {
return PLAYBACK_CONNECTION_MODE !== 'relay' && Number.isFinite(session.duration) && session.duration > 0;
}
function startSessionMetadataProbe(session) {
void probeSessionDuration(session).then((duration) => {
const current = sessions.get(session.id);
if (current !== session) {
return;
}
session.duration = duration;
session.metadataStatus = Number.isFinite(duration) ? 'ready' : 'unavailable';
}).catch((error) => {
const current = sessions.get(session.id);
if (current !== session) {
return;
}
session.duration = null;
session.metadataStatus = 'unavailable';
logWarn(`duration probe failed id=${shortId(session.id)} error=${oneLine(error.message)}`);
});
}
async function probeSessionDuration(session) {
const source = createSourceInput(session.id, 'probe');
try {
const output = await runFfprobe([
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'json',
source.url,
]);
const payload = JSON.parse(output);
const duration = Number(payload?.format?.duration);
return Number.isFinite(duration) && duration > 0 ? duration : null;
} finally {
source.release();
}
}
function runFfprobe(args) {
return new Promise((resolve, reject) => {
const ffprobe = spawn(FFPROBE_PATH, args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let timedOut = false;
let settled = false;
const finish = (callback) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
callback();
};
const timer = setTimeout(() => {
timedOut = true;
stopProcess(ffprobe);
}, METADATA_PROBE_TIMEOUT_MS);
timer.unref();
ffprobe.stdout.on('data', (chunk) => {
stdout = appendTail(stdout, chunk);
});
ffprobe.stderr.on('data', (chunk) => {
stderr = appendTail(stderr, chunk);
});
ffprobe.on('error', (error) => {
finish(() => reject(error));
});
ffprobe.on('close', (code, signal) => {
finish(() => {
if (timedOut) {
reject(new Error('ffprobe timed out.'));
return;
}
if (code === 0) {
resolve(stdout);
return;
}
const detail = redactSecrets(stderr).trim();
reject(new Error(detail ? `ffprobe exited with code ${code}: ${detail}` : `ffprobe exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`));
});
});
});
}
async function loadRecentUrls() { async function loadRecentUrls() {
try { try {
const raw = await fs.readFile(RECENT_URLS_PATH, 'utf8'); const raw = await fs.readFile(RECENT_URLS_PATH, 'utf8');
@@ -443,6 +621,9 @@ function streamSplitFrames(websocket, session) {
fps, fps,
quality, quality,
width, width,
duration: session.duration,
seekable: isSessionSeekable(session),
seekSeconds: session.seekSeconds,
}); });
ffmpeg.stdout.on('data', handleFrameData); ffmpeg.stdout.on('data', handleFrameData);
@@ -576,6 +757,9 @@ function createRelayPlayback(session) {
get closed() { get closed() {
return closed; return closed;
}, },
stop(reason = 'playback_stopped') {
stop(reason);
},
attachAudio(request, response) { attachAudio(request, response) {
if (closed) { if (closed) {
response.status(410).end(); response.status(410).end();
@@ -624,6 +808,9 @@ function createRelayPlayback(session) {
fps, fps,
quality, quality,
width, width,
duration: session.duration,
seekable: isSessionSeekable(session),
seekSeconds: session.seekSeconds,
}); });
websocket.on('close', () => { websocket.on('close', () => {
@@ -888,7 +1075,9 @@ function createRelayPlayback(session) {
sourceController?.abort(); sourceController?.abort();
audioInput?.destroy(); audioInput?.destroy();
frameInput?.destroy(); frameInput?.destroy();
playbacks.delete(session.id); if (playbacks.get(session.id) === playback) {
playbacks.delete(session.id);
}
if (audioResponse && !audioResponse.writableEnded) { if (audioResponse && !audioResponse.writableEnded) {
audioResponse.end(); audioResponse.end();
@@ -939,6 +1128,9 @@ function createPlayback(session) {
get closed() { get closed() {
return closed; return closed;
}, },
stop(reason = 'playback_stopped') {
stop(reason);
},
attachAudio(request, response) { attachAudio(request, response) {
if (closed) { if (closed) {
response.status(410).end(); response.status(410).end();
@@ -987,6 +1179,9 @@ function createPlayback(session) {
fps, fps,
quality, quality,
width, width,
duration: session.duration,
seekable: isSessionSeekable(session),
seekSeconds: session.seekSeconds,
}); });
websocket.on('close', () => { websocket.on('close', () => {
@@ -1184,7 +1379,9 @@ function createPlayback(session) {
closed = true; closed = true;
clearReadyTimer(); clearReadyTimer();
releaseSource(); releaseSource();
playbacks.delete(session.id); if (playbacks.get(session.id) === playback) {
playbacks.delete(session.id);
}
audioQueue = []; audioQueue = [];
audioQueueBytes = 0; audioQueueBytes = 0;
@@ -1252,16 +1449,16 @@ function createFrameWorker(session) {
} }
function buildRelayAudioArgs(session) { function buildRelayAudioArgs(session) {
return buildAudioArgs(session, 'pipe:0', { seekable: false }); return buildAudioArgs(session, 'pipe:0', { seekable: false, startTime: 0 });
} }
function buildRelayFrameArgs(session) { function buildRelayFrameArgs(session) {
return buildFrameArgs(session, 'pipe:0', { seekable: false }); return buildFrameArgs(session, 'pipe:0', { seekable: false, startTime: 0 });
} }
function buildPlaybackArgs(session, inputUrl) { function buildPlaybackArgs(session, inputUrl) {
return [ return [
...buildInputArgs(inputUrl), ...buildInputArgs(inputUrl, { startTime: session.seekSeconds }),
'-map', '-map',
'0:a:0?', '0:a:0?',
'-vn', '-vn',
@@ -1284,7 +1481,7 @@ function buildPlaybackArgs(session, inputUrl) {
function buildAudioArgs(session, inputUrl, inputOptions) { function buildAudioArgs(session, inputUrl, inputOptions) {
return [ return [
...buildInputArgs(inputUrl, inputOptions), ...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }),
'-map', '-map',
'0:a:0?', '0:a:0?',
'-vn', '-vn',
@@ -1304,14 +1501,14 @@ function buildAudioArgs(session, inputUrl, inputOptions) {
function buildFrameArgs(session, inputUrl, inputOptions) { function buildFrameArgs(session, inputUrl, inputOptions) {
return [ return [
...buildInputArgs(inputUrl, inputOptions), ...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }),
'-map', '-map',
'0:v:0', '0:v:0',
...buildFrameOutputArgs(session, 'pipe:1'), ...buildFrameOutputArgs(session, 'pipe:1'),
]; ];
} }
function buildInputArgs(inputUrl, { seekable = true } = {}) { function buildInputArgs(inputUrl, { seekable = true, startTime = 0 } = {}) {
const args = [ const args = [
'-hide_banner', '-hide_banner',
'-nostdin', '-nostdin',
@@ -1321,13 +1518,21 @@ function buildInputArgs(inputUrl, { seekable = true } = {}) {
]; ];
if (seekable) { if (seekable) {
args.push('-seekable', FFMPEG_INPUT_SEEKABLE); args.push('-seekable', startTime > 0 ? '1' : FFMPEG_INPUT_SEEKABLE);
}
if (startTime > 0) {
args.push('-ss', formatFfmpegSeconds(startTime));
} }
args.push('-re', '-i', inputUrl); args.push('-re', '-i', inputUrl);
return args; return args;
} }
function formatFfmpegSeconds(seconds) {
return clampNumber(seconds, 0, Number.MAX_SAFE_INTEGER).toFixed(3);
}
function buildFrameOutputArgs(session, outputUrl) { function buildFrameOutputArgs(session, outputUrl) {
const { fps, quality, width } = session.options; const { fps, quality, width } = session.options;
const videoFilter = `fps=${fps},scale=w='min(${width},iw)':h=-2:flags=bicubic:out_range=pc,format=yuvj420p`; const videoFilter = `fps=${fps},scale=w='min(${width},iw)':h=-2:flags=bicubic:out_range=pc,format=yuvj420p`;