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