Files
carplay/public/app.js

1246 lines
31 KiB
JavaScript
Raw Normal View History

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-04 20:33:14 -07:00
libraryPanel: document.querySelector('#library-panel'),
2026-05-01 22:08:50 -07:00
recentPanel: document.querySelector('#recent-panel'),
recentList: document.querySelector('#recent-list'),
2026-05-04 20:33:14 -07:00
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'),
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 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;
2026-05-04 20:22:21 -07:00
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;
2026-05-26 19:11:19 -07:00
const METADATA_REFRESH_ATTEMPTS = 20;
const METADATA_REFRESH_INTERVAL_MS = 650;
2026-05-01 21:51:25 -07:00
const state = {
generation: 0,
2026-05-04 20:22:21 -07:00
streamGeneration: 0,
2026-05-01 21:51:25 -07:00
session: null,
websocket: null,
pendingFrames: [],
decodingFrames: false,
2026-05-01 21:51:25 -07:00
frames: [],
currentBitmap: null,
raf: 0,
frameCount: 0,
controlsVisible: true,
hideControlsTimer: 0,
2026-05-01 22:08:50 -07:00
recentUrls: [],
2026-05-04 20:33:14 -07:00
favorites: [],
favoriteModalTrigger: null,
2026-05-02 18:53:35 -07:00
playbackOffset: 0,
duration: null,
seekable: false,
isSeeking: false,
seekPreviewTime: 0,
2026-05-04 20:22:21 -07:00
frameWatchdogTimer: 0,
lastFramePacketAt: 0,
lastFramePaintedAt: 0,
lastPlaybackRestartAt: 0,
2026-05-01 21:51:25 -07:00
};
2026-05-04 20:33:14 -07:00
void loadEntryLists();
2026-05-01 22:08:50 -07:00
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-04 20:33:14 -07:00
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();
}
});
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();
2026-05-04 20:22:21 -07:00
startFrameWatchdog();
2026-05-01 21:51:25 -07:00
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);
}
});
2026-05-04 20:22:21 -07:00
window.addEventListener('online', () => {
if (state.session && !elements.audio.paused && isFrameStreamStale(FRAME_STALL_RESET_MS)) {
restartPlaybackStreams();
}
});
2026-05-01 21:51:25 -07:00
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;
2026-05-04 20:22:21 -07:00
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
2026-05-01 21:51:25 -07:00
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();
2026-05-04 20:22:21 -07:00
startFrameWatchdog();
2026-05-02 18:53:35 -07:00
void refreshSessionMetadata(session.id);
}
function connectPlaybackStreams() {
if (!state.session) {
return;
}
2026-05-04 20:22:21 -07:00
const streamGeneration = state.streamGeneration + 1;
2026-05-02 18:53:35 -07:00
const session = state.session;
2026-05-04 20:22:21 -07:00
const now = Date.now();
state.streamGeneration = streamGeneration;
state.lastPlaybackRestartAt = now;
state.lastFramePacketAt = now;
2026-05-02 18:53:35 -07:00
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) => {
2026-05-04 20:22:21 -07:00
if (streamGeneration !== state.streamGeneration) {
2026-05-01 21:51:25 -07:00
return;
}
if (typeof event.data === 'string') {
handleControlMessage(event.data);
return;
}
2026-05-04 20:22:21 -07:00
handleFramePacket(event.data, streamGeneration);
2026-05-01 21:51:25 -07:00
});
websocket.addEventListener('close', () => {
2026-05-04 20:22:21 -07:00
if (streamGeneration === state.streamGeneration && state.session?.id === session.id) {
2026-05-01 21:51:25 -07:00
showPlayerMessage('Stream ended');
setControlsVisible(true);
}
});
websocket.addEventListener('error', () => {
2026-05-04 20:22:21 -07:00
if (streamGeneration === state.streamGeneration && 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);
}
}
2026-05-04 20:22:21 -07:00
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 resetAfterMs = state.lastFramePaintedAt > 0 ? FRAME_STALL_RESET_MS : FRAME_STARTUP_STALL_RESET_MS;
if (!isFrameStreamStale(resetAfterMs)) {
return;
}
restartPlaybackStreams();
}
function isFrameStreamStale(resetAfterMs) {
const now = Date.now();
const lastFrameAt = state.lastFramePacketAt || state.lastPlaybackRestartAt || now;
return now - lastFrameAt >= resetAfterMs && now - state.lastPlaybackRestartAt >= PLAYBACK_RESTART_COOLDOWN_MS;
}
function restartPlaybackStreams() {
if (!state.session) {
return;
}
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) {
2026-05-01 21:51:25 -07:00
if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
return;
}
const timestamp = new DataView(packet, 0, 8).getFloat64(0, true);
2026-05-04 20:22:21 -07:00
if (streamGeneration !== state.streamGeneration || isLateFrame(timestamp)) {
2026-05-01 21:51:25 -07:00
return;
}
2026-05-04 20:22:21 -07:00
state.lastFramePacketAt = Date.now();
state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), streamGeneration });
trimPendingFrameQueue();
void pumpFrameDecodeQueue();
2026-05-01 21:51:25 -07:00
}
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;
}
dropLateDecodedFrames();
2026-05-01 21:51:25 -07:00
const frameLeadSeconds = 1 / Math.max(1, state.session.options.fps);
const targetTime = elements.audio.currentTime + frameLeadSeconds;
let frameToDraw = null;
2026-05-01 21:51:25 -07:00
while (state.frames.length > 0 && state.frames[0].timestamp <= targetTime) {
const frame = state.frames.shift();
if (frameToDraw) {
releaseImage(frameToDraw.bitmap);
2026-05-01 21:51:25 -07:00
}
frameToDraw = frame;
2026-05-01 21:51:25 -07:00
}
if (!frameToDraw) {
return;
}
if (state.currentBitmap) {
releaseImage(state.currentBitmap);
2026-05-01 21:51:25 -07:00
}
state.currentBitmap = frameToDraw.bitmap;
drawBitmap(frameToDraw.bitmap);
2026-05-04 20:22:21 -07:00
state.lastFramePaintedAt = Date.now();
elements.loader.hidden = true;
clearPlayerMessage();
2026-05-01 21:51:25 -07:00
}
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;
}
2026-05-04 20:22:21 -07:00
if (frame.streamGeneration !== state.streamGeneration || isLateFrame(frame.timestamp)) {
continue;
}
let bitmap;
try {
2026-05-04 20:22:21 -07:00
bitmap = await decodeImageWithTimeout(new Blob([frame.jpeg], { type: 'image/jpeg' }));
} catch {
continue;
}
2026-05-04 20:22:21 -07:00
if (frame.streamGeneration !== state.streamGeneration || 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);
}
}
2026-05-01 21:51:25 -07:00
function trimFrameQueue() {
dropLateDecodedFrames();
const maxQueuedFrames = getFrameQueueLimit(MAX_DECODED_FRAME_QUEUE_SECONDS, MIN_DECODED_FRAME_QUEUE);
2026-05-01 21:51:25 -07:00
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;
}
2026-05-01 21:51:25 -07:00
function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.generation += 1;
2026-05-04 20:22:21 -07:00
state.streamGeneration += 1;
2026-05-01 21:51:25 -07:00
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-04 20:22:21 -07:00
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
2026-05-01 21:51:25 -07:00
clearHideControlsTimer();
2026-05-04 20:22:21 -07:00
clearFrameWatchdog();
2026-05-01 21:51:25 -07:00
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();
}
}
2026-05-04 20:22:21 -07:00
function clearFrameQueue({ keepCurrent = false } = {}) {
state.pendingFrames = [];
2026-05-01 21:51:25 -07:00
for (const frame of state.frames) {
releaseImage(frame.bitmap);
}
state.frames = [];
2026-05-04 20:22:21 -07:00
if (!keepCurrent && state.currentBitmap) {
2026-05-01 21:51:25 -07:00
releaseImage(state.currentBitmap);
state.currentBitmap = null;
}
}
2026-05-04 20:22:21 -07:00
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;
}
2026-05-01 21:51:25 -07:00
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;
2026-05-04 20:22:21 -07:00
state.streamGeneration += 1;
2026-05-02 18:53:35 -07:00
state.playbackOffset = targetTime;
state.seekPreviewTime = targetTime;
state.isSeeking = false;
state.frameCount = 0;
2026-05-04 20:22:21 -07:00
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
2026-05-02 18:53:35 -07:00
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;
2026-05-26 19:11:19 -07:00
for (let attempt = 0; attempt < METADATA_REFRESH_ATTEMPTS; attempt += 1) {
2026-05-02 18:53:35 -07:00
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;
}
2026-05-26 19:11:19 -07:00
await delay(METADATA_REFRESH_INTERVAL_MS);
2026-05-02 18:53:35 -07:00
}
}
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-04 20:33:14 -07:00
void loadEntryLists();
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
2026-05-04 20:33:14 -07:00
for (const button of elements.libraryPanel.querySelectorAll('button')) {
2026-05-01 22:08:50 -07:00
button.disabled = isBusy;
}
}
2026-05-04 20:33:14 -07:00
async function loadEntryLists() {
await Promise.all([
loadRecentUrls(),
loadFavorites(),
]);
}
2026-05-01 22:08:50 -07:00
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';
2026-05-04 20:33:14 -07:00
button.className = 'url-item';
2026-05-01 22:08:50 -07:00
button.dataset.recentIndex = String(index);
button.textContent = item.displayUrl || item.url;
return button;
}),
);
2026-05-04 20:33:14 -07:00
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;
}
2026-05-01 21:51:25 -07:00
}