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'), libraryPanel: document.querySelector('#library-panel'), recentPanel: document.querySelector('#recent-panel'), recentList: document.querySelector('#recent-list'), favoritesPanel: document.querySelector('#favorites-panel'), favoritesList: document.querySelector('#favorites-list'), editFavorites: document.querySelector('#edit-favorites'), favoritesModal: document.querySelector('#favorites-modal'), favoritesForm: document.querySelector('#favorites-form'), closeFavorites: document.querySelector('#close-favorites'), cancelFavorites: document.querySelector('#cancel-favorites'), addFavorite: document.querySelector('#add-favorite'), saveFavorites: document.querySelector('#save-favorites'), favoritesEditorList: document.querySelector('#favorites-editor-list'), favoritesMessage: document.querySelector('#favorites-message'), 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 = 1.5; const MAX_DECODED_FRAME_QUEUE_SECONDS = 1.5; const MIN_PENDING_FRAME_QUEUE = 6; const MIN_DECODED_FRAME_QUEUE = 8; const IMAGE_DECODE_TIMEOUT_MS = 3000; const FRAME_STALL_CHECK_MS = 1000; const FRAME_STARTUP_STALL_RESET_MS = 10000; const FRAME_STALL_RESET_MS = 6000; const PLAYBACK_RESTART_COOLDOWN_MS = 8000; const PLAYBACK_RESTART_DELAY_MS = 750; const METADATA_REFRESH_ATTEMPTS = 20; const METADATA_REFRESH_INTERVAL_MS = 650; const state = { generation: 0, streamGeneration: 0, session: null, websocket: null, pendingFrames: [], decodingFrames: false, frames: [], currentBitmap: null, raf: 0, frameCount: 0, controlsVisible: true, hideControlsTimer: 0, recentUrls: [], favorites: [], favoriteModalTrigger: null, playbackOffset: 0, duration: null, seekable: false, isSeeking: false, seekPreviewTime: 0, frameWatchdogTimer: 0, lastFramePacketAt: 0, lastFramePaintedAt: 0, lastPlaybackRestartAt: 0, lastRenderErrorAt: 0, }; void loadEntryLists(); 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.favoritesList.addEventListener('click', (event) => { const button = event.target.closest('[data-favorite-index]'); if (!button) { return; } const item = state.favorites[Number(button.dataset.favoriteIndex)]; if (!item) { return; } elements.url.value = item.url; elements.form.requestSubmit(); }); elements.editFavorites.addEventListener('click', () => { openFavoritesModal(); }); elements.closeFavorites.addEventListener('click', () => { closeFavoritesModal(); }); elements.cancelFavorites.addEventListener('click', () => { closeFavoritesModal(); }); elements.addFavorite.addEventListener('click', () => { elements.favoritesEditorList.append(createFavoriteEditorRow()); focusLastFavoriteTitle(); }); elements.favoritesEditorList.addEventListener('click', (event) => { const button = event.target.closest('[data-remove-favorite]'); if (!button) { return; } button.closest('.favorite-editor-row')?.remove(); ensureFavoriteEditorRows(); }); elements.favoritesForm.addEventListener('submit', (event) => { event.preventDefault(); void saveFavoritesFromModal(); }); elements.favoritesModal.addEventListener('click', (event) => { if (event.target === elements.favoritesModal) { closeFavoritesModal(); } }); window.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !elements.favoritesModal.hidden) { closeFavoritesModal(); } }); 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(); startFrameWatchdog(); 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); } }); window.addEventListener('online', () => { const stallReason = getFrameStallReason(); if (state.session && !elements.audio.paused && stallReason) { restartPlaybackStreams(`network_recovered_${stallReason}`); } }); document.addEventListener('visibilitychange', () => { const stallReason = getFrameStallReason(); if (!document.hidden && state.session && !elements.audio.paused && stallReason) { restartPlaybackStreams(`visible_${stallReason}`); } }); 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; state.lastFramePacketAt = 0; state.lastFramePaintedAt = 0; clearFrameQueue(); syncControlLabels(); syncPlayhead(); setControlsVisible(true); elements.loader.hidden = false; clearPlayerMessage(); connectPlaybackStreams(); startFrameWatchdog(); void refreshSessionMetadata(session.id); } function connectPlaybackStreams() { if (!state.session) { return; } const streamGeneration = state.streamGeneration + 1; const session = state.session; const now = Date.now(); state.streamGeneration = streamGeneration; state.lastPlaybackRestartAt = now; state.lastFramePacketAt = now; 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 (streamGeneration !== state.streamGeneration) { return; } if (typeof event.data === 'string') { handleControlMessage(event.data); return; } handleFramePacket(event.data, streamGeneration); }); websocket.addEventListener('close', () => { if (streamGeneration === state.streamGeneration && state.session?.id === session.id) { showPlayerMessage('Stream ended'); setControlsVisible(true); } }); websocket.addEventListener('error', () => { if (streamGeneration === state.streamGeneration && 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 startFrameWatchdog() { if (state.frameWatchdogTimer) { return; } state.frameWatchdogTimer = window.setInterval(checkFrameWatchdog, FRAME_STALL_CHECK_MS); } function clearFrameWatchdog() { if (!state.frameWatchdogTimer) { return; } window.clearInterval(state.frameWatchdogTimer); state.frameWatchdogTimer = 0; } function checkFrameWatchdog() { if (!state.session || state.isSeeking || document.hidden || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) { return; } const stallReason = getFrameStallReason(); if (!stallReason) { return; } restartPlaybackStreams(stallReason); } function getFrameStallReason() { const now = Date.now(); if (!state.session || state.isSeeking || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) { return ''; } if (now - state.lastPlaybackRestartAt < PLAYBACK_RESTART_COOLDOWN_MS) { return ''; } if (state.lastFramePaintedAt < state.lastPlaybackRestartAt) { return now - state.lastPlaybackRestartAt >= FRAME_STARTUP_STALL_RESET_MS ? 'frame_startup_stalled' : ''; } return now - state.lastFramePaintedAt >= FRAME_STALL_RESET_MS ? 'frame_paint_stalled' : ''; } function restartPlaybackStreams(reason = 'frame_stalled') { if (!state.session) { return; } console.warn(`Restarting playback streams: ${reason}`); if (state.seekable && Number.isFinite(state.duration) && state.duration > 0) { state.lastPlaybackRestartAt = Date.now(); void seekTo(getVisiblePlaybackTime()); return; } const streamGeneration = state.streamGeneration + 1; const sessionId = state.session.id; const now = Date.now(); state.streamGeneration = streamGeneration; state.lastPlaybackRestartAt = now; state.lastFramePacketAt = now; elements.loader.hidden = false; clearPlayerMessage(); clearFrameQueue({ keepCurrent: true }); if (state.websocket) { state.websocket.close(1000, 'frame stream stalled'); state.websocket = null; } elements.audio.pause(); elements.audio.removeAttribute('src'); elements.audio.load(); window.setTimeout(() => { if (state.session?.id === sessionId && state.streamGeneration === streamGeneration) { connectPlaybackStreams(); } }, PLAYBACK_RESTART_DELAY_MS); } function handleFramePacket(packet, streamGeneration) { if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) { return; } const timestamp = new DataView(packet, 0, 8).getFloat64(0, true); if (streamGeneration !== state.streamGeneration) { return; } state.lastFramePacketAt = Date.now(); state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), streamGeneration }); 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 = () => { try { drawReadyFrames(); syncPlayhead(); } catch (error) { logRenderError(error); } finally { if (state.session) { state.raf = requestAnimationFrame(render); } else { state.raf = 0; } } }; state.raf = requestAnimationFrame(render); } function drawReadyFrames() { if (!state.session) { return; } dropLateDecodedFrames({ keepNewest: true }); 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); state.lastFramePaintedAt = Date.now(); 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({ keepNewest: true }); const frame = state.pendingFrames.shift(); if (!frame) { return; } if (frame.streamGeneration !== state.streamGeneration) { continue; } if (isLateFrame(frame.timestamp) && state.pendingFrames.length > 0) { continue; } let bitmap; try { bitmap = await decodeImageWithTimeout(new Blob([frame.jpeg], { type: 'image/jpeg' })); } catch { continue; } if (frame.streamGeneration !== state.streamGeneration) { releaseImage(bitmap); continue; } if (isLateFrame(frame.timestamp) && (state.pendingFrames.length > 0 || state.frames.length > 0)) { 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({ keepNewest: true }); 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({ keepNewest: true }); 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({ keepNewest = false } = {}) { let removeCount = 0; const removableFrames = keepNewest ? Math.max(0, state.pendingFrames.length - 1) : state.pendingFrames.length; while (removeCount < removableFrames && isLateFrame(state.pendingFrames[removeCount].timestamp)) { removeCount += 1; } if (removeCount > 0) { state.pendingFrames.splice(0, removeCount); } } function dropLateDecodedFrames({ keepNewest = false } = {}) { let removeCount = 0; const removableFrames = keepNewest ? Math.max(0, state.frames.length - 1) : state.frames.length; while (removeCount < removableFrames && 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.streamGeneration += 1; state.session = null; state.frameCount = 0; state.playbackOffset = 0; state.duration = null; state.seekable = false; state.isSeeking = false; state.seekPreviewTime = 0; state.lastFramePacketAt = 0; state.lastFramePaintedAt = 0; clearHideControlsTimer(); clearFrameWatchdog(); 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({ keepCurrent = false } = {}) { state.pendingFrames = []; for (const frame of state.frames) { releaseImage(frame.bitmap); } state.frames = []; if (!keepCurrent && state.currentBitmap) { releaseImage(state.currentBitmap); state.currentBitmap = null; } } async function decodeImageWithTimeout(blob) { let timedOut = false; let timeoutId = 0; const decodePromise = decodeImage(blob).then( (image) => { if (timedOut) { releaseImage(image); return null; } return image; }, () => null, ); const timeoutPromise = new Promise((resolve) => { timeoutId = window.setTimeout(() => { timedOut = true; resolve(null); }, IMAGE_DECODE_TIMEOUT_MS); }); const image = await Promise.race([decodePromise, timeoutPromise]); window.clearTimeout(timeoutId); if (!image) { throw new Error('Image decode failed.'); } return image; } 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 logRenderError(error) { const now = Date.now(); if (now - state.lastRenderErrorAt < 5000) { return; } state.lastRenderErrorAt = now; console.warn('Frame render loop error', error); } 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.streamGeneration += 1; state.playbackOffset = targetTime; state.seekPreviewTime = targetTime; state.isSeeking = false; state.frameCount = 0; state.lastFramePacketAt = 0; state.lastFramePaintedAt = 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 < METADATA_REFRESH_ATTEMPTS; 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(METADATA_REFRESH_INTERVAL_MS); } } 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 loadEntryLists(); 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.libraryPanel.querySelectorAll('button')) { button.disabled = isBusy; } } async function loadEntryLists() { await Promise.all([ loadRecentUrls(), loadFavorites(), ]); } 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 = 'url-item'; button.dataset.recentIndex = String(index); button.textContent = item.displayUrl || item.url; return button; }), ); if (state.recentUrls.length === 0) { elements.recentList.replaceChildren(createEmptyListMessage('No recents')); } } async function loadFavorites() { try { const response = await fetch('/api/favorites', { cache: 'no-store' }); if (!response.ok) { throw new Error('Failed to load favorites.'); } const payload = await response.json(); state.favorites = Array.isArray(payload.favorites) ? payload.favorites : []; renderFavorites(); } catch { state.favorites = []; renderFavorites(); } } function renderFavorites() { elements.favoritesList.replaceChildren( ...state.favorites.map((item, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'url-item'; button.dataset.favoriteIndex = String(index); button.textContent = item.title; return button; }), ); if (state.favorites.length === 0) { elements.favoritesList.replaceChildren(createEmptyListMessage('No favorites')); } } function createEmptyListMessage(text) { const message = document.createElement('p'); message.className = 'empty-list'; message.textContent = text; return message; } function openFavoritesModal() { state.favoriteModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null; elements.favoritesMessage.textContent = ''; renderFavoriteEditorRows(state.favorites); elements.favoritesModal.hidden = false; elements.favoritesModal.classList.add('is-open'); focusFirstFavoriteField(); } function closeFavoritesModal() { elements.favoritesModal.hidden = true; elements.favoritesModal.classList.remove('is-open'); elements.favoritesMessage.textContent = ''; state.favoriteModalTrigger?.focus(); state.favoriteModalTrigger = null; } function renderFavoriteEditorRows(items) { const rows = items.length > 0 ? items : [{ title: '', url: '' }]; elements.favoritesEditorList.replaceChildren( ...rows.map((item) => createFavoriteEditorRow(item)), ); } function createFavoriteEditorRow(item = { title: '', url: '' }) { const row = document.createElement('div'); row.className = 'favorite-editor-row'; const title = document.createElement('input'); title.type = 'text'; title.placeholder = 'Title'; title.setAttribute('aria-label', 'Favorite title'); title.autocomplete = 'off'; title.maxLength = 120; title.value = item.title ?? ''; title.dataset.favoriteTitle = ''; const url = document.createElement('input'); url.type = 'url'; url.placeholder = 'URL'; url.setAttribute('aria-label', 'Favorite URL'); url.autocomplete = 'off'; url.value = item.url ?? ''; url.dataset.favoriteUrl = ''; const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'remove-favorite'; remove.dataset.removeFavorite = ''; remove.setAttribute('aria-label', 'Remove favorite'); remove.textContent = 'Remove'; row.append(title, url, remove); return row; } function ensureFavoriteEditorRows() { if (elements.favoritesEditorList.children.length === 0) { elements.favoritesEditorList.append(createFavoriteEditorRow()); } } function focusFirstFavoriteField() { const field = elements.favoritesEditorList.querySelector('[data-favorite-title], [data-favorite-url]'); field?.focus(); } function focusLastFavoriteTitle() { const rows = elements.favoritesEditorList.querySelectorAll('.favorite-editor-row'); const row = rows[rows.length - 1]; row?.querySelector('[data-favorite-title]')?.focus(); } async function saveFavoritesFromModal() { elements.favoritesMessage.textContent = ''; setFavoritesModalBusy(true); try { const favorites = readFavoriteEditorRows(); const response = await fetch('/api/favorites', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ favorites }), }); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error ?? 'Failed to save favorites.'); } state.favorites = Array.isArray(payload.favorites) ? payload.favorites : []; renderFavorites(); closeFavoritesModal(); } catch (error) { elements.favoritesMessage.textContent = error.message; } finally { setFavoritesModalBusy(false); } } function readFavoriteEditorRows() { return [...elements.favoritesEditorList.querySelectorAll('.favorite-editor-row')] .map((row) => ({ title: row.querySelector('[data-favorite-title]')?.value.trim() ?? '', url: row.querySelector('[data-favorite-url]')?.value.trim() ?? '', })) .filter((item) => item.title || item.url); } function setFavoritesModalBusy(isBusy) { for (const field of elements.favoritesForm.querySelectorAll('input, button')) { field.disabled = isBusy; } }