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'),
|
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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 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`;
|
||||||
|
|||||||
Reference in New Issue
Block a user