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'),
|
2026-06-11 21:13:48 -07:00
|
|
|
play: document.querySelector('#play'),
|
|
|
|
|
queue: document.querySelector('#queue'),
|
2026-05-01 21:51:25 -07:00
|
|
|
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'),
|
2026-06-11 21:13:48 -07:00
|
|
|
favoritesManager: document.querySelector('#favorites-manager'),
|
2026-05-04 20:33:14 -07:00
|
|
|
favoritesEditorList: document.querySelector('#favorites-editor-list'),
|
2026-06-11 21:13:48 -07:00
|
|
|
favoriteWizard: document.querySelector('#favorite-wizard'),
|
|
|
|
|
favoriteWizardStep: document.querySelector('#favorite-wizard-step'),
|
|
|
|
|
favoriteWizardBack: document.querySelector('#favorite-wizard-back'),
|
|
|
|
|
favoriteTitleField: document.querySelector('#favorite-title-field'),
|
|
|
|
|
favoriteUrlField: document.querySelector('#favorite-url-field'),
|
|
|
|
|
favoriteTitle: document.querySelector('#favorite-title'),
|
|
|
|
|
favoriteUrl: document.querySelector('#favorite-url'),
|
2026-05-04 20:33:14 -07:00
|
|
|
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'),
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-07 00:44:45 -07:00
|
|
|
const context = elements.canvas.getContext('2d', { alpha: false, desynchronized: true });
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
const FRAME_LATE_GRACE_SECONDS = 0.25;
|
2026-06-11 10:06:56 -07:00
|
|
|
const FRAME_CLOCK_SEND_INTERVAL_MS = 250;
|
|
|
|
|
const CLIENT_TELEMETRY_INTERVAL_MS = 5000;
|
2026-06-07 00:52:37 -07:00
|
|
|
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;
|
2026-06-12 19:47:02 -07:00
|
|
|
const FRAME_STARTUP_STALL_RESET_MS = 15000;
|
|
|
|
|
const FRAME_STALL_RESET_MS = 12000;
|
|
|
|
|
const FRAME_PACKET_ACTIVITY_GRACE_MS = 5000;
|
|
|
|
|
const FRAME_UNPAINTED_PACKET_RESET_MS = 20000;
|
|
|
|
|
const PLAYBACK_RESTART_COOLDOWN_MS = 15000;
|
2026-05-04 20:22:21 -07:00
|
|
|
const PLAYBACK_RESTART_DELAY_MS = 750;
|
2026-06-12 19:47:02 -07:00
|
|
|
const MIN_RECOVERY_RESUME_SECONDS = 2;
|
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,
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
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-06-11 21:13:48 -07:00
|
|
|
favoriteWizard: createFavoriteWizardState(),
|
2026-05-02 18:53:35 -07:00
|
|
|
playbackOffset: 0,
|
|
|
|
|
duration: null,
|
|
|
|
|
seekable: false,
|
|
|
|
|
isSeeking: false,
|
2026-06-12 19:46:45 -07:00
|
|
|
seekPointerId: null,
|
2026-06-12 19:47:02 -07:00
|
|
|
isRestartingPlayback: false,
|
2026-05-02 18:53:35 -07:00
|
|
|
seekPreviewTime: 0,
|
2026-06-12 19:46:45 -07:00
|
|
|
lastSeekCommitTime: null,
|
|
|
|
|
lastSeekCommitAt: 0,
|
2026-05-04 20:22:21 -07:00
|
|
|
frameWatchdogTimer: 0,
|
|
|
|
|
lastFramePacketAt: 0,
|
|
|
|
|
lastFramePaintedAt: 0,
|
2026-06-11 10:06:56 -07:00
|
|
|
lastFrameClockSentAt: 0,
|
|
|
|
|
lastClientTelemetrySentAt: 0,
|
2026-05-04 20:22:21 -07:00
|
|
|
lastPlaybackRestartAt: 0,
|
2026-06-05 21:19:07 -07:00
|
|
|
lastRenderErrorAt: 0,
|
2026-06-11 10:06:56 -07:00
|
|
|
clientTelemetry: createClientTelemetry(),
|
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();
|
2026-06-11 21:13:48 -07:00
|
|
|
await playCurrentUrl();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.queue.addEventListener('click', () => {
|
|
|
|
|
void queueCurrentUrl();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function playCurrentUrl() {
|
2026-05-01 21:51:25 -07:00
|
|
|
setEntryMessage('');
|
|
|
|
|
setFormBusy(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
stopSession({ showEntry: false });
|
|
|
|
|
|
|
|
|
|
const response = await fetch('/api/session', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-06-11 10:29:07 -07:00
|
|
|
body: JSON.stringify({ url: elements.url.value, width: getViewportFrameWidth() }),
|
2026-05-01 21:51:25 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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-06-11 21:13:48 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function queueCurrentUrl() {
|
|
|
|
|
setEntryMessage('');
|
|
|
|
|
setFormBusy(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/recent-urls', {
|
|
|
|
|
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 queue URL.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.recentUrls = Array.isArray(payload.urls) ? payload.urls : [];
|
|
|
|
|
renderRecentUrls();
|
|
|
|
|
setEntryMessage('Queued');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setEntryMessage(error.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setFormBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-01 21:51:25 -07:00
|
|
|
|
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', () => {
|
2026-06-11 21:13:48 -07:00
|
|
|
if (state.favoriteWizard.active) {
|
|
|
|
|
showFavoritesManager();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 20:33:14 -07:00
|
|
|
closeFavoritesModal();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.addFavorite.addEventListener('click', () => {
|
2026-06-11 21:13:48 -07:00
|
|
|
startFavoriteWizard('add');
|
2026-05-04 20:33:14 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.favoritesEditorList.addEventListener('click', (event) => {
|
2026-06-11 21:13:48 -07:00
|
|
|
const removeButton = event.target.closest('[data-remove-favorite]');
|
|
|
|
|
const editButton = event.target.closest('[data-edit-favorite]');
|
2026-05-04 20:33:14 -07:00
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
if (removeButton) {
|
|
|
|
|
void removeFavorite(Number(removeButton.dataset.removeFavorite));
|
2026-05-04 20:33:14 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
if (editButton) {
|
|
|
|
|
startFavoriteWizard('edit', Number(editButton.dataset.editFavorite));
|
|
|
|
|
}
|
2026-05-04 20:33:14 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.favoritesForm.addEventListener('submit', (event) => {
|
|
|
|
|
event.preventDefault();
|
2026-06-11 21:13:48 -07:00
|
|
|
void advanceFavoriteWizard();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.favoriteWizardBack.addEventListener('click', () => {
|
|
|
|
|
if (!state.favoriteWizard.active) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.favoriteWizard.step === 'url') {
|
|
|
|
|
state.favoriteWizard.step = 'title';
|
|
|
|
|
syncFavoriteWizard();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showFavoritesManager();
|
2026-05-04 20:33:14 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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-06-12 19:46:45 -07:00
|
|
|
elements.seek.addEventListener('pointerdown', (event) => {
|
|
|
|
|
beginSeekScrub(event);
|
2026-05-02 18:53:35 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.seek.addEventListener('input', () => {
|
2026-06-12 19:46:45 -07:00
|
|
|
updateSeekScrubPreview();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.seek.addEventListener('pointermove', (event) => {
|
|
|
|
|
if (state.isSeeking && state.seekPointerId === null) {
|
|
|
|
|
updateSeekScrubPreview(event);
|
2026-05-02 18:53:35 -07:00
|
|
|
}
|
2026-06-12 19:46:45 -07:00
|
|
|
});
|
2026-05-02 18:53:35 -07:00
|
|
|
|
2026-06-12 19:46:45 -07:00
|
|
|
elements.seek.addEventListener('pointerup', (event) => {
|
|
|
|
|
updateSeekScrubPreview(event);
|
|
|
|
|
commitSeekScrub(event);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.seek.addEventListener('pointercancel', () => {
|
|
|
|
|
cancelSeekScrub();
|
2026-05-02 18:53:35 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.seek.addEventListener('change', () => {
|
2026-06-12 19:46:45 -07:00
|
|
|
commitSeekScrub({ force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.addEventListener('pointerup', (event) => {
|
|
|
|
|
if (state.seekPointerId === event.pointerId) {
|
|
|
|
|
updateSeekScrubPreview(event);
|
|
|
|
|
commitSeekScrub(event);
|
2026-05-02 18:53:35 -07:00
|
|
|
}
|
2026-06-12 19:46:45 -07:00
|
|
|
});
|
2026-05-02 18:53:35 -07:00
|
|
|
|
2026-06-12 19:46:45 -07:00
|
|
|
window.addEventListener('pointermove', (event) => {
|
|
|
|
|
if (state.seekPointerId === event.pointerId) {
|
|
|
|
|
updateSeekScrubPreview(event);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.addEventListener('pointercancel', (event) => {
|
|
|
|
|
if (state.seekPointerId === event.pointerId) {
|
|
|
|
|
cancelSeekScrub();
|
|
|
|
|
}
|
2026-05-02 18:53:35 -07:00
|
|
|
});
|
|
|
|
|
|
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', () => {
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('audioPlaying');
|
2026-05-01 21:51:25 -07:00
|
|
|
elements.loader.hidden = state.frameCount > 0;
|
|
|
|
|
clearPlayerMessage();
|
|
|
|
|
scheduleControlsHide();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.audio.addEventListener('waiting', () => {
|
|
|
|
|
if (state.session) {
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('audioWaiting');
|
2026-05-01 21:51:25 -07:00
|
|
|
elements.loader.hidden = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-02 18:53:35 -07:00
|
|
|
elements.audio.addEventListener('timeupdate', () => {
|
|
|
|
|
syncPlayhead();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.audio.addEventListener('ended', () => {
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('audioEnded');
|
2026-05-02 18:53:35 -07:00
|
|
|
syncPlayhead();
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
elements.audio.addEventListener('error', () => {
|
|
|
|
|
if (state.session) {
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('audioErrors');
|
|
|
|
|
sendClientTelemetry({ force: true, reason: 'audio_error' });
|
2026-05-01 21:51:25 -07:00
|
|
|
showPlayerMessage('Audio failed');
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-04 20:22:21 -07:00
|
|
|
window.addEventListener('online', () => {
|
2026-06-05 21:19:07 -07:00
|
|
|
const stallReason = getFrameStallReason();
|
|
|
|
|
|
|
|
|
|
if (state.session && !elements.audio.paused && stallReason) {
|
2026-06-12 19:47:02 -07:00
|
|
|
void restartPlaybackStreams(`network_recovered_${stallReason}`);
|
2026-06-05 21:19:07 -07:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
|
|
|
const stallReason = getFrameStallReason();
|
|
|
|
|
|
|
|
|
|
if (!document.hidden && state.session && !elements.audio.paused && stallReason) {
|
2026-06-12 19:47:02 -07:00
|
|
|
void restartPlaybackStreams(`visible_${stallReason}`);
|
2026-05-04 20:22:21 -07:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
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-06-11 10:06:56 -07:00
|
|
|
state.lastFrameClockSentAt = 0;
|
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() {
|
2026-06-12 19:47:02 -07:00
|
|
|
if (!state.session || state.isSeeking || state.isRestartingPlayback || document.hidden || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) {
|
2026-05-04 20:22:21 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
const stallReason = getFrameStallReason();
|
2026-05-04 20:22:21 -07:00
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
if (!stallReason) {
|
2026-05-04 20:22:21 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 19:47:02 -07:00
|
|
|
void restartPlaybackStreams(stallReason);
|
2026-05-04 20:22:21 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
function getFrameStallReason() {
|
2026-05-04 20:22:21 -07:00
|
|
|
const now = Date.now();
|
2026-06-05 21:19:07 -07:00
|
|
|
|
2026-06-12 19:47:02 -07:00
|
|
|
if (!state.session || state.isSeeking || state.isRestartingPlayback || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) {
|
2026-06-05 21:19:07 -07:00
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (now - state.lastPlaybackRestartAt < PLAYBACK_RESTART_COOLDOWN_MS) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 19:47:02 -07:00
|
|
|
const hasRecentPackets = state.lastFramePacketAt > state.lastFramePaintedAt
|
|
|
|
|
&& now - state.lastFramePacketAt < FRAME_PACKET_ACTIVITY_GRACE_MS;
|
|
|
|
|
|
|
|
|
|
if (hasRecentPackets) {
|
|
|
|
|
const unpaintedSince = state.lastFramePaintedAt > state.lastPlaybackRestartAt
|
|
|
|
|
? state.lastFramePaintedAt
|
|
|
|
|
: state.lastPlaybackRestartAt;
|
|
|
|
|
|
|
|
|
|
if (now - unpaintedSince < FRAME_UNPAINTED_PACKET_RESET_MS) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'frame_packets_unpainted';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
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' : '';
|
2026-05-04 20:22:21 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-12 19:47:02 -07:00
|
|
|
async function restartPlaybackStreams(reason = 'frame_stalled') {
|
|
|
|
|
if (!state.session || state.isRestartingPlayback) {
|
2026-05-04 20:22:21 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
console.warn(`Restarting playback streams: ${reason}`);
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('playbackRestarts');
|
|
|
|
|
sendClientTelemetry({ force: true, reason });
|
2026-06-05 21:19:07 -07:00
|
|
|
|
2026-06-12 19:47:02 -07:00
|
|
|
state.isRestartingPlayback = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const resumeTime = getVisiblePlaybackTime();
|
|
|
|
|
|
|
|
|
|
if (resumeTime >= MIN_RECOVERY_RESUME_SECONDS) {
|
|
|
|
|
const resumed = await resumePlaybackAt(resumeTime, reason);
|
|
|
|
|
|
|
|
|
|
if (resumed) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
restartPlaybackFromCurrentSource(reason);
|
|
|
|
|
} finally {
|
|
|
|
|
state.isRestartingPlayback = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restartPlaybackFromCurrentSource(reason) {
|
|
|
|
|
if (!state.session) {
|
2026-05-04 20:22:21 -07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 19:47:02 -07:00
|
|
|
async function resumePlaybackAt(value, reason) {
|
|
|
|
|
const sessionId = state.session?.id;
|
|
|
|
|
|
|
|
|
|
if (!sessionId) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const duration = state.duration;
|
|
|
|
|
const targetTime = clampNumber(value, 0, Number.isFinite(duration) ? duration : Number.MAX_SAFE_INTEGER);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/session/${encodeURIComponent(sessionId)}/seek`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ time: targetTime, allowBestEffort: true, reason }),
|
|
|
|
|
});
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
|
|
|
|
|
if (response.status === 409) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(payload.error ?? 'Recovery seek failed.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.session?.id !== sessionId) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.generation += 1;
|
|
|
|
|
state.streamGeneration += 1;
|
|
|
|
|
state.session = payload;
|
|
|
|
|
updateSessionMetadata(payload);
|
|
|
|
|
state.playbackOffset = Number.isFinite(payload.seekSeconds) ? payload.seekSeconds : targetTime;
|
|
|
|
|
state.seekPreviewTime = state.playbackOffset;
|
|
|
|
|
state.isSeeking = false;
|
|
|
|
|
state.frameCount = 0;
|
|
|
|
|
state.lastFramePacketAt = 0;
|
|
|
|
|
state.lastFramePaintedAt = 0;
|
|
|
|
|
elements.loader.hidden = false;
|
|
|
|
|
clearPlayerMessage();
|
|
|
|
|
clearFrameQueue({ keepCurrent: true });
|
|
|
|
|
syncPlayhead();
|
|
|
|
|
|
|
|
|
|
if (state.websocket) {
|
|
|
|
|
state.websocket.close(1000, 'client recovery seek');
|
|
|
|
|
state.websocket = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
elements.audio.pause();
|
|
|
|
|
elements.audio.removeAttribute('src');
|
|
|
|
|
elements.audio.load();
|
|
|
|
|
connectPlaybackStreams();
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Playback recovery seek failed', error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 20:22:21 -07:00
|
|
|
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-06-05 21:19:07 -07:00
|
|
|
if (streamGeneration !== state.streamGeneration) {
|
2026-05-01 21:51:25 -07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:06:56 -07:00
|
|
|
if (isLateFrame(timestamp)) {
|
|
|
|
|
noteLateFrameTelemetry('lateFramePackets', timestamp);
|
|
|
|
|
state.lastFramePacketAt = Date.now();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 20:22:21 -07:00
|
|
|
state.lastFramePacketAt = Date.now();
|
2026-06-07 00:44:45 -07:00
|
|
|
state.pendingFrames.push({ timestamp, jpeg: new Uint8Array(packet, 8), streamGeneration });
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:06:56 -07:00
|
|
|
function sendFrameClock() {
|
|
|
|
|
if (!state.session || !state.websocket || state.websocket.readyState !== WebSocket.OPEN || elements.audio.readyState === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
if (now - state.lastFrameClockSentAt < FRAME_CLOCK_SEND_INTERVAL_MS) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0;
|
|
|
|
|
|
|
|
|
|
state.lastFrameClockSentAt = now;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
state.websocket.send(JSON.stringify({ type: 'clock', currentTime }));
|
|
|
|
|
} catch {
|
|
|
|
|
state.lastFrameClockSentAt = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendClientTelemetry({ force = false, reason = 'periodic' } = {}) {
|
|
|
|
|
if (!state.websocket || state.websocket.readyState !== WebSocket.OPEN) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
if (!force && now - state.lastClientTelemetrySentAt < CLIENT_TELEMETRY_INTERVAL_MS) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.lastClientTelemetrySentAt = now;
|
|
|
|
|
|
|
|
|
|
const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0;
|
|
|
|
|
const telemetry = state.clientTelemetry;
|
|
|
|
|
const payload = {
|
|
|
|
|
type: 'telemetry',
|
|
|
|
|
reason,
|
|
|
|
|
currentTime,
|
|
|
|
|
paused: elements.audio.paused,
|
|
|
|
|
readyState: elements.audio.readyState,
|
|
|
|
|
pendingFrames: state.pendingFrames.length,
|
|
|
|
|
decodedFrames: state.frames.length,
|
|
|
|
|
paintedFrames: state.frameCount,
|
|
|
|
|
lastFramePacketAgeMs: state.lastFramePacketAt > 0 ? now - state.lastFramePacketAt : null,
|
|
|
|
|
lastFramePaintAgeMs: state.lastFramePaintedAt > 0 ? now - state.lastFramePaintedAt : null,
|
|
|
|
|
hidden: document.hidden,
|
|
|
|
|
online: navigator.onLine,
|
|
|
|
|
counters: telemetry.counters,
|
|
|
|
|
max: telemetry.max,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
state.clientTelemetry = createClientTelemetry();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
state.websocket.send(JSON.stringify(payload));
|
|
|
|
|
} catch {
|
|
|
|
|
state.clientTelemetry = mergeClientTelemetry(state.clientTelemetry, telemetry);
|
|
|
|
|
state.lastClientTelemetrySentAt = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
function startRenderLoop() {
|
|
|
|
|
if (state.raf) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const render = () => {
|
2026-06-05 21:19:07 -07:00
|
|
|
try {
|
2026-06-11 10:06:56 -07:00
|
|
|
sendFrameClock();
|
|
|
|
|
sendClientTelemetry();
|
2026-06-05 21:19:07 -07:00
|
|
|
drawReadyFrames();
|
|
|
|
|
syncPlayhead();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logRenderError(error);
|
|
|
|
|
} finally {
|
|
|
|
|
if (state.session) {
|
|
|
|
|
state.raf = requestAnimationFrame(render);
|
|
|
|
|
} else {
|
|
|
|
|
state.raf = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-01 21:51:25 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
state.raf = requestAnimationFrame(render);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawReadyFrames() {
|
|
|
|
|
if (!state.session) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:06:56 -07:00
|
|
|
dropLateDecodedFrames();
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
const frameLeadSeconds = 1 / Math.max(1, state.session.options.fps);
|
|
|
|
|
const targetTime = elements.audio.currentTime + frameLeadSeconds;
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
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();
|
|
|
|
|
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
if (frameToDraw) {
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('readyFramesSkippedForNewer');
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
releaseImage(frameToDraw.bitmap);
|
2026-05-01 21:51:25 -07:00
|
|
|
}
|
|
|
|
|
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
frameToDraw = frame;
|
2026-05-01 21:51:25 -07:00
|
|
|
}
|
|
|
|
|
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
if (!frameToDraw) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.currentBitmap) {
|
|
|
|
|
releaseImage(state.currentBitmap);
|
2026-05-01 21:51:25 -07:00
|
|
|
}
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
|
|
|
|
|
state.currentBitmap = frameToDraw.bitmap;
|
|
|
|
|
drawBitmap(frameToDraw.bitmap);
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('framesPainted');
|
|
|
|
|
noteFrameLagTelemetry('paintLagMs', frameToDraw.timestamp);
|
2026-05-04 20:22:21 -07:00
|
|
|
state.lastFramePaintedAt = Date.now();
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
async function pumpFrameDecodeQueue() {
|
|
|
|
|
if (state.decodingFrames) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.decodingFrames = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
while (state.pendingFrames.length > 0) {
|
2026-06-11 10:06:56 -07:00
|
|
|
dropLatePendingFrames();
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
|
|
|
|
|
const frame = state.pendingFrames.shift();
|
|
|
|
|
|
|
|
|
|
if (!frame) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
if (frame.streamGeneration !== state.streamGeneration) {
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('staleGenerationPendingFrames');
|
2026-06-05 21:19:07 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:06:56 -07:00
|
|
|
if (isLateFrame(frame.timestamp)) {
|
|
|
|
|
noteLateFrameTelemetry('latePendingFramesBeforeDecode', frame.timestamp);
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let bitmap;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-04 20:22:21 -07:00
|
|
|
bitmap = await decodeImageWithTimeout(new Blob([frame.jpeg], { type: 'image/jpeg' }));
|
2026-06-11 10:06:56 -07:00
|
|
|
} catch (error) {
|
|
|
|
|
noteClientTelemetry(error.message === 'Image decode timed out.' ? 'imageDecodeTimeouts' : 'imageDecodeFailures');
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
if (frame.streamGeneration !== state.streamGeneration) {
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('staleGenerationDecodedFrames');
|
2026-06-05 21:19:07 -07:00
|
|
|
releaseImage(bitmap);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:06:56 -07:00
|
|
|
if (isLateFrame(frame.timestamp)) {
|
|
|
|
|
noteLateFrameTelemetry('latePendingFramesAfterDecode', frame.timestamp);
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
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() {
|
2026-06-11 10:06:56 -07:00
|
|
|
dropLatePendingFrames();
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
|
|
|
|
|
const maxQueuedFrames = getFrameQueueLimit(MAX_PENDING_FRAME_QUEUE_SECONDS, MIN_PENDING_FRAME_QUEUE);
|
|
|
|
|
const overflow = state.pendingFrames.length - maxQueuedFrames;
|
|
|
|
|
|
|
|
|
|
if (overflow > 0) {
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('pendingQueueOverflowFrames', overflow);
|
|
|
|
|
noteClientTelemetryMax('pendingQueuePeakFrames', state.pendingFrames.length);
|
2026-06-11 10:29:07 -07:00
|
|
|
state.pendingFrames.splice(maxQueuedFrames, overflow);
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
function trimFrameQueue() {
|
2026-06-11 10:06:56 -07:00
|
|
|
dropLateDecodedFrames();
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:29:07 -07:00
|
|
|
const removed = state.frames.splice(maxQueuedFrames, overflow);
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('decodedQueueOverflowFrames', overflow);
|
|
|
|
|
noteClientTelemetryMax('decodedQueuePeakFrames', state.frames.length + overflow);
|
2026-05-01 21:51:25 -07:00
|
|
|
|
|
|
|
|
for (const frame of removed) {
|
|
|
|
|
releaseImage(frame.bitmap);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
function dropLatePendingFrames({ keepNewest = false } = {}) {
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
let removeCount = 0;
|
2026-06-05 21:19:07 -07:00
|
|
|
const removableFrames = keepNewest ? Math.max(0, state.pendingFrames.length - 1) : state.pendingFrames.length;
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
while (removeCount < removableFrames && isLateFrame(state.pendingFrames[removeCount].timestamp)) {
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
removeCount += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (removeCount > 0) {
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('latePendingFrames', removeCount);
|
|
|
|
|
noteFrameLagTelemetry('latePendingFramesMaxLagMs', state.pendingFrames[0]?.timestamp);
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
state.pendingFrames.splice(0, removeCount);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
function dropLateDecodedFrames({ keepNewest = false } = {}) {
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
let removeCount = 0;
|
2026-06-05 21:19:07 -07:00
|
|
|
const removableFrames = keepNewest ? Math.max(0, state.frames.length - 1) : state.frames.length;
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
|
2026-06-05 21:19:07 -07:00
|
|
|
while (removeCount < removableFrames && isLateFrame(state.frames[removeCount].timestamp)) {
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
removeCount += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (removeCount <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const removed = state.frames.splice(0, removeCount);
|
2026-06-11 10:06:56 -07:00
|
|
|
noteClientTelemetry('lateDecodedFrames', removeCount);
|
|
|
|
|
noteFrameLagTelemetry('lateDecodedFramesMaxLagMs', removed[0]?.timestamp);
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:29:07 -07:00
|
|
|
function getViewportFrameWidth() {
|
|
|
|
|
const width = Math.ceil(Math.max(
|
|
|
|
|
document.documentElement.clientWidth || 0,
|
|
|
|
|
window.innerWidth || 0,
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
return clampNumber(width, 160, 1920);
|
|
|
|
|
}
|
|
|
|
|
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
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-06-11 10:06:56 -07:00
|
|
|
function noteLateFrameTelemetry(name, timestamp, count = 1) {
|
|
|
|
|
noteClientTelemetry(name, count);
|
|
|
|
|
noteFrameLagTelemetry(`${name}MaxLagMs`, timestamp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function noteFrameLagTelemetry(name, timestamp) {
|
|
|
|
|
if (!Number.isFinite(timestamp) || elements.audio.readyState === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0;
|
|
|
|
|
noteClientTelemetryMax(name, Math.max(0, currentTime - timestamp) * 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createClientTelemetry() {
|
|
|
|
|
return {
|
|
|
|
|
counters: {},
|
|
|
|
|
max: {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
function createFavoriteWizardState() {
|
|
|
|
|
return {
|
|
|
|
|
active: false,
|
|
|
|
|
mode: 'add',
|
|
|
|
|
index: -1,
|
|
|
|
|
step: 'title',
|
|
|
|
|
title: '',
|
|
|
|
|
url: '',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:06:56 -07:00
|
|
|
function noteClientTelemetry(name, count = 1) {
|
|
|
|
|
if (!state.clientTelemetry || !Number.isFinite(count) || count <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.clientTelemetry.counters[name] = (state.clientTelemetry.counters[name] ?? 0) + count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function noteClientTelemetryMax(name, value) {
|
|
|
|
|
if (!state.clientTelemetry || !Number.isFinite(value)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.clientTelemetry.max[name] = Math.max(state.clientTelemetry.max[name] ?? 0, Math.round(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mergeClientTelemetry(target, source) {
|
|
|
|
|
for (const [name, count] of Object.entries(source.counters)) {
|
|
|
|
|
target.counters[name] = (target.counters[name] ?? 0) + count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [name, value] of Object.entries(source.max)) {
|
|
|
|
|
target.max[name] = Math.max(target.max[name] ?? 0, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return target;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 21:51:25 -07:00
|
|
|
function stopSession({ showEntry: shouldShowEntry = true } = {}) {
|
2026-06-11 10:06:56 -07:00
|
|
|
sendClientTelemetry({ force: true, reason: 'stop_session' });
|
2026-05-01 21:51:25 -07:00
|
|
|
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;
|
2026-06-12 19:46:45 -07:00
|
|
|
state.seekPointerId = null;
|
2026-06-12 19:47:02 -07:00
|
|
|
state.isRestartingPlayback = false;
|
2026-05-02 18:53:35 -07:00
|
|
|
state.seekPreviewTime = 0;
|
2026-06-12 19:46:45 -07:00
|
|
|
state.lastSeekCommitTime = null;
|
|
|
|
|
state.lastSeekCommitAt = 0;
|
2026-05-04 20:22:21 -07:00
|
|
|
state.lastFramePacketAt = 0;
|
|
|
|
|
state.lastFramePaintedAt = 0;
|
2026-06-11 10:06:56 -07:00
|
|
|
state.lastFrameClockSentAt = 0;
|
|
|
|
|
state.lastClientTelemetrySentAt = 0;
|
|
|
|
|
state.clientTelemetry = createClientTelemetry();
|
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 } = {}) {
|
sequential frames support
- Server now has configurable MAX_WS_BUFFER_BYTES defaulting to 2097152, and skips JPEG frames
when the WebSocket is backed up instead of queueing stale frames in ws (server/index.js:30,
server/index.js:1439).
- Browser frame handling now decodes frames sequentially, drops late frames against the audio
clock, caps pending/decoded queues, and draws only the latest due frame per animation tick
(public/app.js:280, public/app.js:381).
- Relay/split normal EOF closes are no longer mislabeled as client_disconnect, which should
make logs around ffmpeg decode warnings less misleading (server/index.js:797, server/
index.js:1071).
- Documented MAX_WS_BUFFER_BYTES in README, Compose, and AGENTS.
2026-05-04 00:00:34 -07:00
|
|
|
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) {
|
2026-06-11 10:06:56 -07:00
|
|
|
throw new Error(timedOut ? 'Image decode timed out.' : 'Image decode failed.');
|
2026-05-04 20:22:21 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-06-05 21:19:07 -07:00
|
|
|
function logRenderError(error) {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
if (now - state.lastRenderErrorAt < 5000) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.lastRenderErrorAt = now;
|
|
|
|
|
console.warn('Frame render loop error', error);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 18:53:35 -07:00
|
|
|
async function seekTo(value) {
|
|
|
|
|
if (!state.session || !state.seekable) {
|
|
|
|
|
state.isSeeking = false;
|
2026-06-12 19:46:45 -07:00
|
|
|
state.seekPointerId = null;
|
|
|
|
|
elements.playhead.classList.remove('scrubbing');
|
2026-05-02 18:53:35 -07:00
|
|
|
syncPlayhead();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const duration = state.duration;
|
|
|
|
|
|
|
|
|
|
if (!Number.isFinite(duration) || duration <= 0) {
|
|
|
|
|
state.isSeeking = false;
|
2026-06-12 19:46:45 -07:00
|
|
|
state.seekPointerId = null;
|
|
|
|
|
elements.playhead.classList.remove('scrubbing');
|
2026-05-02 18:53:35 -07:00
|
|
|
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;
|
2026-06-12 19:46:45 -07:00
|
|
|
state.seekPointerId = null;
|
2026-05-02 18:53:35 -07:00
|
|
|
state.frameCount = 0;
|
2026-05-04 20:22:21 -07:00
|
|
|
state.lastFramePacketAt = 0;
|
|
|
|
|
state.lastFramePaintedAt = 0;
|
2026-06-12 19:46:45 -07:00
|
|
|
elements.playhead.classList.remove('scrubbing');
|
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;
|
2026-06-12 19:46:45 -07:00
|
|
|
const canScrub = state.seekable && hasDuration;
|
2026-05-02 18:53:35 -07:00
|
|
|
const max = hasDuration ? duration : 1;
|
|
|
|
|
const value = hasDuration ? clampNumber(currentTime, 0, max) : 0;
|
|
|
|
|
const progress = hasDuration && max > 0 ? (value / max) * 100 : 0;
|
|
|
|
|
|
2026-06-12 19:46:45 -07:00
|
|
|
elements.playhead.hidden = !canScrub;
|
|
|
|
|
elements.playhead.setAttribute('aria-hidden', String(!canScrub));
|
2026-05-02 18:53:35 -07:00
|
|
|
elements.currentTime.textContent = formatTime(currentTime);
|
|
|
|
|
elements.totalTime.textContent = formatTime(duration);
|
|
|
|
|
elements.seek.max = String(max);
|
|
|
|
|
elements.seek.value = String(value);
|
2026-06-12 19:46:45 -07:00
|
|
|
elements.seek.disabled = !canScrub;
|
2026-05-02 18:53:35 -07:00
|
|
|
elements.seek.setAttribute('aria-valuemin', '0');
|
|
|
|
|
elements.seek.setAttribute('aria-valuemax', String(Math.round(max)));
|
|
|
|
|
elements.seek.setAttribute('aria-valuenow', String(Math.round(value)));
|
2026-06-12 19:46:45 -07:00
|
|
|
elements.seek.setAttribute('aria-valuetext', `${formatTime(value)} of ${formatTime(duration)}`);
|
2026-05-02 18:53:35 -07:00
|
|
|
elements.seek.style.setProperty('--progress', `${progress}%`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 19:46:45 -07:00
|
|
|
function beginSeekScrub(event) {
|
|
|
|
|
if (!canSeekScrub()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event?.preventDefault();
|
|
|
|
|
state.isSeeking = true;
|
|
|
|
|
state.seekPointerId = event?.pointerId ?? null;
|
|
|
|
|
state.seekPreviewTime = getSeekTimeFromPointer(event) ?? getSeekSliderValue();
|
|
|
|
|
elements.playhead.classList.add('scrubbing');
|
|
|
|
|
setControlsVisible(true);
|
|
|
|
|
clearHideControlsTimer();
|
|
|
|
|
|
|
|
|
|
if (event?.pointerId !== undefined && typeof elements.seek.setPointerCapture === 'function') {
|
|
|
|
|
try {
|
|
|
|
|
elements.seek.setPointerCapture(event.pointerId);
|
|
|
|
|
} catch {
|
|
|
|
|
state.seekPointerId = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
syncPlayhead();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateSeekScrubPreview(event) {
|
|
|
|
|
if (!canSeekScrub()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event?.preventDefault?.();
|
|
|
|
|
state.isSeeking = true;
|
|
|
|
|
state.seekPreviewTime = getSeekTimeFromPointer(event) ?? getSeekSliderValue();
|
|
|
|
|
elements.playhead.classList.add('scrubbing');
|
|
|
|
|
syncPlayhead();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function commitSeekScrub(event) {
|
|
|
|
|
const force = event?.force === true;
|
|
|
|
|
|
|
|
|
|
if (state.seekPointerId !== null && event?.pointerId !== undefined && state.seekPointerId !== event.pointerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!canSeekScrub()) {
|
|
|
|
|
cancelSeekScrub();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!force && !state.isSeeking) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetTime = getSeekSliderValue();
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
Number.isFinite(state.lastSeekCommitTime) &&
|
|
|
|
|
Math.abs(targetTime - state.lastSeekCommitTime) < 0.05 &&
|
|
|
|
|
now - state.lastSeekCommitAt < 1000
|
|
|
|
|
) {
|
|
|
|
|
state.isSeeking = false;
|
|
|
|
|
state.seekPointerId = null;
|
|
|
|
|
elements.playhead.classList.remove('scrubbing');
|
|
|
|
|
syncPlayhead();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.lastSeekCommitTime = targetTime;
|
|
|
|
|
state.lastSeekCommitAt = now;
|
|
|
|
|
void seekTo(targetTime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelSeekScrub() {
|
|
|
|
|
state.isSeeking = false;
|
|
|
|
|
state.seekPointerId = null;
|
|
|
|
|
elements.playhead.classList.remove('scrubbing');
|
|
|
|
|
syncPlayhead();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function canSeekScrub() {
|
|
|
|
|
return Boolean(state.session && state.seekable && Number.isFinite(state.duration) && state.duration > 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSeekSliderValue() {
|
|
|
|
|
const duration = state.duration;
|
|
|
|
|
const value = Number(elements.seek.value);
|
|
|
|
|
|
|
|
|
|
if (!Number.isFinite(duration) || duration <= 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.isSeeking && Number.isFinite(state.seekPreviewTime)) {
|
|
|
|
|
return clampNumber(state.seekPreviewTime, 0, duration);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return clampNumber(Number.isFinite(value) ? value : 0, 0, duration);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSeekTimeFromPointer(event) {
|
|
|
|
|
const duration = state.duration;
|
|
|
|
|
|
|
|
|
|
if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(duration) || duration <= 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rect = elements.seek.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
if (rect.width <= 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return clampNumber(((event.clientX - rect.left) / rect.width) * duration, 0, duration);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 18:53:35 -07:00
|
|
|
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;
|
2026-06-11 21:13:48 -07:00
|
|
|
elements.play.disabled = isBusy;
|
|
|
|
|
elements.queue.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 = '';
|
2026-06-11 21:13:48 -07:00
|
|
|
showFavoritesManager();
|
2026-05-04 20:33:14 -07:00
|
|
|
elements.favoritesModal.hidden = false;
|
|
|
|
|
elements.favoritesModal.classList.add('is-open');
|
2026-06-11 21:13:48 -07:00
|
|
|
elements.addFavorite.focus();
|
2026-05-04 20:33:14 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeFavoritesModal() {
|
|
|
|
|
elements.favoritesModal.hidden = true;
|
|
|
|
|
elements.favoritesModal.classList.remove('is-open');
|
|
|
|
|
elements.favoritesMessage.textContent = '';
|
2026-06-11 21:13:48 -07:00
|
|
|
state.favoriteWizard = createFavoriteWizardState();
|
2026-05-04 20:33:14 -07:00
|
|
|
state.favoriteModalTrigger?.focus();
|
|
|
|
|
state.favoriteModalTrigger = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
function showFavoritesManager() {
|
|
|
|
|
state.favoriteWizard = createFavoriteWizardState();
|
|
|
|
|
elements.favoritesMessage.textContent = '';
|
|
|
|
|
elements.favoritesManager.hidden = false;
|
|
|
|
|
elements.favoriteWizard.hidden = true;
|
|
|
|
|
elements.favoriteWizardBack.hidden = true;
|
|
|
|
|
elements.saveFavorites.hidden = true;
|
|
|
|
|
elements.cancelFavorites.textContent = 'Done';
|
|
|
|
|
renderFavoriteManager();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderFavoriteManager() {
|
2026-05-04 20:33:14 -07:00
|
|
|
elements.favoritesEditorList.replaceChildren(
|
2026-06-11 21:13:48 -07:00
|
|
|
...state.favorites.map((item, index) => createFavoriteManagerRow(item, index)),
|
2026-05-04 20:33:14 -07:00
|
|
|
);
|
2026-06-11 21:13:48 -07:00
|
|
|
|
|
|
|
|
if (state.favorites.length === 0) {
|
|
|
|
|
elements.favoritesEditorList.replaceChildren(createEmptyListMessage('No favorites'));
|
|
|
|
|
}
|
2026-05-04 20:33:14 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
function createFavoriteManagerRow(item, index) {
|
2026-05-04 20:33:14 -07:00
|
|
|
const row = document.createElement('div');
|
2026-06-11 21:13:48 -07:00
|
|
|
row.className = 'favorite-manager-row';
|
|
|
|
|
|
|
|
|
|
const summary = document.createElement('div');
|
|
|
|
|
summary.className = 'favorite-summary';
|
|
|
|
|
|
|
|
|
|
const title = document.createElement('div');
|
|
|
|
|
title.className = 'favorite-title';
|
|
|
|
|
title.textContent = item.title;
|
|
|
|
|
|
|
|
|
|
const url = document.createElement('div');
|
|
|
|
|
url.className = 'favorite-url';
|
|
|
|
|
url.textContent = item.url;
|
|
|
|
|
|
|
|
|
|
const edit = document.createElement('button');
|
|
|
|
|
edit.type = 'button';
|
|
|
|
|
edit.className = 'secondary-button favorite-action';
|
|
|
|
|
edit.dataset.editFavorite = String(index);
|
2026-06-12 19:49:16 -07:00
|
|
|
edit.setAttribute('aria-label', `Edit ${item.title}`);
|
|
|
|
|
edit.title = 'Edit';
|
|
|
|
|
edit.append(createFavoriteActionIcon('edit'));
|
2026-05-04 20:33:14 -07:00
|
|
|
|
|
|
|
|
const remove = document.createElement('button');
|
|
|
|
|
remove.type = 'button';
|
2026-06-11 21:13:48 -07:00
|
|
|
remove.className = 'remove-favorite favorite-action';
|
|
|
|
|
remove.dataset.removeFavorite = String(index);
|
2026-06-12 19:49:16 -07:00
|
|
|
remove.setAttribute('aria-label', `Remove ${item.title}`);
|
|
|
|
|
remove.title = 'Remove';
|
|
|
|
|
remove.append(createFavoriteActionIcon('remove'));
|
2026-05-04 20:33:14 -07:00
|
|
|
|
2026-06-12 08:45:34 -07:00
|
|
|
const actions = document.createElement('div');
|
|
|
|
|
actions.className = 'favorite-actions';
|
|
|
|
|
actions.append(edit, remove);
|
|
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
summary.append(title, url);
|
2026-06-12 08:45:34 -07:00
|
|
|
row.append(summary, actions);
|
2026-05-04 20:33:14 -07:00
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 19:49:16 -07:00
|
|
|
function createFavoriteActionIcon(kind) {
|
|
|
|
|
const namespace = 'http://www.w3.org/2000/svg';
|
|
|
|
|
const svg = document.createElementNS(namespace, 'svg');
|
|
|
|
|
const paths = kind === 'edit'
|
|
|
|
|
? ['M12 20h9', 'M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z']
|
|
|
|
|
: ['M3 6h18', 'M8 6V4h8v2', 'M6 6l1 15h10l1-15', 'M10 11v6', 'M14 11v6'];
|
|
|
|
|
|
|
|
|
|
svg.setAttribute('viewBox', '0 0 24 24');
|
|
|
|
|
svg.setAttribute('aria-hidden', 'true');
|
|
|
|
|
svg.setAttribute('focusable', 'false');
|
|
|
|
|
|
|
|
|
|
for (const pathData of paths) {
|
|
|
|
|
const path = document.createElementNS(namespace, 'path');
|
|
|
|
|
path.setAttribute('d', pathData);
|
|
|
|
|
svg.append(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return svg;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
function startFavoriteWizard(mode, index = -1) {
|
|
|
|
|
const item = mode === 'edit' ? state.favorites[index] : null;
|
|
|
|
|
|
|
|
|
|
if (mode === 'edit' && !item) {
|
|
|
|
|
return;
|
2026-05-04 20:33:14 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
state.favoriteWizard = {
|
|
|
|
|
active: true,
|
|
|
|
|
mode,
|
|
|
|
|
index,
|
|
|
|
|
step: 'title',
|
|
|
|
|
title: item?.title ?? '',
|
|
|
|
|
url: item?.url ?? elements.url.value.trim(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
elements.favoritesMessage.textContent = '';
|
|
|
|
|
elements.favoritesManager.hidden = true;
|
|
|
|
|
elements.favoriteWizard.hidden = false;
|
|
|
|
|
elements.favoriteWizardBack.hidden = false;
|
|
|
|
|
elements.saveFavorites.hidden = false;
|
|
|
|
|
elements.cancelFavorites.textContent = 'Cancel';
|
|
|
|
|
syncFavoriteWizard();
|
2026-05-04 20:33:14 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
function syncFavoriteWizard() {
|
|
|
|
|
const wizard = state.favoriteWizard;
|
|
|
|
|
const isUrlStep = wizard.step === 'url';
|
|
|
|
|
|
|
|
|
|
elements.favoriteWizardStep.textContent = isUrlStep ? 'URL' : 'Title';
|
|
|
|
|
elements.favoriteTitleField.hidden = isUrlStep;
|
|
|
|
|
elements.favoriteUrlField.hidden = !isUrlStep;
|
|
|
|
|
elements.favoriteTitle.value = wizard.title;
|
|
|
|
|
elements.favoriteUrl.value = wizard.url;
|
|
|
|
|
elements.saveFavorites.textContent = isUrlStep ? 'Save' : 'Next';
|
|
|
|
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
if (isUrlStep) {
|
|
|
|
|
elements.favoriteUrl.focus();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
elements.favoriteTitle.focus();
|
|
|
|
|
}, 0);
|
2026-05-04 20:33:14 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 21:13:48 -07:00
|
|
|
async function advanceFavoriteWizard() {
|
|
|
|
|
if (!state.favoriteWizard.active) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 20:33:14 -07:00
|
|
|
elements.favoritesMessage.textContent = '';
|
2026-06-11 21:13:48 -07:00
|
|
|
const wizard = state.favoriteWizard;
|
|
|
|
|
|
|
|
|
|
if (wizard.step === 'title') {
|
|
|
|
|
const title = elements.favoriteTitle.value.trim();
|
|
|
|
|
|
|
|
|
|
if (!title) {
|
|
|
|
|
elements.favoritesMessage.textContent = 'Favorite needs a title.';
|
|
|
|
|
elements.favoriteTitle.focus();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wizard.title = title;
|
|
|
|
|
wizard.step = 'url';
|
|
|
|
|
syncFavoriteWizard();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = elements.favoriteUrl.value.trim();
|
|
|
|
|
|
|
|
|
|
if (!url) {
|
|
|
|
|
elements.favoritesMessage.textContent = 'Favorite needs a URL.';
|
|
|
|
|
elements.favoriteUrl.focus();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wizard.url = url;
|
|
|
|
|
|
|
|
|
|
const nextFavorites = wizard.mode === 'edit'
|
|
|
|
|
? state.favorites.map((item, index) => (index === wizard.index ? { title: wizard.title, url: wizard.url } : item))
|
|
|
|
|
: [...state.favorites, { title: wizard.title, url: wizard.url }];
|
|
|
|
|
|
|
|
|
|
await saveFavoritesList(nextFavorites, {
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
showFavoritesManager();
|
|
|
|
|
elements.addFavorite.focus();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function removeFavorite(index) {
|
|
|
|
|
if (!Number.isInteger(index) || index < 0 || index >= state.favorites.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextFavorites = state.favorites.filter((_item, itemIndex) => itemIndex !== index);
|
|
|
|
|
await saveFavoritesList(nextFavorites);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveFavoritesList(favorites, { onSuccess = () => {} } = {}) {
|
2026-05-04 20:33:14 -07:00
|
|
|
setFavoritesModalBusy(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
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();
|
2026-06-11 21:13:48 -07:00
|
|
|
renderFavoriteManager();
|
|
|
|
|
onSuccess();
|
2026-05-04 20:33:14 -07:00
|
|
|
} catch (error) {
|
|
|
|
|
elements.favoritesMessage.textContent = error.message;
|
|
|
|
|
} finally {
|
|
|
|
|
setFavoritesModalBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setFavoritesModalBusy(isBusy) {
|
|
|
|
|
for (const field of elements.favoritesForm.querySelectorAll('input, button')) {
|
|
|
|
|
field.disabled = isBusy;
|
|
|
|
|
}
|
2026-05-01 21:51:25 -07:00
|
|
|
}
|