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'), 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, recentUrls: [], }; 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.audio.addEventListener('play', () => { startRenderLoop(); syncControlLabels(); scheduleControlsHide(); }); elements.audio.addEventListener('pause', () => { syncControlLabels(); setControlsVisible(true); }); 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('error', () => { if (state.session) { showPlayerMessage('Audio failed'); setControlsVisible(true); } }); function startSession(session) { const generation = state.generation; state.session = session; state.frameCount = 0; clearFrameQueue(); syncControlLabels(); setControlsVisible(true); elements.loader.hidden = false; clearPlayerMessage(); const websocketProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const websocketUrl = `${websocketProtocol}//${window.location.host}/frames/${session.id}`; 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 (state.session?.id === session.id) { showPlayerMessage('Stream ended'); setControlsVisible(true); } }); websocket.addEventListener('error', () => { if (state.session?.id === session.id) { showPlayerMessage('Stream failed'); setControlsVisible(true); } }); elements.audio.src = `/audio/${session.id}`; 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); } if (message.type === 'end') { showPlayerMessage('Stream ended'); setControlsVisible(true); } } function startRenderLoop() { if (state.raf) { return; } const render = () => { drawReadyFrames(); 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; 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(); 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(); } } 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) { 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; }