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'), recentPanel: document.querySelector('#recent-panel'), recentList: document.querySelector('#recent-list'), 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'), 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'), }; const context = elements.canvas.getContext('2d', { alpha: false }); const FRAME_LATE_GRACE_SECONDS = 0.25; const MAX_PENDING_FRAME_QUEUE_SECONDS = 2; const MAX_DECODED_FRAME_QUEUE_SECONDS = 3; const MIN_PENDING_FRAME_QUEUE = 12; const MIN_DECODED_FRAME_QUEUE = 24; const state = { generation: 0, session: null, websocket: null, pendingFrames: [], decodingFrames: false, frames: [], currentBitmap: null, raf: 0, frameCount: 0, controlsVisible: true, hideControlsTimer: 0, recentUrls: [], playbackOffset: 0, duration: null, seekable: false, isSeeking: false, seekPreviewTime: 0, }; void loadRecentUrls(); 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(); void loadRecentUrls(); startSession(payload); } catch (error) { stopSession(); setEntryMessage(error.message); } finally { setFormBusy(false); } }); 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(); }); 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(); }); 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', () => { elements.loader.hidden = state.frameCount > 0; clearPlayerMessage(); scheduleControlsHide(); }); elements.audio.addEventListener('waiting', () => { if (state.session) { elements.loader.hidden = false; } }); elements.audio.addEventListener('timeupdate', () => { syncPlayhead(); }); elements.audio.addEventListener('ended', () => { syncPlayhead(); setControlsVisible(true); }); elements.audio.addEventListener('error', () => { if (state.session) { showPlayerMessage('Audio failed'); setControlsVisible(true); } }); function startSession(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; 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}?g=${session.seekGeneration ?? 0}`; 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', () => { if (generation === state.generation && state.session?.id === session.id) { showPlayerMessage('Stream ended'); setControlsVisible(true); } }); websocket.addEventListener('error', () => { if (generation === state.generation && state.session?.id === session.id) { showPlayerMessage('Stream failed'); setControlsVisible(true); } }); elements.audio.pause(); elements.audio.src = `/audio/${session.id}?g=${session.seekGeneration ?? 0}`; elements.audio.load(); void playAudio(); startRenderLoop(); } async function playAudio() { try { await elements.audio.play(); clearPlayerMessage(); } catch { showPlayerMessage('Tap play'); setControlsVisible(true); } } function handleFramePacket(packet, generation) { if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) { return; } const timestamp = new DataView(packet, 0, 8).getFloat64(0, true); if (generation !== state.generation || isLateFrame(timestamp)) { return; } state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), generation }); trimPendingFrameQueue(); void pumpFrameDecodeQueue(); } function handleControlMessage(rawMessage) { let message; try { message = JSON.parse(rawMessage); } catch { return; } if (message.type === 'error') { showPlayerMessage('Stream failed'); setControlsVisible(true); } if (message.type === 'ready') { updateSessionMetadata(message); } if (message.type === 'end') { showPlayerMessage('Stream ended'); setControlsVisible(true); } } function startRenderLoop() { if (state.raf) { return; } const render = () => { drawReadyFrames(); syncPlayhead(); state.raf = requestAnimationFrame(render); }; state.raf = requestAnimationFrame(render); } function drawReadyFrames() { if (!state.session) { return; } dropLateDecodedFrames(); const frameLeadSeconds = 1 / Math.max(1, state.session.options.fps); const targetTime = elements.audio.currentTime + frameLeadSeconds; let frameToDraw = null; while (state.frames.length > 0 && state.frames[0].timestamp <= targetTime) { const frame = state.frames.shift(); if (frameToDraw) { releaseImage(frameToDraw.bitmap); } frameToDraw = frame; } if (!frameToDraw) { return; } if (state.currentBitmap) { releaseImage(state.currentBitmap); } state.currentBitmap = frameToDraw.bitmap; drawBitmap(frameToDraw.bitmap); 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); } async function pumpFrameDecodeQueue() { if (state.decodingFrames) { return; } state.decodingFrames = true; try { while (state.pendingFrames.length > 0) { dropLatePendingFrames(); const frame = state.pendingFrames.shift(); if (!frame) { return; } if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) { continue; } let bitmap; try { bitmap = await decodeImage(new Blob([frame.jpeg], { type: 'image/jpeg' })); } catch { continue; } if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) { releaseImage(bitmap); continue; } state.frames.push({ timestamp: frame.timestamp, bitmap }); state.frameCount += 1; trimFrameQueue(); } } finally { state.decodingFrames = false; if (state.pendingFrames.length > 0) { window.setTimeout(() => { void pumpFrameDecodeQueue(); }, 0); } } } function trimPendingFrameQueue() { dropLatePendingFrames(); const maxQueuedFrames = getFrameQueueLimit(MAX_PENDING_FRAME_QUEUE_SECONDS, MIN_PENDING_FRAME_QUEUE); const overflow = state.pendingFrames.length - maxQueuedFrames; if (overflow > 0) { state.pendingFrames.splice(0, overflow); } } function trimFrameQueue() { dropLateDecodedFrames(); const maxQueuedFrames = getFrameQueueLimit(MAX_DECODED_FRAME_QUEUE_SECONDS, MIN_DECODED_FRAME_QUEUE); 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 dropLatePendingFrames() { let removeCount = 0; while (removeCount < state.pendingFrames.length && isLateFrame(state.pendingFrames[removeCount].timestamp)) { removeCount += 1; } if (removeCount > 0) { state.pendingFrames.splice(0, removeCount); } } function dropLateDecodedFrames() { let removeCount = 0; while (removeCount < state.frames.length && isLateFrame(state.frames[removeCount].timestamp)) { removeCount += 1; } if (removeCount <= 0) { return; } const removed = state.frames.splice(0, removeCount); for (const frame of removed) { releaseImage(frame.bitmap); } } function getFrameQueueLimit(seconds, minimum) { const fps = state.session?.options.fps ?? 24; return Math.max(minimum, Math.ceil(fps * seconds)); } function isLateFrame(timestamp) { if (!state.session || state.isSeeking || elements.audio.paused || elements.audio.readyState === 0) { return false; } const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0; return timestamp < currentTime - FRAME_LATE_GRACE_SECONDS; } 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) { 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(); syncPlayhead(); if (shouldShowEntry) { showEntry(); } } function clearFrameQueue() { state.pendingFrames = []; 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(); } } 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); elements.controls.setAttribute('aria-hidden', String(!visible)); if (visible && !elements.audio.paused) { scheduleControlsHide(); } else { clearHideControlsTimer(); } } function scheduleControlsHide() { clearHideControlsTimer(); state.hideControlsTimer = window.setTimeout(() => { if (state.session && !elements.audio.paused && !state.isSeeking) { 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; void loadRecentUrls(); 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; 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; }