Compare commits

...

4 Commits

Author SHA1 Message Date
8b74d25954 Enable recorded stream seeking 2026-05-26 19:11:19 -07:00
5e2a2e1de7 adds favorites 2026-05-04 20:33:14 -07:00
47dae48673 frame watchdog in case it gets stuck 2026-05-04 20:22:21 -07:00
81d9cfc1c2 better frame decoding on server 2026-05-04 17:10:59 -07:00
7 changed files with 973 additions and 159 deletions

View File

@@ -6,18 +6,18 @@ This project is a web video player for clients that can decode audio and still i
The UI intentionally has only two screens:
- URL entry screen with a stream URL input, a `Next` button, and globally stored recently played URLs.
- URL entry screen with a stream URL input, a `Next` button, globally stored recently played URLs, and globally stored favorites.
- Fullscreen player screen with JPEG frames drawn to a canvas and native audio playback through an `<audio>` element.
- Playback controls are overlay controls toggled by tapping/clicking the frame area, similar to YouTube.
- Do not reintroduce debug panels, frame counters, settings forms, explanatory marketing copy, or visible ffmpeg details into the normal UI.
The backend stores recently played URLs globally, not per-browser. The default path is `data/recent-urls.json`, configurable with `RECENT_URLS_PATH`. Docker Compose persists this through the `frame-stream-data` volume.
The backend stores recently played URLs and favorites globally, not per-browser. The default recent URL path is `data/recent-urls.json`, configurable with `RECENT_URLS_PATH`. The default favorites path is `data/favorites.json`, configurable with `FAVORITES_PATH`. Docker Compose persists both through the `frame-stream-data` volume.
## Core Architecture
The app is plain Node/Express plus browser JavaScript:
- `server/index.js`: API, WebSocket, source proxy/relay, ffmpeg process lifecycle, recent URL persistence.
- `server/index.js`: API, WebSocket, source proxy/relay, ffmpeg process lifecycle, recent URL and favorites persistence.
- `public/index.html`: frontend markup.
- `public/app.js`: URL submission, WebSocket frame receiving, audio element coordination, canvas drawing, overlay controls.
- `public/styles.css`: two-screen player UI.
@@ -28,6 +28,8 @@ Main public endpoints:
- `POST /api/session`: validates the stream URL, stores recent URL, creates a short-lived playback session.
- `GET /api/recent-urls`: returns global recent URL entries with `url`, redacted `displayUrl`, and `lastPlayedAt`.
- `GET /api/favorites`: returns global favorite entries with `title` and `url`.
- `PUT /api/favorites`: replaces the global favorites list. Each favorite has a user-provided `title` and stream `url`.
- `GET /audio/:sessionId`: serves MP3 audio to the browser audio element.
- `WS /frames/:sessionId`: sends timed JPEG frame packets to the browser.
- `GET /api/health`: exposes basic health and active playback connection mode.
@@ -73,6 +75,8 @@ The regression history matters:
Default code behavior is `split` when no mode is set. The Compose example uses `relay` because it is the mode to try for IPTV streams.
Finite-duration sessions are treated as recorded video and seekable. When the configured mode is `relay`, recorded sessions switch to the seek-capable `split` path once duration metadata is known; live/unknown-duration streams stay in relay mode.
## ffmpeg Pipelines
All ffmpeg command builders live near the bottom of `server/index.js`.
@@ -170,6 +174,8 @@ Runtime:
- `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`.
- `RECENT_URLS_PATH`: recent URL JSON path.
- `RECENT_URL_LIMIT`: recent URL count, default `12`.
- `FAVORITES_PATH`: favorites JSON path.
- `FAVORITES_LIMIT`: favorites count, default `50`.
- `MAX_WS_BUFFER_BYTES`: server-side WebSocket JPEG frame backlog cap, default `2097152`.
- `MAX_AUDIO_QUEUE_BYTES`: single-mode audio output queue cap, default `16777216`.
- `MAX_RELAY_BRANCH_QUEUE_BYTES`: relay per-branch compressed-input queue cap, default `16777216`.
@@ -218,6 +224,7 @@ Start a mode locally:
```sh
PORT=3014 RECENT_URLS_PATH=/tmp/carplay-relay-recent.json \
FAVORITES_PATH=/tmp/carplay-relay-favorites.json \
FFMPEG_LOG_LEVEL=warning FFMPEG_INPUT_SEEKABLE=0 \
PLAYBACK_CONNECTION_MODE=relay npm start
```
@@ -225,7 +232,7 @@ PORT=3014 RECENT_URLS_PATH=/tmp/carplay-relay-recent.json \
After smoke testing, remove generated assets:
```sh
rm -f public/_relay-smoke.ts /tmp/carplay-*-recent.json
rm -f public/_relay-smoke.ts /tmp/carplay-*-recent.json /tmp/carplay-*-favorites.json
```
## Security Notes

View File

@@ -39,7 +39,7 @@ docker compose -f docker-compose-example.yml up --build
The app uses CPU decoding by default, so no video device is required. The compose example includes commented VAAPI/NVIDIA passthrough options for future hardware-accelerated `ffmpeg` setups, but hardware acceleration is usually only useful when server CPU is saturated.
Recently played URLs are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume.
Recently played URLs and favorites are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume.
`ffmpeg` worker lifecycle, stderr warnings/errors, and source proxy open/close events are written to stdout/stderr, so they appear in `docker logs`. For more detail while debugging a stream, set `FFMPEG_LOG_LEVEL=info` in Docker Compose and run:
@@ -61,6 +61,8 @@ Available playback modes:
- `relay`: One source connection from the backend, then the compressed input bytes are teed into separate audio and frame `ffmpeg` workers. This is intended for IPTV hosts that stop early or reject multiple active connections.
- `single`: One source connection and one `ffmpeg` worker with both audio and frame outputs. This is the simplest one-connection fallback, but audio and frame delivery can affect each other.
Finite-duration streams are treated as recorded video and become seekable once metadata is available. If the app is configured for `relay`, recorded streams switch to the seek-capable `split` path; live or unknown-duration streams stay in relay mode.
Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `16777216`.
## Tuning

View File

@@ -19,6 +19,7 @@ services:
MAX_AUDIO_QUEUE_BYTES: "16777216"
MAX_RELAY_BRANCH_QUEUE_BYTES: "16777216"
RECENT_URLS_PATH: /app/data/recent-urls.json
FAVORITES_PATH: /app/data/favorites.json
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:

View File

@@ -5,8 +5,20 @@ const elements = {
url: document.querySelector('#stream-url'),
next: document.querySelector('#next'),
entryMessage: document.querySelector('#entry-message'),
libraryPanel: document.querySelector('#library-panel'),
recentPanel: document.querySelector('#recent-panel'),
recentList: document.querySelector('#recent-list'),
favoritesPanel: document.querySelector('#favorites-panel'),
favoritesList: document.querySelector('#favorites-list'),
editFavorites: document.querySelector('#edit-favorites'),
favoritesModal: document.querySelector('#favorites-modal'),
favoritesForm: document.querySelector('#favorites-form'),
closeFavorites: document.querySelector('#close-favorites'),
cancelFavorites: document.querySelector('#cancel-favorites'),
addFavorite: document.querySelector('#add-favorite'),
saveFavorites: document.querySelector('#save-favorites'),
favoritesEditorList: document.querySelector('#favorites-editor-list'),
favoritesMessage: document.querySelector('#favorites-message'),
audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'),
stage: document.querySelector('#video-stage'),
@@ -28,9 +40,18 @@ const MAX_PENDING_FRAME_QUEUE_SECONDS = 2;
const MAX_DECODED_FRAME_QUEUE_SECONDS = 3;
const MIN_PENDING_FRAME_QUEUE = 12;
const MIN_DECODED_FRAME_QUEUE = 24;
const IMAGE_DECODE_TIMEOUT_MS = 3000;
const FRAME_STALL_CHECK_MS = 1000;
const FRAME_STARTUP_STALL_RESET_MS = 10000;
const FRAME_STALL_RESET_MS = 6000;
const PLAYBACK_RESTART_COOLDOWN_MS = 8000;
const PLAYBACK_RESTART_DELAY_MS = 750;
const METADATA_REFRESH_ATTEMPTS = 20;
const METADATA_REFRESH_INTERVAL_MS = 650;
const state = {
generation: 0,
streamGeneration: 0,
session: null,
websocket: null,
pendingFrames: [],
@@ -42,14 +63,20 @@ const state = {
controlsVisible: true,
hideControlsTimer: 0,
recentUrls: [],
favorites: [],
favoriteModalTrigger: null,
playbackOffset: 0,
duration: null,
seekable: false,
isSeeking: false,
seekPreviewTime: 0,
frameWatchdogTimer: 0,
lastFramePacketAt: 0,
lastFramePaintedAt: 0,
lastPlaybackRestartAt: 0,
};
void loadRecentUrls();
void loadEntryLists();
elements.form.addEventListener('submit', async (event) => {
event.preventDefault();
@@ -99,6 +126,68 @@ elements.recentList.addEventListener('click', (event) => {
elements.form.requestSubmit();
});
elements.favoritesList.addEventListener('click', (event) => {
const button = event.target.closest('[data-favorite-index]');
if (!button) {
return;
}
const item = state.favorites[Number(button.dataset.favoriteIndex)];
if (!item) {
return;
}
elements.url.value = item.url;
elements.form.requestSubmit();
});
elements.editFavorites.addEventListener('click', () => {
openFavoritesModal();
});
elements.closeFavorites.addEventListener('click', () => {
closeFavoritesModal();
});
elements.cancelFavorites.addEventListener('click', () => {
closeFavoritesModal();
});
elements.addFavorite.addEventListener('click', () => {
elements.favoritesEditorList.append(createFavoriteEditorRow());
focusLastFavoriteTitle();
});
elements.favoritesEditorList.addEventListener('click', (event) => {
const button = event.target.closest('[data-remove-favorite]');
if (!button) {
return;
}
button.closest('.favorite-editor-row')?.remove();
ensureFavoriteEditorRows();
});
elements.favoritesForm.addEventListener('submit', (event) => {
event.preventDefault();
void saveFavoritesFromModal();
});
elements.favoritesModal.addEventListener('click', (event) => {
if (event.target === elements.favoritesModal) {
closeFavoritesModal();
}
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !elements.favoritesModal.hidden) {
closeFavoritesModal();
}
});
elements.stage.addEventListener('pointerup', (event) => {
if (!state.session || event.target.closest('.controls')) {
return;
@@ -156,6 +245,7 @@ elements.seek.addEventListener('change', () => {
elements.audio.addEventListener('play', () => {
startRenderLoop();
startFrameWatchdog();
syncControlLabels();
scheduleControlsHide();
syncPlayhead();
@@ -195,6 +285,12 @@ elements.audio.addEventListener('error', () => {
}
});
window.addEventListener('online', () => {
if (state.session && !elements.audio.paused && isFrameStreamStale(FRAME_STALL_RESET_MS)) {
restartPlaybackStreams();
}
});
function startSession(session) {
state.session = session;
state.playbackOffset = Number(session.seekSeconds) || 0;
@@ -203,6 +299,8 @@ function startSession(session) {
state.isSeeking = false;
state.seekPreviewTime = state.playbackOffset;
state.frameCount = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
clearFrameQueue();
syncControlLabels();
syncPlayhead();
@@ -211,6 +309,7 @@ function startSession(session) {
clearPlayerMessage();
connectPlaybackStreams();
startFrameWatchdog();
void refreshSessionMetadata(session.id);
}
@@ -219,8 +318,12 @@ function connectPlaybackStreams() {
return;
}
const generation = state.generation;
const streamGeneration = state.streamGeneration + 1;
const session = state.session;
const now = Date.now();
state.streamGeneration = streamGeneration;
state.lastPlaybackRestartAt = now;
state.lastFramePacketAt = now;
if (state.websocket) {
state.websocket.close(1000, 'client reconnecting');
@@ -234,7 +337,7 @@ function connectPlaybackStreams() {
state.websocket = websocket;
websocket.addEventListener('message', (event) => {
if (generation !== state.generation) {
if (streamGeneration !== state.streamGeneration) {
return;
}
@@ -243,18 +346,18 @@ function connectPlaybackStreams() {
return;
}
void handleFramePacket(event.data, generation);
handleFramePacket(event.data, streamGeneration);
});
websocket.addEventListener('close', () => {
if (generation === state.generation && state.session?.id === session.id) {
if (streamGeneration === state.streamGeneration && state.session?.id === session.id) {
showPlayerMessage('Stream ended');
setControlsVisible(true);
}
});
websocket.addEventListener('error', () => {
if (generation === state.generation && state.session?.id === session.id) {
if (streamGeneration === state.streamGeneration && state.session?.id === session.id) {
showPlayerMessage('Stream failed');
setControlsVisible(true);
}
@@ -277,18 +380,94 @@ async function playAudio() {
}
}
function handleFramePacket(packet, generation) {
function startFrameWatchdog() {
if (state.frameWatchdogTimer) {
return;
}
state.frameWatchdogTimer = window.setInterval(checkFrameWatchdog, FRAME_STALL_CHECK_MS);
}
function clearFrameWatchdog() {
if (!state.frameWatchdogTimer) {
return;
}
window.clearInterval(state.frameWatchdogTimer);
state.frameWatchdogTimer = 0;
}
function checkFrameWatchdog() {
if (!state.session || state.isSeeking || document.hidden || elements.audio.paused || elements.audio.ended || elements.audio.readyState === 0) {
return;
}
const resetAfterMs = state.lastFramePaintedAt > 0 ? FRAME_STALL_RESET_MS : FRAME_STARTUP_STALL_RESET_MS;
if (!isFrameStreamStale(resetAfterMs)) {
return;
}
restartPlaybackStreams();
}
function isFrameStreamStale(resetAfterMs) {
const now = Date.now();
const lastFrameAt = state.lastFramePacketAt || state.lastPlaybackRestartAt || now;
return now - lastFrameAt >= resetAfterMs && now - state.lastPlaybackRestartAt >= PLAYBACK_RESTART_COOLDOWN_MS;
}
function restartPlaybackStreams() {
if (!state.session) {
return;
}
if (state.seekable && Number.isFinite(state.duration) && state.duration > 0) {
state.lastPlaybackRestartAt = Date.now();
void seekTo(getVisiblePlaybackTime());
return;
}
const streamGeneration = state.streamGeneration + 1;
const sessionId = state.session.id;
const now = Date.now();
state.streamGeneration = streamGeneration;
state.lastPlaybackRestartAt = now;
state.lastFramePacketAt = now;
elements.loader.hidden = false;
clearPlayerMessage();
clearFrameQueue({ keepCurrent: true });
if (state.websocket) {
state.websocket.close(1000, 'frame stream stalled');
state.websocket = null;
}
elements.audio.pause();
elements.audio.removeAttribute('src');
elements.audio.load();
window.setTimeout(() => {
if (state.session?.id === sessionId && state.streamGeneration === streamGeneration) {
connectPlaybackStreams();
}
}, PLAYBACK_RESTART_DELAY_MS);
}
function handleFramePacket(packet, streamGeneration) {
if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
return;
}
const timestamp = new DataView(packet, 0, 8).getFloat64(0, true);
if (generation !== state.generation || isLateFrame(timestamp)) {
if (streamGeneration !== state.streamGeneration || isLateFrame(timestamp)) {
return;
}
state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), generation });
state.lastFramePacketAt = Date.now();
state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), streamGeneration });
trimPendingFrameQueue();
void pumpFrameDecodeQueue();
}
@@ -362,6 +541,7 @@ function drawReadyFrames() {
state.currentBitmap = frameToDraw.bitmap;
drawBitmap(frameToDraw.bitmap);
state.lastFramePaintedAt = Date.now();
elements.loader.hidden = true;
clearPlayerMessage();
}
@@ -395,19 +575,19 @@ async function pumpFrameDecodeQueue() {
return;
}
if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) {
if (frame.streamGeneration !== state.streamGeneration || isLateFrame(frame.timestamp)) {
continue;
}
let bitmap;
try {
bitmap = await decodeImage(new Blob([frame.jpeg], { type: 'image/jpeg' }));
bitmap = await decodeImageWithTimeout(new Blob([frame.jpeg], { type: 'image/jpeg' }));
} catch {
continue;
}
if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) {
if (frame.streamGeneration !== state.streamGeneration || isLateFrame(frame.timestamp)) {
releaseImage(bitmap);
continue;
}
@@ -501,6 +681,7 @@ function isLateFrame(timestamp) {
function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.generation += 1;
state.streamGeneration += 1;
state.session = null;
state.frameCount = 0;
state.playbackOffset = 0;
@@ -508,7 +689,10 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.seekable = false;
state.isSeeking = false;
state.seekPreviewTime = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
clearHideControlsTimer();
clearFrameWatchdog();
if (state.websocket) {
state.websocket.close(1000, 'client stopped');
@@ -537,7 +721,7 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
}
}
function clearFrameQueue() {
function clearFrameQueue({ keepCurrent = false } = {}) {
state.pendingFrames = [];
for (const frame of state.frames) {
@@ -546,12 +730,45 @@ function clearFrameQueue() {
state.frames = [];
if (state.currentBitmap) {
if (!keepCurrent && state.currentBitmap) {
releaseImage(state.currentBitmap);
state.currentBitmap = null;
}
}
async function decodeImageWithTimeout(blob) {
let timedOut = false;
let timeoutId = 0;
const decodePromise = decodeImage(blob).then(
(image) => {
if (timedOut) {
releaseImage(image);
return null;
}
return image;
},
() => null,
);
const timeoutPromise = new Promise((resolve) => {
timeoutId = window.setTimeout(() => {
timedOut = true;
resolve(null);
}, IMAGE_DECODE_TIMEOUT_MS);
});
const image = await Promise.race([decodePromise, timeoutPromise]);
window.clearTimeout(timeoutId);
if (!image) {
throw new Error('Image decode failed.');
}
return image;
}
async function decodeImage(blob) {
if ('createImageBitmap' in window) {
return createImageBitmap(blob);
@@ -596,10 +813,13 @@ async function seekTo(value) {
const sessionId = state.session.id;
state.generation = generation;
state.streamGeneration += 1;
state.playbackOffset = targetTime;
state.seekPreviewTime = targetTime;
state.isSeeking = false;
state.frameCount = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
elements.loader.hidden = false;
clearPlayerMessage();
clearFrameQueue();
@@ -648,7 +868,7 @@ async function seekTo(value) {
async function refreshSessionMetadata(sessionId) {
const generation = state.generation;
for (let attempt = 0; attempt < 10; attempt += 1) {
for (let attempt = 0; attempt < METADATA_REFRESH_ATTEMPTS; attempt += 1) {
if (generation !== state.generation || state.session?.id !== sessionId) {
return;
}
@@ -676,7 +896,7 @@ async function refreshSessionMetadata(sessionId) {
return;
}
await delay(650);
await delay(METADATA_REFRESH_INTERVAL_MS);
}
}
@@ -797,7 +1017,7 @@ function syncControlLabels() {
function showEntry() {
elements.playerScreen.hidden = true;
elements.entryScreen.hidden = false;
void loadRecentUrls();
void loadEntryLists();
elements.url.focus();
}
@@ -822,11 +1042,18 @@ function setFormBusy(isBusy) {
elements.url.disabled = isBusy;
elements.next.disabled = isBusy;
for (const button of elements.recentList.querySelectorAll('button')) {
for (const button of elements.libraryPanel.querySelectorAll('button')) {
button.disabled = isBusy;
}
}
async function loadEntryLists() {
await Promise.all([
loadRecentUrls(),
loadFavorites(),
]);
}
async function loadRecentUrls() {
try {
const response = await fetch('/api/recent-urls', { cache: 'no-store' });
@@ -849,12 +1076,170 @@ function renderRecentUrls() {
...state.recentUrls.map((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'recent-url';
button.className = 'url-item';
button.dataset.recentIndex = String(index);
button.textContent = item.displayUrl || item.url;
return button;
}),
);
elements.recentPanel.hidden = state.recentUrls.length === 0;
if (state.recentUrls.length === 0) {
elements.recentList.replaceChildren(createEmptyListMessage('No recents'));
}
}
async function loadFavorites() {
try {
const response = await fetch('/api/favorites', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to load favorites.');
}
const payload = await response.json();
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites();
} catch {
state.favorites = [];
renderFavorites();
}
}
function renderFavorites() {
elements.favoritesList.replaceChildren(
...state.favorites.map((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'url-item';
button.dataset.favoriteIndex = String(index);
button.textContent = item.title;
return button;
}),
);
if (state.favorites.length === 0) {
elements.favoritesList.replaceChildren(createEmptyListMessage('No favorites'));
}
}
function createEmptyListMessage(text) {
const message = document.createElement('p');
message.className = 'empty-list';
message.textContent = text;
return message;
}
function openFavoritesModal() {
state.favoriteModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
elements.favoritesMessage.textContent = '';
renderFavoriteEditorRows(state.favorites);
elements.favoritesModal.hidden = false;
elements.favoritesModal.classList.add('is-open');
focusFirstFavoriteField();
}
function closeFavoritesModal() {
elements.favoritesModal.hidden = true;
elements.favoritesModal.classList.remove('is-open');
elements.favoritesMessage.textContent = '';
state.favoriteModalTrigger?.focus();
state.favoriteModalTrigger = null;
}
function renderFavoriteEditorRows(items) {
const rows = items.length > 0 ? items : [{ title: '', url: '' }];
elements.favoritesEditorList.replaceChildren(
...rows.map((item) => createFavoriteEditorRow(item)),
);
}
function createFavoriteEditorRow(item = { title: '', url: '' }) {
const row = document.createElement('div');
row.className = 'favorite-editor-row';
const title = document.createElement('input');
title.type = 'text';
title.placeholder = 'Title';
title.setAttribute('aria-label', 'Favorite title');
title.autocomplete = 'off';
title.maxLength = 120;
title.value = item.title ?? '';
title.dataset.favoriteTitle = '';
const url = document.createElement('input');
url.type = 'url';
url.placeholder = 'URL';
url.setAttribute('aria-label', 'Favorite URL');
url.autocomplete = 'off';
url.value = item.url ?? '';
url.dataset.favoriteUrl = '';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'remove-favorite';
remove.dataset.removeFavorite = '';
remove.setAttribute('aria-label', 'Remove favorite');
remove.textContent = 'Remove';
row.append(title, url, remove);
return row;
}
function ensureFavoriteEditorRows() {
if (elements.favoritesEditorList.children.length === 0) {
elements.favoritesEditorList.append(createFavoriteEditorRow());
}
}
function focusFirstFavoriteField() {
const field = elements.favoritesEditorList.querySelector('[data-favorite-title], [data-favorite-url]');
field?.focus();
}
function focusLastFavoriteTitle() {
const rows = elements.favoritesEditorList.querySelectorAll('.favorite-editor-row');
const row = rows[rows.length - 1];
row?.querySelector('[data-favorite-title]')?.focus();
}
async function saveFavoritesFromModal() {
elements.favoritesMessage.textContent = '';
setFavoritesModalBusy(true);
try {
const favorites = readFavoriteEditorRows();
const response = await fetch('/api/favorites', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ favorites }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to save favorites.');
}
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites();
closeFavoritesModal();
} catch (error) {
elements.favoritesMessage.textContent = error.message;
} finally {
setFavoritesModalBusy(false);
}
}
function readFavoriteEditorRows() {
return [...elements.favoritesEditorList.querySelectorAll('.favorite-editor-row')]
.map((row) => ({
title: row.querySelector('[data-favorite-title]')?.value.trim() ?? '',
url: row.querySelector('[data-favorite-url]')?.value.trim() ?? '',
}))
.filter((item) => item.title || item.url);
}
function setFavoritesModalBusy(isBusy) {
for (const field of elements.favoritesForm.querySelectorAll('input, button')) {
field.disabled = isBusy;
}
}

View File

@@ -22,8 +22,20 @@
>
<button id="next" type="submit">Next</button>
</form>
<div id="recent-panel" class="recent-panel" aria-label="Recently played URLs" hidden>
<div id="recent-list" class="recent-list"></div>
<div id="library-panel" class="library-panel" aria-label="Saved streams">
<section id="recent-panel" class="url-column" aria-labelledby="recent-heading">
<div class="column-heading">
<h2 id="recent-heading">Recents</h2>
</div>
<div id="recent-list" class="url-list"></div>
</section>
<section id="favorites-panel" class="url-column" aria-labelledby="favorites-heading">
<div class="column-heading">
<h2 id="favorites-heading">Favorites</h2>
<button id="edit-favorites" type="button" class="small-button">Edit</button>
</div>
<div id="favorites-list" class="url-list"></div>
</section>
</div>
</div>
<div id="entry-message" class="message" role="status" aria-live="polite"></div>
@@ -48,6 +60,22 @@
</div>
<audio id="audio" preload="none"></audio>
</section>
<div id="favorites-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="favorites-modal-title" hidden>
<form id="favorites-form" class="modal-panel">
<div class="modal-heading">
<h2 id="favorites-modal-title">Edit Favorites</h2>
<button id="close-favorites" type="button" class="icon-button" aria-label="Close favorites editor">&times;</button>
</div>
<div id="favorites-editor-list" class="favorites-editor-list"></div>
<button id="add-favorite" type="button" class="secondary-button">Add Favorite</button>
<div id="favorites-message" class="modal-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button id="cancel-favorites" type="button" class="secondary-button">Cancel</button>
<button id="save-favorites" type="submit" class="primary-button">Save</button>
</div>
</form>
</div>
</main>
<script src="/app.js" type="module"></script>

View File

@@ -64,7 +64,7 @@ button:disabled {
.entry-stack {
display: grid;
width: min(760px, 100%);
width: min(980px, 100%);
gap: 0.75rem;
}
@@ -96,7 +96,12 @@ button:disabled {
}
.url-form button,
.control-button {
.control-button,
.small-button,
.secondary-button,
.primary-button,
.icon-button,
.remove-favorite {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--fg);
@@ -111,28 +116,61 @@ button:disabled {
color: #070707;
}
.recent-panel {
.library-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.url-column {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.07);
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.recent-list {
.column-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 3.2rem;
padding: 0.55rem 0.75rem 0.2rem 1rem;
}
.column-heading h2,
.modal-heading h2 {
margin: 0;
font-size: 0.86rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
color: var(--soft);
}
.small-button {
flex: 0 0 auto;
min-width: 4.2rem;
padding: 0.45rem 0.8rem;
font-size: 0.84rem;
font-weight: 800;
}
.url-list {
display: grid;
max-height: min(42vh, 24rem);
overflow: auto;
padding: 0.35rem;
}
.recent-url {
.url-item {
display: block;
width: 100%;
overflow: hidden;
border: 0;
border-radius: 20px;
border-radius: 16px;
padding: 0.85rem 1rem;
color: var(--soft);
text-align: left;
@@ -141,13 +179,19 @@ button:disabled {
background: transparent;
}
.recent-url:hover,
.recent-url:focus {
.url-item:hover,
.url-item:focus {
color: var(--fg);
background: rgba(255, 255, 255, 0.09);
outline: none;
}
.empty-list {
margin: 0;
padding: 0.85rem 1rem;
color: rgba(246, 241, 232, 0.42);
}
.message {
position: fixed;
bottom: 2rem;
@@ -242,12 +286,13 @@ canvas {
top: -1.75rem;
right: 1.1rem;
left: 1.1rem;
height: 2.2rem;
height: 2.45rem;
}
.time-badge {
position: absolute;
top: 0;
pointer-events: none;
min-width: 3.6rem;
padding: 0.22rem 0.45rem;
border: 1px solid var(--line);
@@ -274,10 +319,10 @@ canvas {
.seek-slider {
position: absolute;
right: 0;
bottom: 0.18rem;
bottom: 0;
left: 0;
width: 100%;
height: 1rem;
height: 1.35rem;
margin: 0;
appearance: none;
background: transparent;
@@ -365,6 +410,107 @@ audio {
display: none;
}
.modal {
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(12px);
}
.modal-panel {
display: grid;
width: min(860px, 100%);
max-height: min(760px, calc(100vh - 2rem));
overflow: auto;
gap: 0.85rem;
margin: 0;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--glass-strong);
box-shadow: 0 28px 120px rgba(0, 0, 0, 0.55);
}
.modal-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.icon-button {
display: grid;
width: 2.3rem;
height: 2.3rem;
place-items: center;
padding: 0;
font-size: 1.2rem;
font-weight: 800;
line-height: 1;
}
.favorites-editor-list {
display: grid;
gap: 0.55rem;
}
.favorite-editor-row {
display: grid;
grid-template-columns: minmax(8rem, 0.8fr) minmax(12rem, 1.6fr) auto;
gap: 0.55rem;
}
.favorite-editor-row input {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.72rem 0.8rem;
color: var(--fg);
background: rgba(255, 255, 255, 0.08);
outline: none;
}
.favorite-editor-row input::placeholder {
color: rgba(246, 241, 232, 0.42);
}
.favorite-editor-row input:focus {
border-color: rgba(232, 168, 79, 0.7);
}
.secondary-button,
.primary-button,
.remove-favorite {
min-height: 2.7rem;
padding: 0.65rem 0.95rem;
font-weight: 800;
}
.primary-button {
border-color: transparent;
color: #070707;
background: var(--fg);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
}
.modal-message {
min-height: 1.2rem;
color: var(--soft);
}
.modal-message:empty {
display: none;
}
[hidden] {
display: none !important;
}
@@ -386,6 +532,29 @@ audio {
width: 100%;
}
.library-panel {
grid-template-columns: 1fr;
}
.url-list {
max-height: 28vh;
}
.favorite-editor-row {
grid-template-columns: 1fr;
}
.remove-favorite,
.secondary-button,
.primary-button {
width: 100%;
}
.modal-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.controls {
right: 0.75rem;
bottom: 0.75rem;

View File

@@ -24,6 +24,8 @@ const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0';
const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE);
const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '..', 'data', 'recent-urls.json');
const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50);
const FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', 'data', 'favorites.json');
const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200);
const SESSION_TTL_MS = 60 * 60 * 1000;
const METADATA_PROBE_TIMEOUT_MS = 8 * 1000;
const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000;
@@ -46,9 +48,11 @@ const sourceTokens = new Map();
const playbacks = new Map();
let recentUrls = [];
let recentWrite = Promise.resolve();
let favorites = [];
let favoritesWrite = Promise.resolve();
app.disable('x-powered-by');
app.use(express.json({ limit: '32kb' }));
app.use(express.json({ limit: '256kb' }));
app.use(express.static(publicDir));
app.get('/api/health', (_request, response) => {
@@ -65,6 +69,34 @@ app.get('/api/recent-urls', (_request, response) => {
});
});
app.get('/api/favorites', (_request, response) => {
response.json({ favorites: formatFavoritesPayload() });
});
app.put('/api/favorites', async (request, response) => {
let nextFavorites;
try {
nextFavorites = parseFavoritesPayload(request.body?.favorites);
} catch (error) {
response.status(400).json({ error: error.message });
return;
}
favorites = nextFavorites;
favoritesWrite = favoritesWrite.catch(() => {}).then(() => saveFavorites());
try {
await favoritesWrite;
} catch (error) {
console.warn(`Failed to store favorites: ${error.message}`);
response.status(500).json({ error: 'Failed to store favorites.' });
return;
}
response.json({ favorites: formatFavoritesPayload() });
});
app.post('/api/session', async (request, response) => {
let url;
@@ -237,11 +269,14 @@ app.get('/audio/:sessionId', (request, response) => {
return;
}
if (PLAYBACK_CONNECTION_MODE === 'single' || PLAYBACK_CONNECTION_MODE === 'relay') {
getOrCreatePlayback(session).attachAudio(request, response);
const playbackMode = getSessionPlaybackConnectionMode(session);
if (playbackMode === 'single' || playbackMode === 'relay') {
getOrCreatePlayback(session, playbackMode).attachAudio(request, response);
return;
}
stopSharedPlaybackIfNeeded(session, playbackMode);
streamSplitAudio(request, response, session);
});
@@ -269,11 +304,14 @@ wss.on('connection', (websocket, _request, sessionId) => {
return;
}
if (PLAYBACK_CONNECTION_MODE === 'single' || PLAYBACK_CONNECTION_MODE === 'relay') {
getOrCreatePlayback(session).attachFrames(websocket);
const playbackMode = getSessionPlaybackConnectionMode(session);
if (playbackMode === 'single' || playbackMode === 'relay') {
getOrCreatePlayback(session, playbackMode).attachFrames(websocket);
return;
}
stopSharedPlaybackIfNeeded(session, playbackMode);
streamSplitFrames(websocket, session);
});
@@ -293,7 +331,10 @@ setInterval(() => {
}
}, 60 * 1000).unref();
await loadRecentUrls();
await Promise.all([
loadRecentUrls(),
loadFavorites(),
]);
server.listen(PORT, () => {
console.log(`Frame stream app listening at http://localhost:${PORT} mode=${PLAYBACK_CONNECTION_MODE}`);
@@ -378,7 +419,19 @@ function formatSessionPayload(session) {
}
function isSessionSeekable(session) {
return PLAYBACK_CONNECTION_MODE !== 'relay' && Number.isFinite(session.duration) && session.duration > 0;
return hasRecordedDuration(session);
}
function hasRecordedDuration(session) {
return Number.isFinite(session.duration) && session.duration > 0;
}
function getSessionPlaybackConnectionMode(session) {
if (PLAYBACK_CONNECTION_MODE === 'relay' && hasRecordedDuration(session)) {
return 'split';
}
return PLAYBACK_CONNECTION_MODE;
}
function startSessionMetadataProbe(session) {
@@ -501,6 +554,25 @@ async function loadRecentUrls() {
}
}
async function loadFavorites() {
try {
const raw = await fs.readFile(FAVORITES_PATH, 'utf8');
const parsed = JSON.parse(raw);
const items = Array.isArray(parsed?.favorites) ? parsed.favorites : [];
favorites = items
.map(normalizeFavorite)
.filter(Boolean)
.slice(0, FAVORITES_LIMIT);
} catch (error) {
if (error.code !== 'ENOENT') {
console.warn(`Failed to read favorites: ${error.message}`);
}
favorites = [];
}
}
async function addRecentUrl(url) {
const lastPlayedAt = new Date().toISOString();
recentUrls = [
@@ -522,6 +594,85 @@ async function saveRecentUrls() {
await fs.rename(temporaryPath, RECENT_URLS_PATH);
}
async function saveFavorites() {
const directory = path.dirname(FAVORITES_PATH);
const temporaryPath = `${FAVORITES_PATH}.${process.pid}.tmp`;
const payload = `${JSON.stringify({ favorites }, null, 2)}\n`;
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(temporaryPath, payload, 'utf8');
await fs.rename(temporaryPath, FAVORITES_PATH);
}
function formatFavoritesPayload() {
return favorites.map((item) => ({
title: item.title,
url: item.url,
}));
}
function parseFavoritesPayload(value) {
if (!Array.isArray(value)) {
throw new Error('Favorites must be a list.');
}
if (value.length > FAVORITES_LIMIT) {
throw new Error(`Favorites are limited to ${FAVORITES_LIMIT} items.`);
}
return value.map((item, index) => parseFavorite(item, index));
}
function parseFavorite(item, index) {
if (!item || typeof item !== 'object') {
throw new Error(`Favorite ${index + 1} is invalid.`);
}
const title = parseFavoriteTitle(item.title, index);
let url;
try {
url = parseStreamUrl(item.url);
} catch (error) {
throw new Error(`Favorite ${index + 1}: ${error.message}`);
}
return { title, url };
}
function normalizeFavorite(item) {
if (!item || typeof item !== 'object') {
return null;
}
try {
return {
title: parseFavoriteTitle(item.title, 0),
url: parseStreamUrl(item.url),
};
} catch {
return null;
}
}
function parseFavoriteTitle(value, index) {
if (typeof value !== 'string') {
throw new Error(`Favorite ${index + 1} needs a title.`);
}
const title = value.trim();
if (!title) {
throw new Error(`Favorite ${index + 1} needs a title.`);
}
if (title.length > 120) {
throw new Error(`Favorite ${index + 1} title is too long.`);
}
return title;
}
function getSession(sessionId) {
const session = sessions.get(sessionId);
@@ -533,18 +684,34 @@ function getSession(sessionId) {
return session;
}
function getOrCreatePlayback(session) {
function getOrCreatePlayback(session, playbackMode = getSessionPlaybackConnectionMode(session)) {
const existing = playbacks.get(session.id);
if (existing && !existing.closed) {
if (existing && !existing.closed && existing.mode === playbackMode) {
return existing;
}
const playback = PLAYBACK_CONNECTION_MODE === 'relay' ? createRelayPlayback(session) : createPlayback(session);
stopSharedPlaybackIfNeeded(session, playbackMode);
const playback = playbackMode === 'relay' ? createRelayPlayback(session) : createPlayback(session);
playbacks.set(session.id, playback);
return playback;
}
function stopSharedPlaybackIfNeeded(session, playbackMode) {
const existing = playbacks.get(session.id);
if (!existing || existing.closed || existing.mode === playbackMode) {
return;
}
existing.stop('playback_mode_changed');
if (playbacks.get(session.id) === existing) {
playbacks.delete(session.id);
}
}
function streamSplitAudio(request, response, session) {
const worker = createAudioWorker(session);
const releaseWorker = once(worker.release);
@@ -617,11 +784,11 @@ function streamSplitFrames(websocket, session) {
const stderrTail = createFfmpegStderrTail();
const { fps, quality, width } = session.options;
const label = `frames:${shortId(session.id)}`;
let frameBuffer = Buffer.alloc(0);
let frameIndex = 0;
let skippedFrames = 0;
let stopReason = 'process_exit';
let serverClosingWebSocket = false;
const frameParser = createJpegFrameParser(handleJpegFrame);
logger.start();
@@ -694,45 +861,27 @@ function streamSplitFrames(websocket, session) {
});
function handleFrameData(chunk) {
frameBuffer = Buffer.concat([frameBuffer, chunk]);
frameParser.write(chunk);
}
for (;;) {
const start = frameBuffer.indexOf(JPEG_SOI);
function handleJpegFrame(jpeg) {
const timestamp = frameIndex / fps;
frameIndex += 1;
if (start === -1) {
frameBuffer = Buffer.alloc(0);
return;
}
const sendResult = sendFramePacket(websocket, timestamp, jpeg, () => {
stopReason = 'websocket_send_error';
stopProcess(ffmpeg);
});
const end = frameBuffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
if (end === -1) {
frameBuffer = start === 0 ? frameBuffer : frameBuffer.subarray(start);
return;
}
const jpeg = frameBuffer.subarray(start, end + JPEG_EOI.length);
frameBuffer = frameBuffer.subarray(end + JPEG_EOI.length);
const timestamp = frameIndex / fps;
frameIndex += 1;
if (!isWebSocketOpen(websocket)) {
return;
}
const sendResult = sendFramePacket(websocket, timestamp, jpeg, () => {
stopReason = 'websocket_send_error';
stopProcess(ffmpeg);
});
if (sendResult === 'closed') {
return;
}
if (sendResult === 'skipped') {
skippedFrames += 1;
}
if (sendResult === 'closed') {
return false;
}
if (sendResult === 'skipped') {
skippedFrames += 1;
}
return true;
}
}
@@ -749,7 +898,6 @@ function createRelayPlayback(session) {
let frameLogger = null;
let sourceController = null;
let sourceBytes = 0;
let frameBuffer = Buffer.alloc(0);
let frameIndex = 0;
let skippedFrames = 0;
let stopReason = 'process_exit';
@@ -767,8 +915,10 @@ function createRelayPlayback(session) {
let readyTimer = null;
const audioStderr = createFfmpegStderrTail();
const frameStderr = createFfmpegStderrTail();
const frameParser = createJpegFrameParser(handleJpegFrame);
const playback = {
mode: 'relay',
get closed() {
return closed;
},
@@ -987,46 +1137,28 @@ function createRelayPlayback(session) {
}
function handleFrameData(chunk) {
frameBuffer = Buffer.concat([frameBuffer, chunk]);
frameParser.write(chunk);
}
for (;;) {
const start = frameBuffer.indexOf(JPEG_SOI);
function handleJpegFrame(jpeg) {
const timestamp = frameIndex / fps;
frameIndex += 1;
if (start === -1) {
frameBuffer = Buffer.alloc(0);
return;
const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
if (error && !closed) {
stop('websocket_send_error');
}
});
const end = frameBuffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
if (end === -1) {
frameBuffer = start === 0 ? frameBuffer : frameBuffer.subarray(start);
return;
}
const jpeg = frameBuffer.subarray(start, end + JPEG_EOI.length);
frameBuffer = frameBuffer.subarray(end + JPEG_EOI.length);
const timestamp = frameIndex / fps;
frameIndex += 1;
if (!isWebSocketOpen(websocket)) {
return;
}
const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
if (error && !closed) {
stop('websocket_send_error');
}
});
if (sendResult === 'closed') {
return;
}
if (sendResult === 'skipped') {
skippedFrames += 1;
}
if (sendResult === 'closed') {
return false;
}
if (sendResult === 'skipped') {
skippedFrames += 1;
}
return true;
}
function stop(reason) {
@@ -1136,7 +1268,6 @@ function createPlayback(session) {
let logger = null;
let releaseSource = () => {};
let stderr = '';
let frameBuffer = Buffer.alloc(0);
let frameIndex = 0;
let skippedFrames = 0;
let stopReason = 'process_exit';
@@ -1144,8 +1275,10 @@ function createPlayback(session) {
let closed = false;
let readyTimer = null;
const stderrTail = createFfmpegStderrTail();
const frameParser = createJpegFrameParser(handleJpegFrame);
const playback = {
mode: 'single',
get closed() {
return closed;
},
@@ -1339,46 +1472,28 @@ function createPlayback(session) {
}
function handleFrameData(chunk) {
frameBuffer = Buffer.concat([frameBuffer, chunk]);
frameParser.write(chunk);
}
for (;;) {
const start = frameBuffer.indexOf(JPEG_SOI);
function handleJpegFrame(jpeg) {
const timestamp = frameIndex / fps;
frameIndex += 1;
if (start === -1) {
frameBuffer = Buffer.alloc(0);
return;
const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
if (error && !closed) {
stop('websocket_send_error');
}
});
const end = frameBuffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
if (end === -1) {
frameBuffer = start === 0 ? frameBuffer : frameBuffer.subarray(start);
return;
}
const jpeg = frameBuffer.subarray(start, end + JPEG_EOI.length);
frameBuffer = frameBuffer.subarray(end + JPEG_EOI.length);
const timestamp = frameIndex / fps;
frameIndex += 1;
if (!isWebSocketOpen(websocket)) {
return;
}
const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
if (error && !closed) {
stop('websocket_send_error');
}
});
if (sendResult === 'closed') {
return;
}
if (sendResult === 'skipped') {
skippedFrames += 1;
}
if (sendResult === 'closed') {
return false;
}
if (sendResult === 'skipped') {
skippedFrames += 1;
}
return true;
}
function stop(reason) {
@@ -1436,12 +1551,101 @@ function isWebSocketOpen(websocket) {
return websocket?.readyState === WebSocket.OPEN;
}
function createJpegFrameParser(onFrame) {
let collecting = false;
let pendingMarkerByte = false;
let parts = [];
let byteLength = 0;
return {
write(chunk) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
let offset = 0;
while (offset < buffer.length) {
if (!collecting) {
if (pendingMarkerByte && buffer[offset] === 0xd8) {
collecting = true;
pendingMarkerByte = false;
parts = [JPEG_SOI];
byteLength = JPEG_SOI.length;
offset += 1;
} else {
const start = buffer.indexOf(JPEG_SOI, offset);
if (start === -1) {
pendingMarkerByte = buffer[buffer.length - 1] === 0xff;
return true;
}
collecting = true;
pendingMarkerByte = false;
parts = [];
byteLength = 0;
offset = start;
}
}
if (pendingMarkerByte) {
pendingMarkerByte = false;
if (buffer[offset] === 0xd9) {
appendFramePart(buffer.subarray(offset, offset + 1));
offset += 1;
if (!emitFrame()) {
return false;
}
continue;
}
}
const end = buffer.indexOf(JPEG_EOI, offset);
if (end === -1) {
appendFramePart(buffer.subarray(offset));
pendingMarkerByte = buffer[buffer.length - 1] === 0xff;
return true;
}
appendFramePart(buffer.subarray(offset, end + JPEG_EOI.length));
offset = end + JPEG_EOI.length;
if (!emitFrame()) {
return false;
}
}
return true;
},
};
function appendFramePart(part) {
if (part.length === 0) {
return;
}
parts.push(part);
byteLength += part.length;
}
function emitFrame() {
const frame = { parts, byteLength };
collecting = false;
pendingMarkerByte = false;
parts = [];
byteLength = 0;
return onFrame(frame) !== false;
}
}
function sendFramePacket(websocket, timestamp, jpeg, onError) {
if (!isWebSocketOpen(websocket)) {
return 'closed';
}
const packetBytes = 8 + jpeg.length;
const packetBytes = 8 + getJpegFrameByteLength(jpeg);
if (websocket.bufferedAmount > 0 && websocket.bufferedAmount + packetBytes > MAX_WS_BUFFER_BYTES) {
return 'skipped';
@@ -1449,7 +1653,7 @@ function sendFramePacket(websocket, timestamp, jpeg, onError) {
const packet = Buffer.allocUnsafe(packetBytes);
packet.writeDoubleLE(timestamp, 0);
jpeg.copy(packet, 8);
copyJpegFrame(jpeg, packet, 8);
websocket.send(packet, { binary: true }, (error) => {
if (error) {
onError(error);
@@ -1458,6 +1662,24 @@ function sendFramePacket(websocket, timestamp, jpeg, onError) {
return 'sent';
}
function getJpegFrameByteLength(jpeg) {
return Buffer.isBuffer(jpeg) ? jpeg.length : jpeg.byteLength;
}
function copyJpegFrame(jpeg, packet, offset) {
if (Buffer.isBuffer(jpeg)) {
jpeg.copy(packet, offset);
return;
}
let writeOffset = offset;
for (const part of jpeg.parts) {
part.copy(packet, writeOffset);
writeOffset += part.length;
}
}
function createSourceInput(sessionId, kind) {
const token = randomUUID();
sourceTokens.set(token, { sessionId, kind, createdAt: Date.now() });