adds seeking support
This commit is contained in:
280
public/app.js
280
public/app.js
@@ -13,6 +13,10 @@ const elements = {
|
||||
loader: document.querySelector('#loader'),
|
||||
playerMessage: document.querySelector('#player-message'),
|
||||
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'),
|
||||
playPause: document.querySelector('#play-pause'),
|
||||
mute: document.querySelector('#mute'),
|
||||
@@ -31,6 +35,11 @@ const state = {
|
||||
controlsVisible: true,
|
||||
hideControlsTimer: 0,
|
||||
recentUrls: [],
|
||||
playbackOffset: 0,
|
||||
duration: null,
|
||||
seekable: false,
|
||||
isSeeking: false,
|
||||
seekPreviewTime: 0,
|
||||
};
|
||||
|
||||
void loadRecentUrls();
|
||||
@@ -109,15 +118,46 @@ elements.mute.addEventListener('click', () => {
|
||||
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', () => {
|
||||
startRenderLoop();
|
||||
syncControlLabels();
|
||||
scheduleControlsHide();
|
||||
syncPlayhead();
|
||||
});
|
||||
|
||||
elements.audio.addEventListener('pause', () => {
|
||||
syncControlLabels();
|
||||
setControlsVisible(true);
|
||||
syncPlayhead();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
if (state.session) {
|
||||
showPlayerMessage('Audio failed');
|
||||
@@ -140,17 +189,39 @@ elements.audio.addEventListener('error', () => {
|
||||
});
|
||||
|
||||
function startSession(session) {
|
||||
const generation = state.generation;
|
||||
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;
|
||||
clearFrameQueue();
|
||||
syncControlLabels();
|
||||
syncPlayhead();
|
||||
setControlsVisible(true);
|
||||
elements.loader.hidden = false;
|
||||
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 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);
|
||||
websocket.binaryType = 'arraybuffer';
|
||||
state.websocket = websocket;
|
||||
@@ -169,20 +240,21 @@ function startSession(session) {
|
||||
});
|
||||
|
||||
websocket.addEventListener('close', () => {
|
||||
if (state.session?.id === session.id) {
|
||||
if (generation === state.generation && state.session?.id === session.id) {
|
||||
showPlayerMessage('Stream ended');
|
||||
setControlsVisible(true);
|
||||
}
|
||||
});
|
||||
|
||||
websocket.addEventListener('error', () => {
|
||||
if (state.session?.id === session.id) {
|
||||
if (generation === state.generation && state.session?.id === session.id) {
|
||||
showPlayerMessage('Stream failed');
|
||||
setControlsVisible(true);
|
||||
}
|
||||
});
|
||||
|
||||
elements.audio.src = `/audio/${session.id}`;
|
||||
elements.audio.pause();
|
||||
elements.audio.src = `/audio/${session.id}?g=${session.seekGeneration ?? 0}`;
|
||||
elements.audio.load();
|
||||
void playAudio();
|
||||
startRenderLoop();
|
||||
@@ -231,6 +303,10 @@ function handleControlMessage(rawMessage) {
|
||||
setControlsVisible(true);
|
||||
}
|
||||
|
||||
if (message.type === 'ready') {
|
||||
updateSessionMetadata(message);
|
||||
}
|
||||
|
||||
if (message.type === 'end') {
|
||||
showPlayerMessage('Stream ended');
|
||||
setControlsVisible(true);
|
||||
@@ -244,6 +320,7 @@ function startRenderLoop() {
|
||||
|
||||
const render = () => {
|
||||
drawReadyFrames();
|
||||
syncPlayhead();
|
||||
state.raf = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
@@ -309,6 +386,11 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
|
||||
state.generation += 1;
|
||||
state.session = null;
|
||||
state.frameCount = 0;
|
||||
state.playbackOffset = 0;
|
||||
state.duration = null;
|
||||
state.seekable = false;
|
||||
state.isSeeking = false;
|
||||
state.seekPreviewTime = 0;
|
||||
clearHideControlsTimer();
|
||||
|
||||
if (state.websocket) {
|
||||
@@ -331,6 +413,7 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
|
||||
clearPlayerMessage();
|
||||
setControlsVisible(true);
|
||||
syncControlLabels();
|
||||
syncPlayhead();
|
||||
|
||||
if (shouldShowEntry) {
|
||||
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) {
|
||||
state.controlsVisible = visible;
|
||||
elements.stage.classList.toggle('controls-hidden', !visible);
|
||||
@@ -389,7 +657,7 @@ function setControlsVisible(visible) {
|
||||
function scheduleControlsHide() {
|
||||
clearHideControlsTimer();
|
||||
state.hideControlsTimer = window.setTimeout(() => {
|
||||
if (state.session && !elements.audio.paused) {
|
||||
if (state.session && !elements.audio.paused && !state.isSeeking) {
|
||||
setControlsVisible(false);
|
||||
}
|
||||
}, 2400);
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
<div id="player-message" class="player-message" role="status" aria-live="polite"></div>
|
||||
|
||||
<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="play-pause" type="button" class="control-button primary">Pause</button>
|
||||
<button id="mute" type="button" class="control-button">Mute</button>
|
||||
|
||||
@@ -237,6 +237,111 @@ canvas {
|
||||
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 {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
@@ -288,6 +393,11 @@ audio {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playhead {
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
|
||||
225
server/index.js
225
server/index.js
@@ -18,12 +18,14 @@ const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000);
|
||||
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_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0';
|
||||
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_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50);
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000;
|
||||
const METADATA_PROBE_TIMEOUT_MS = 8 * 1000;
|
||||
const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000;
|
||||
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);
|
||||
@@ -80,9 +82,14 @@ app.post('/api/session', async (request, response) => {
|
||||
id,
|
||||
url,
|
||||
options,
|
||||
duration: null,
|
||||
metadataStatus: 'pending',
|
||||
seekSeconds: 0,
|
||||
seekGeneration: 0,
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: Date.now(),
|
||||
});
|
||||
startSessionMetadataProbe(sessions.get(id));
|
||||
|
||||
try {
|
||||
await addRecentUrl(url);
|
||||
@@ -90,7 +97,56 @@ app.post('/api/session', async (request, response) => {
|
||||
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) => {
|
||||
@@ -297,6 +353,10 @@ function clampInteger(value, fallback, min, max) {
|
||||
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) {
|
||||
if (typeof value !== 'string') {
|
||||
return defaults.audioBitrate;
|
||||
@@ -305,6 +365,124 @@ function parseAudioBitrate(value) {
|
||||
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() {
|
||||
try {
|
||||
const raw = await fs.readFile(RECENT_URLS_PATH, 'utf8');
|
||||
@@ -443,6 +621,9 @@ function streamSplitFrames(websocket, session) {
|
||||
fps,
|
||||
quality,
|
||||
width,
|
||||
duration: session.duration,
|
||||
seekable: isSessionSeekable(session),
|
||||
seekSeconds: session.seekSeconds,
|
||||
});
|
||||
|
||||
ffmpeg.stdout.on('data', handleFrameData);
|
||||
@@ -576,6 +757,9 @@ function createRelayPlayback(session) {
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
stop(reason = 'playback_stopped') {
|
||||
stop(reason);
|
||||
},
|
||||
attachAudio(request, response) {
|
||||
if (closed) {
|
||||
response.status(410).end();
|
||||
@@ -624,6 +808,9 @@ function createRelayPlayback(session) {
|
||||
fps,
|
||||
quality,
|
||||
width,
|
||||
duration: session.duration,
|
||||
seekable: isSessionSeekable(session),
|
||||
seekSeconds: session.seekSeconds,
|
||||
});
|
||||
|
||||
websocket.on('close', () => {
|
||||
@@ -888,7 +1075,9 @@ function createRelayPlayback(session) {
|
||||
sourceController?.abort();
|
||||
audioInput?.destroy();
|
||||
frameInput?.destroy();
|
||||
playbacks.delete(session.id);
|
||||
if (playbacks.get(session.id) === playback) {
|
||||
playbacks.delete(session.id);
|
||||
}
|
||||
|
||||
if (audioResponse && !audioResponse.writableEnded) {
|
||||
audioResponse.end();
|
||||
@@ -939,6 +1128,9 @@ function createPlayback(session) {
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
stop(reason = 'playback_stopped') {
|
||||
stop(reason);
|
||||
},
|
||||
attachAudio(request, response) {
|
||||
if (closed) {
|
||||
response.status(410).end();
|
||||
@@ -987,6 +1179,9 @@ function createPlayback(session) {
|
||||
fps,
|
||||
quality,
|
||||
width,
|
||||
duration: session.duration,
|
||||
seekable: isSessionSeekable(session),
|
||||
seekSeconds: session.seekSeconds,
|
||||
});
|
||||
|
||||
websocket.on('close', () => {
|
||||
@@ -1184,7 +1379,9 @@ function createPlayback(session) {
|
||||
closed = true;
|
||||
clearReadyTimer();
|
||||
releaseSource();
|
||||
playbacks.delete(session.id);
|
||||
if (playbacks.get(session.id) === playback) {
|
||||
playbacks.delete(session.id);
|
||||
}
|
||||
audioQueue = [];
|
||||
audioQueueBytes = 0;
|
||||
|
||||
@@ -1252,16 +1449,16 @@ function createFrameWorker(session) {
|
||||
}
|
||||
|
||||
function buildRelayAudioArgs(session) {
|
||||
return buildAudioArgs(session, 'pipe:0', { seekable: false });
|
||||
return buildAudioArgs(session, 'pipe:0', { seekable: false, startTime: 0 });
|
||||
}
|
||||
|
||||
function buildRelayFrameArgs(session) {
|
||||
return buildFrameArgs(session, 'pipe:0', { seekable: false });
|
||||
return buildFrameArgs(session, 'pipe:0', { seekable: false, startTime: 0 });
|
||||
}
|
||||
|
||||
function buildPlaybackArgs(session, inputUrl) {
|
||||
return [
|
||||
...buildInputArgs(inputUrl),
|
||||
...buildInputArgs(inputUrl, { startTime: session.seekSeconds }),
|
||||
'-map',
|
||||
'0:a:0?',
|
||||
'-vn',
|
||||
@@ -1284,7 +1481,7 @@ function buildPlaybackArgs(session, inputUrl) {
|
||||
|
||||
function buildAudioArgs(session, inputUrl, inputOptions) {
|
||||
return [
|
||||
...buildInputArgs(inputUrl, inputOptions),
|
||||
...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }),
|
||||
'-map',
|
||||
'0:a:0?',
|
||||
'-vn',
|
||||
@@ -1304,14 +1501,14 @@ function buildAudioArgs(session, inputUrl, inputOptions) {
|
||||
|
||||
function buildFrameArgs(session, inputUrl, inputOptions) {
|
||||
return [
|
||||
...buildInputArgs(inputUrl, inputOptions),
|
||||
...buildInputArgs(inputUrl, { ...inputOptions, startTime: inputOptions?.startTime ?? session.seekSeconds }),
|
||||
'-map',
|
||||
'0:v:0',
|
||||
...buildFrameOutputArgs(session, 'pipe:1'),
|
||||
];
|
||||
}
|
||||
|
||||
function buildInputArgs(inputUrl, { seekable = true } = {}) {
|
||||
function buildInputArgs(inputUrl, { seekable = true, startTime = 0 } = {}) {
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-nostdin',
|
||||
@@ -1321,13 +1518,21 @@ function buildInputArgs(inputUrl, { seekable = true } = {}) {
|
||||
];
|
||||
|
||||
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);
|
||||
return args;
|
||||
}
|
||||
|
||||
function formatFfmpegSeconds(seconds) {
|
||||
return clampNumber(seconds, 0, Number.MAX_SAFE_INTEGER).toFixed(3);
|
||||
}
|
||||
|
||||
function buildFrameOutputArgs(session, outputUrl) {
|
||||
const { fps, quality, width } = session.options;
|
||||
const videoFilter = `fps=${fps},scale=w='min(${width},iw)':h=-2:flags=bicubic:out_range=pc,format=yuvj420p`;
|
||||
|
||||
Reference in New Issue
Block a user