2026-05-01 21:51:25 -07:00
|
|
|
const elements = {
|
|
|
|
|
entryScreen: document.querySelector('#entry-screen'),
|
|
|
|
|
playerScreen: document.querySelector('#player-screen'),
|
|
|
|
|
form: document.querySelector('#stream-form'),
|
|
|
|
|
url: document.querySelector('#stream-url'),
|
|
|
|
|
next: document.querySelector('#next'),
|
|
|
|
|
entryMessage: document.querySelector('#entry-message'),
|
2026-05-01 22:08:50 -07:00
|
|
|
recentPanel: document.querySelector('#recent-panel'),
|
|
|
|
|
recentList: document.querySelector('#recent-list'),
|
2026-05-01 21:51:25 -07:00
|
|
|
audio: document.querySelector('#audio'),
|
|
|
|
|
canvas: document.querySelector('#screen'),
|
|
|
|
|
stage: document.querySelector('#video-stage'),
|
|
|
|
|
loader: document.querySelector('#loader'),
|
|
|
|
|
playerMessage: document.querySelector('#player-message'),
|
|
|
|
|
controls: document.querySelector('#controls'),
|
2026-05-02 18:53:35 -07:00
|
|
|
playhead: document.querySelector('#playhead'),
|
|
|
|
|
seek: document.querySelector('#seek'),
|
|
|
|
|
currentTime: document.querySelector('#current-time'),
|
|
|
|
|
totalTime: document.querySelector('#total-time'),
|
2026-05-01 21:51:25 -07:00
|
|
|
back: document.querySelector('#back'),
|
|
|
|
|
playPause: document.querySelector('#play-pause'),
|
|
|
|
|
mute: document.querySelector('#mute'),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const context = elements.canvas.getContext('2d', { alpha: false });
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
generation: 0,
|
|
|
|
|
session: null,
|
|
|
|
|
websocket: null,
|
|
|
|
|
frames: [],
|
|
|
|
|
currentBitmap: null,
|
|
|
|
|
raf: 0,
|
|
|
|
|
frameCount: 0,
|
|
|
|
|
controlsVisible: true,
|
|
|
|
|
hideControlsTimer: 0,
|
2026-05-01 22:08:50 -07:00
|
|
|
recentUrls: [],
|
2026-05-02 18:53:35 -07:00
|
|
|
playbackOffset: 0,
|
|
|
|
|
duration: null,
|
|
|
|
|
seekable: false,
|
|
|
|
|
isSeeking: false,
|
|
|
|
|
seekPreviewTime: 0,
|
2026-05-01 21:51:25 -07:00
|
|
|
};
|
|
|
|
|
|
2026-05-01 22:08:50 -07:00
|
|
|
void loadRecentUrls();
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
elements.form.addEventListener('submit', async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
setEntryMessage('');
|
|
|
|
|
setFormBusy(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
stopSession({ showEntry: false });
|
|
|
|
|
|
|
|
|
|
const response = await fetch('/api/session', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ url: elements.url.value }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(payload.error ?? 'Failed to create stream session.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showPlayer();
|
2026-05-01 22:08:50 -07:00
|
|
|
void loadRecentUrls();
|
2026-05-01 21:51:25 -07:00
|
|
|
startSession(payload);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
stopSession();
|
|
|
|
|
setEntryMessage(error.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setFormBusy(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-01 22:08:50 -07:00
|
|
|
elements.recentList.addEventListener('click', (event) => {
|
|
|
|
|
const button = event.target.closest('[data-recent-index]');
|
|
|
|
|
|
|
|
|
|
if (!button) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const item = state.recentUrls[Number(button.dataset.recentIndex)];
|
|
|
|
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
elements.url.value = item.url;
|
|
|
|
|
elements.form.requestSubmit();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
elements.stage.addEventListener('pointerup', (event) => {
|
|
|
|
|
if (!state.session || event.target.closest('.controls')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setControlsVisible(!state.controlsVisible);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.back.addEventListener('click', () => {
|
|
|
|
|
stopSession();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.playPause.addEventListener('click', () => {
|
|
|
|
|
if (elements.audio.paused) {
|
|
|
|
|
void playAudio();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
elements.audio.pause();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.mute.addEventListener('click', () => {
|
|
|
|
|
elements.audio.muted = !elements.audio.muted;
|
|
|
|
|
syncControlLabels();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-02 18:53:35 -07:00
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
elements.audio.addEventListener('play', () => {
|
|
|
|
|
startRenderLoop();
|
|
|
|
|
syncControlLabels();
|
|
|
|
|
scheduleControlsHide();
|
2026-05-02 18:53:35 -07:00
|
|
|
syncPlayhead();
|
2026-05-01 21:51:25 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.audio.addEventListener('pause', () => {
|
|
|
|
|
syncControlLabels();
|
|
|
|
|
setControlsVisible(true);
|
2026-05-02 18:53:35 -07:00
|
|
|
syncPlayhead();
|
2026-05-01 21:51:25 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.audio.addEventListener('playing', () => {
|
|
|
|
|
elements.loader.hidden = state.frameCount > 0;
|
|
|
|
|
clearPlayerMessage();
|
|
|
|
|
scheduleControlsHide();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.audio.addEventListener('waiting', () => {
|
|
|
|
|
if (state.session) {
|
|
|
|
|
elements.loader.hidden = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-02 18:53:35 -07:00
|
|
|
elements.audio.addEventListener('timeupdate', () => {
|
|
|
|
|
syncPlayhead();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.audio.addEventListener('ended', () => {
|
|
|
|
|
syncPlayhead();
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
elements.audio.addEventListener('error', () => {
|
|
|
|
|
if (state.session) {
|
|
|
|
|
showPlayerMessage('Audio failed');
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function startSession(session) {
|
|
|
|
|
state.session = session;
|
2026-05-02 18:53:35 -07:00
|
|
|
state.playbackOffset = Number(session.seekSeconds) || 0;
|
|
|
|
|
state.duration = normalizeDuration(session.duration);
|
|
|
|
|
state.seekable = Boolean(session.seekable);
|
|
|
|
|
state.isSeeking = false;
|
|
|
|
|
state.seekPreviewTime = state.playbackOffset;
|
2026-05-01 21:51:25 -07:00
|
|
|
state.frameCount = 0;
|
|
|
|
|
clearFrameQueue();
|
|
|
|
|
syncControlLabels();
|
2026-05-02 18:53:35 -07:00
|
|
|
syncPlayhead();
|
2026-05-01 21:51:25 -07:00
|
|
|
setControlsVisible(true);
|
|
|
|
|
elements.loader.hidden = false;
|
|
|
|
|
clearPlayerMessage();
|
|
|
|
|
|
2026-05-02 18:53:35 -07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
2026-05-02 18:53:35 -07:00
|
|
|
const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}?g=${session.seekGeneration ?? 0}`;
|
2026-05-01 21:51:25 -07:00
|
|
|
const websocket = new WebSocket(websocketUrl);
|
|
|
|
|
websocket.binaryType = 'arraybuffer';
|
|
|
|
|
state.websocket = websocket;
|
|
|
|
|
|
|
|
|
|
websocket.addEventListener('message', (event) => {
|
|
|
|
|
if (generation !== state.generation) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof event.data === 'string') {
|
|
|
|
|
handleControlMessage(event.data);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void handleFramePacket(event.data, generation);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
websocket.addEventListener('close', () => {
|
2026-05-02 18:53:35 -07:00
|
|
|
if (generation === state.generation && state.session?.id === session.id) {
|
2026-05-01 21:51:25 -07:00
|
|
|
showPlayerMessage('Stream ended');
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
websocket.addEventListener('error', () => {
|
2026-05-02 18:53:35 -07:00
|
|
|
if (generation === state.generation && state.session?.id === session.id) {
|
2026-05-01 21:51:25 -07:00
|
|
|
showPlayerMessage('Stream failed');
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-02 18:53:35 -07:00
|
|
|
elements.audio.pause();
|
|
|
|
|
elements.audio.src = `/audio/${session.id}?g=${session.seekGeneration ?? 0}`;
|
2026-05-01 21:51:25 -07:00
|
|
|
elements.audio.load();
|
|
|
|
|
void playAudio();
|
|
|
|
|
startRenderLoop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function playAudio() {
|
|
|
|
|
try {
|
|
|
|
|
await elements.audio.play();
|
|
|
|
|
clearPlayerMessage();
|
|
|
|
|
} catch {
|
|
|
|
|
showPlayerMessage('Tap play');
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleFramePacket(packet, generation) {
|
|
|
|
|
if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timestamp = new DataView(packet, 0, 8).getFloat64(0, true);
|
|
|
|
|
const blob = new Blob([packet.slice(8)], { type: 'image/jpeg' });
|
|
|
|
|
const bitmap = await decodeImage(blob);
|
|
|
|
|
|
|
|
|
|
if (generation !== state.generation) {
|
|
|
|
|
releaseImage(bitmap);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.frames.push({ timestamp, bitmap });
|
|
|
|
|
state.frameCount += 1;
|
|
|
|
|
trimFrameQueue();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleControlMessage(rawMessage) {
|
|
|
|
|
let message;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
message = JSON.parse(rawMessage);
|
|
|
|
|
} catch {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (message.type === 'error') {
|
|
|
|
|
showPlayerMessage('Stream failed');
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 18:53:35 -07:00
|
|
|
if (message.type === 'ready') {
|
|
|
|
|
updateSessionMetadata(message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
if (message.type === 'end') {
|
|
|
|
|
showPlayerMessage('Stream ended');
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startRenderLoop() {
|
|
|
|
|
if (state.raf) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const render = () => {
|
|
|
|
|
drawReadyFrames();
|
2026-05-02 18:53:35 -07:00
|
|
|
syncPlayhead();
|
2026-05-01 21:51:25 -07:00
|
|
|
state.raf = requestAnimationFrame(render);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
state.raf = requestAnimationFrame(render);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawReadyFrames() {
|
|
|
|
|
if (!state.session) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const frameLeadSeconds = 1 / Math.max(1, state.session.options.fps);
|
|
|
|
|
const targetTime = elements.audio.currentTime + frameLeadSeconds;
|
|
|
|
|
let drew = false;
|
|
|
|
|
|
|
|
|
|
while (state.frames.length > 0 && state.frames[0].timestamp <= targetTime) {
|
|
|
|
|
const frame = state.frames.shift();
|
|
|
|
|
|
|
|
|
|
if (state.currentBitmap) {
|
|
|
|
|
releaseImage(state.currentBitmap);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.currentBitmap = frame.bitmap;
|
|
|
|
|
drawBitmap(frame.bitmap);
|
|
|
|
|
drew = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (drew) {
|
|
|
|
|
elements.loader.hidden = true;
|
|
|
|
|
clearPlayerMessage();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawBitmap(bitmap) {
|
|
|
|
|
const width = bitmap.width || bitmap.naturalWidth;
|
|
|
|
|
const height = bitmap.height || bitmap.naturalHeight;
|
|
|
|
|
|
|
|
|
|
if (elements.canvas.width !== width || elements.canvas.height !== height) {
|
|
|
|
|
elements.canvas.width = width;
|
|
|
|
|
elements.canvas.height = height;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
context.drawImage(bitmap, 0, 0, elements.canvas.width, elements.canvas.height);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trimFrameQueue() {
|
|
|
|
|
const fps = state.session?.options.fps ?? 24;
|
|
|
|
|
const maxQueuedFrames = Math.max(60, Math.ceil(fps * 8));
|
|
|
|
|
const overflow = state.frames.length - maxQueuedFrames;
|
|
|
|
|
|
|
|
|
|
if (overflow <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const removed = state.frames.splice(0, overflow);
|
|
|
|
|
|
|
|
|
|
for (const frame of removed) {
|
|
|
|
|
releaseImage(frame.bitmap);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopSession({ showEntry: shouldShowEntry = true } = {}) {
|
|
|
|
|
state.generation += 1;
|
|
|
|
|
state.session = null;
|
|
|
|
|
state.frameCount = 0;
|
2026-05-02 18:53:35 -07:00
|
|
|
state.playbackOffset = 0;
|
|
|
|
|
state.duration = null;
|
|
|
|
|
state.seekable = false;
|
|
|
|
|
state.isSeeking = false;
|
|
|
|
|
state.seekPreviewTime = 0;
|
2026-05-01 21:51:25 -07:00
|
|
|
clearHideControlsTimer();
|
|
|
|
|
|
|
|
|
|
if (state.websocket) {
|
|
|
|
|
state.websocket.close(1000, 'client stopped');
|
|
|
|
|
state.websocket = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
elements.audio.pause();
|
|
|
|
|
elements.audio.removeAttribute('src');
|
|
|
|
|
elements.audio.load();
|
|
|
|
|
|
|
|
|
|
if (state.raf) {
|
|
|
|
|
cancelAnimationFrame(state.raf);
|
|
|
|
|
state.raf = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearFrameQueue();
|
|
|
|
|
context.clearRect(0, 0, elements.canvas.width, elements.canvas.height);
|
|
|
|
|
elements.loader.hidden = true;
|
|
|
|
|
clearPlayerMessage();
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
syncControlLabels();
|
2026-05-02 18:53:35 -07:00
|
|
|
syncPlayhead();
|
2026-05-01 21:51:25 -07:00
|
|
|
|
|
|
|
|
if (shouldShowEntry) {
|
|
|
|
|
showEntry();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearFrameQueue() {
|
|
|
|
|
for (const frame of state.frames) {
|
|
|
|
|
releaseImage(frame.bitmap);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.frames = [];
|
|
|
|
|
|
|
|
|
|
if (state.currentBitmap) {
|
|
|
|
|
releaseImage(state.currentBitmap);
|
|
|
|
|
state.currentBitmap = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decodeImage(blob) {
|
|
|
|
|
if ('createImageBitmap' in window) {
|
|
|
|
|
return createImageBitmap(blob);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const image = new Image();
|
|
|
|
|
image.decoding = 'async';
|
|
|
|
|
image.src = url;
|
|
|
|
|
await image.decode();
|
|
|
|
|
return image;
|
|
|
|
|
} finally {
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function releaseImage(image) {
|
|
|
|
|
if (typeof image?.close === 'function') {
|
|
|
|
|
image.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 18:53:35 -07:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
function setControlsVisible(visible) {
|
|
|
|
|
state.controlsVisible = visible;
|
|
|
|
|
elements.stage.classList.toggle('controls-hidden', !visible);
|
|
|
|
|
elements.controls.setAttribute('aria-hidden', String(!visible));
|
|
|
|
|
|
|
|
|
|
if (visible && !elements.audio.paused) {
|
|
|
|
|
scheduleControlsHide();
|
|
|
|
|
} else {
|
|
|
|
|
clearHideControlsTimer();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scheduleControlsHide() {
|
|
|
|
|
clearHideControlsTimer();
|
|
|
|
|
state.hideControlsTimer = window.setTimeout(() => {
|
2026-05-02 18:53:35 -07:00
|
|
|
if (state.session && !elements.audio.paused && !state.isSeeking) {
|
2026-05-01 21:51:25 -07:00
|
|
|
setControlsVisible(false);
|
|
|
|
|
}
|
|
|
|
|
}, 2400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearHideControlsTimer() {
|
|
|
|
|
if (state.hideControlsTimer) {
|
|
|
|
|
window.clearTimeout(state.hideControlsTimer);
|
|
|
|
|
state.hideControlsTimer = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncControlLabels() {
|
|
|
|
|
elements.playPause.textContent = elements.audio.paused ? 'Play' : 'Pause';
|
|
|
|
|
elements.mute.textContent = elements.audio.muted ? 'Unmute' : 'Mute';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showEntry() {
|
|
|
|
|
elements.playerScreen.hidden = true;
|
|
|
|
|
elements.entryScreen.hidden = false;
|
2026-05-01 22:08:50 -07:00
|
|
|
void loadRecentUrls();
|
2026-05-01 21:51:25 -07:00
|
|
|
elements.url.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showPlayer() {
|
|
|
|
|
elements.entryScreen.hidden = true;
|
|
|
|
|
elements.playerScreen.hidden = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showPlayerMessage(message) {
|
|
|
|
|
elements.playerMessage.textContent = message;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearPlayerMessage() {
|
|
|
|
|
elements.playerMessage.textContent = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setEntryMessage(message) {
|
|
|
|
|
elements.entryMessage.textContent = message;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setFormBusy(isBusy) {
|
|
|
|
|
elements.url.disabled = isBusy;
|
|
|
|
|
elements.next.disabled = isBusy;
|
2026-05-01 22:08:50 -07:00
|
|
|
|
|
|
|
|
for (const button of elements.recentList.querySelectorAll('button')) {
|
|
|
|
|
button.disabled = isBusy;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadRecentUrls() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/recent-urls', { cache: 'no-store' });
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('Failed to load recent URLs.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
state.recentUrls = Array.isArray(payload.urls) ? payload.urls : [];
|
|
|
|
|
renderRecentUrls();
|
|
|
|
|
} catch {
|
|
|
|
|
state.recentUrls = [];
|
|
|
|
|
renderRecentUrls();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderRecentUrls() {
|
|
|
|
|
elements.recentList.replaceChildren(
|
|
|
|
|
...state.recentUrls.map((item, index) => {
|
|
|
|
|
const button = document.createElement('button');
|
|
|
|
|
button.type = 'button';
|
|
|
|
|
button.className = 'recent-url';
|
|
|
|
|
button.dataset.recentIndex = String(index);
|
|
|
|
|
button.textContent = item.displayUrl || item.url;
|
|
|
|
|
return button;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
elements.recentPanel.hidden = state.recentUrls.length === 0;
|
2026-05-01 21:51:25 -07:00
|
|
|
}
|