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: 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. - 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. - 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. - 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 ## Core Architecture
The app is plain Node/Express plus browser JavaScript: 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/index.html`: frontend markup.
- `public/app.js`: URL submission, WebSocket frame receiving, audio element coordination, canvas drawing, overlay controls. - `public/app.js`: URL submission, WebSocket frame receiving, audio element coordination, canvas drawing, overlay controls.
- `public/styles.css`: two-screen player UI. - `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. - `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/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. - `GET /audio/:sessionId`: serves MP3 audio to the browser audio element.
- `WS /frames/:sessionId`: sends timed JPEG frame packets to the browser. - `WS /frames/:sessionId`: sends timed JPEG frame packets to the browser.
- `GET /api/health`: exposes basic health and active playback connection mode. - `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. 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 ## ffmpeg Pipelines
All ffmpeg command builders live near the bottom of `server/index.js`. All ffmpeg command builders live near the bottom of `server/index.js`.
@@ -170,6 +174,8 @@ Runtime:
- `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`. - `PLAYBACK_CONNECTION_MODE`: `split`, `relay`, or `single`.
- `RECENT_URLS_PATH`: recent URL JSON path. - `RECENT_URLS_PATH`: recent URL JSON path.
- `RECENT_URL_LIMIT`: recent URL count, default `12`. - `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_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_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`. - `MAX_RELAY_BRANCH_QUEUE_BYTES`: relay per-branch compressed-input queue cap, default `16777216`.
@@ -218,6 +224,7 @@ Start a mode locally:
```sh ```sh
PORT=3014 RECENT_URLS_PATH=/tmp/carplay-relay-recent.json \ 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 \ FFMPEG_LOG_LEVEL=warning FFMPEG_INPUT_SEEKABLE=0 \
PLAYBACK_CONNECTION_MODE=relay npm start 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: After smoke testing, remove generated assets:
```sh ```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 ## 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. 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: `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. - `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. - `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`. 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 ## Tuning

View File

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

View File

@@ -5,8 +5,20 @@ const elements = {
url: document.querySelector('#stream-url'), url: document.querySelector('#stream-url'),
next: document.querySelector('#next'), next: document.querySelector('#next'),
entryMessage: document.querySelector('#entry-message'), entryMessage: document.querySelector('#entry-message'),
libraryPanel: document.querySelector('#library-panel'),
recentPanel: document.querySelector('#recent-panel'), recentPanel: document.querySelector('#recent-panel'),
recentList: document.querySelector('#recent-list'), 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'), audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'), canvas: document.querySelector('#screen'),
stage: document.querySelector('#video-stage'), stage: document.querySelector('#video-stage'),
@@ -28,9 +40,18 @@ const MAX_PENDING_FRAME_QUEUE_SECONDS = 2;
const MAX_DECODED_FRAME_QUEUE_SECONDS = 3; const MAX_DECODED_FRAME_QUEUE_SECONDS = 3;
const MIN_PENDING_FRAME_QUEUE = 12; const MIN_PENDING_FRAME_QUEUE = 12;
const MIN_DECODED_FRAME_QUEUE = 24; 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 = { const state = {
generation: 0, generation: 0,
streamGeneration: 0,
session: null, session: null,
websocket: null, websocket: null,
pendingFrames: [], pendingFrames: [],
@@ -42,14 +63,20 @@ const state = {
controlsVisible: true, controlsVisible: true,
hideControlsTimer: 0, hideControlsTimer: 0,
recentUrls: [], recentUrls: [],
favorites: [],
favoriteModalTrigger: null,
playbackOffset: 0, playbackOffset: 0,
duration: null, duration: null,
seekable: false, seekable: false,
isSeeking: false, isSeeking: false,
seekPreviewTime: 0, seekPreviewTime: 0,
frameWatchdogTimer: 0,
lastFramePacketAt: 0,
lastFramePaintedAt: 0,
lastPlaybackRestartAt: 0,
}; };
void loadRecentUrls(); void loadEntryLists();
elements.form.addEventListener('submit', async (event) => { elements.form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
@@ -99,6 +126,68 @@ elements.recentList.addEventListener('click', (event) => {
elements.form.requestSubmit(); 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) => { elements.stage.addEventListener('pointerup', (event) => {
if (!state.session || event.target.closest('.controls')) { if (!state.session || event.target.closest('.controls')) {
return; return;
@@ -156,6 +245,7 @@ elements.seek.addEventListener('change', () => {
elements.audio.addEventListener('play', () => { elements.audio.addEventListener('play', () => {
startRenderLoop(); startRenderLoop();
startFrameWatchdog();
syncControlLabels(); syncControlLabels();
scheduleControlsHide(); scheduleControlsHide();
syncPlayhead(); 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) { function startSession(session) {
state.session = session; state.session = session;
state.playbackOffset = Number(session.seekSeconds) || 0; state.playbackOffset = Number(session.seekSeconds) || 0;
@@ -203,6 +299,8 @@ function startSession(session) {
state.isSeeking = false; state.isSeeking = false;
state.seekPreviewTime = state.playbackOffset; state.seekPreviewTime = state.playbackOffset;
state.frameCount = 0; state.frameCount = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
clearFrameQueue(); clearFrameQueue();
syncControlLabels(); syncControlLabels();
syncPlayhead(); syncPlayhead();
@@ -211,6 +309,7 @@ function startSession(session) {
clearPlayerMessage(); clearPlayerMessage();
connectPlaybackStreams(); connectPlaybackStreams();
startFrameWatchdog();
void refreshSessionMetadata(session.id); void refreshSessionMetadata(session.id);
} }
@@ -219,8 +318,12 @@ function connectPlaybackStreams() {
return; return;
} }
const generation = state.generation; const streamGeneration = state.streamGeneration + 1;
const session = state.session; const session = state.session;
const now = Date.now();
state.streamGeneration = streamGeneration;
state.lastPlaybackRestartAt = now;
state.lastFramePacketAt = now;
if (state.websocket) { if (state.websocket) {
state.websocket.close(1000, 'client reconnecting'); state.websocket.close(1000, 'client reconnecting');
@@ -234,7 +337,7 @@ function connectPlaybackStreams() {
state.websocket = websocket; state.websocket = websocket;
websocket.addEventListener('message', (event) => { websocket.addEventListener('message', (event) => {
if (generation !== state.generation) { if (streamGeneration !== state.streamGeneration) {
return; return;
} }
@@ -243,18 +346,18 @@ function connectPlaybackStreams() {
return; return;
} }
void handleFramePacket(event.data, generation); handleFramePacket(event.data, streamGeneration);
}); });
websocket.addEventListener('close', () => { websocket.addEventListener('close', () => {
if (generation === state.generation && state.session?.id === session.id) { if (streamGeneration === state.streamGeneration && state.session?.id === session.id) {
showPlayerMessage('Stream ended'); showPlayerMessage('Stream ended');
setControlsVisible(true); setControlsVisible(true);
} }
}); });
websocket.addEventListener('error', () => { websocket.addEventListener('error', () => {
if (generation === state.generation && state.session?.id === session.id) { if (streamGeneration === state.streamGeneration && state.session?.id === session.id) {
showPlayerMessage('Stream failed'); showPlayerMessage('Stream failed');
setControlsVisible(true); 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) { if (!(packet instanceof ArrayBuffer) || packet.byteLength <= 8) {
return; return;
} }
const timestamp = new DataView(packet, 0, 8).getFloat64(0, true); const timestamp = new DataView(packet, 0, 8).getFloat64(0, true);
if (generation !== state.generation || isLateFrame(timestamp)) { if (streamGeneration !== state.streamGeneration || isLateFrame(timestamp)) {
return; return;
} }
state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), generation }); state.lastFramePacketAt = Date.now();
state.pendingFrames.push({ timestamp, jpeg: packet.slice(8), streamGeneration });
trimPendingFrameQueue(); trimPendingFrameQueue();
void pumpFrameDecodeQueue(); void pumpFrameDecodeQueue();
} }
@@ -362,6 +541,7 @@ function drawReadyFrames() {
state.currentBitmap = frameToDraw.bitmap; state.currentBitmap = frameToDraw.bitmap;
drawBitmap(frameToDraw.bitmap); drawBitmap(frameToDraw.bitmap);
state.lastFramePaintedAt = Date.now();
elements.loader.hidden = true; elements.loader.hidden = true;
clearPlayerMessage(); clearPlayerMessage();
} }
@@ -395,19 +575,19 @@ async function pumpFrameDecodeQueue() {
return; return;
} }
if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) { if (frame.streamGeneration !== state.streamGeneration || isLateFrame(frame.timestamp)) {
continue; continue;
} }
let bitmap; let bitmap;
try { try {
bitmap = await decodeImage(new Blob([frame.jpeg], { type: 'image/jpeg' })); bitmap = await decodeImageWithTimeout(new Blob([frame.jpeg], { type: 'image/jpeg' }));
} catch { } catch {
continue; continue;
} }
if (frame.generation !== state.generation || isLateFrame(frame.timestamp)) { if (frame.streamGeneration !== state.streamGeneration || isLateFrame(frame.timestamp)) {
releaseImage(bitmap); releaseImage(bitmap);
continue; continue;
} }
@@ -501,6 +681,7 @@ function isLateFrame(timestamp) {
function stopSession({ showEntry: shouldShowEntry = true } = {}) { function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.generation += 1; state.generation += 1;
state.streamGeneration += 1;
state.session = null; state.session = null;
state.frameCount = 0; state.frameCount = 0;
state.playbackOffset = 0; state.playbackOffset = 0;
@@ -508,7 +689,10 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.seekable = false; state.seekable = false;
state.isSeeking = false; state.isSeeking = false;
state.seekPreviewTime = 0; state.seekPreviewTime = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
clearHideControlsTimer(); clearHideControlsTimer();
clearFrameWatchdog();
if (state.websocket) { if (state.websocket) {
state.websocket.close(1000, 'client stopped'); state.websocket.close(1000, 'client stopped');
@@ -537,7 +721,7 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
} }
} }
function clearFrameQueue() { function clearFrameQueue({ keepCurrent = false } = {}) {
state.pendingFrames = []; state.pendingFrames = [];
for (const frame of state.frames) { for (const frame of state.frames) {
@@ -546,12 +730,45 @@ function clearFrameQueue() {
state.frames = []; state.frames = [];
if (state.currentBitmap) { if (!keepCurrent && state.currentBitmap) {
releaseImage(state.currentBitmap); releaseImage(state.currentBitmap);
state.currentBitmap = null; 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) { async function decodeImage(blob) {
if ('createImageBitmap' in window) { if ('createImageBitmap' in window) {
return createImageBitmap(blob); return createImageBitmap(blob);
@@ -596,10 +813,13 @@ async function seekTo(value) {
const sessionId = state.session.id; const sessionId = state.session.id;
state.generation = generation; state.generation = generation;
state.streamGeneration += 1;
state.playbackOffset = targetTime; state.playbackOffset = targetTime;
state.seekPreviewTime = targetTime; state.seekPreviewTime = targetTime;
state.isSeeking = false; state.isSeeking = false;
state.frameCount = 0; state.frameCount = 0;
state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0;
elements.loader.hidden = false; elements.loader.hidden = false;
clearPlayerMessage(); clearPlayerMessage();
clearFrameQueue(); clearFrameQueue();
@@ -648,7 +868,7 @@ async function seekTo(value) {
async function refreshSessionMetadata(sessionId) { async function refreshSessionMetadata(sessionId) {
const generation = state.generation; 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) { if (generation !== state.generation || state.session?.id !== sessionId) {
return; return;
} }
@@ -676,7 +896,7 @@ async function refreshSessionMetadata(sessionId) {
return; return;
} }
await delay(650); await delay(METADATA_REFRESH_INTERVAL_MS);
} }
} }
@@ -797,7 +1017,7 @@ function syncControlLabels() {
function showEntry() { function showEntry() {
elements.playerScreen.hidden = true; elements.playerScreen.hidden = true;
elements.entryScreen.hidden = false; elements.entryScreen.hidden = false;
void loadRecentUrls(); void loadEntryLists();
elements.url.focus(); elements.url.focus();
} }
@@ -822,11 +1042,18 @@ function setFormBusy(isBusy) {
elements.url.disabled = isBusy; elements.url.disabled = isBusy;
elements.next.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; button.disabled = isBusy;
} }
} }
async function loadEntryLists() {
await Promise.all([
loadRecentUrls(),
loadFavorites(),
]);
}
async function loadRecentUrls() { async function loadRecentUrls() {
try { try {
const response = await fetch('/api/recent-urls', { cache: 'no-store' }); const response = await fetch('/api/recent-urls', { cache: 'no-store' });
@@ -849,12 +1076,170 @@ function renderRecentUrls() {
...state.recentUrls.map((item, index) => { ...state.recentUrls.map((item, index) => {
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.className = 'recent-url'; button.className = 'url-item';
button.dataset.recentIndex = String(index); button.dataset.recentIndex = String(index);
button.textContent = item.displayUrl || item.url; button.textContent = item.displayUrl || item.url;
return button; 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> <button id="next" type="submit">Next</button>
</form> </form>
<div id="recent-panel" class="recent-panel" aria-label="Recently played URLs" hidden> <div id="library-panel" class="library-panel" aria-label="Saved streams">
<div id="recent-list" class="recent-list"></div> <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> </div>
<div id="entry-message" class="message" role="status" aria-live="polite"></div> <div id="entry-message" class="message" role="status" aria-live="polite"></div>
@@ -48,6 +60,22 @@
</div> </div>
<audio id="audio" preload="none"></audio> <audio id="audio" preload="none"></audio>
</section> </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> </main>
<script src="/app.js" type="module"></script> <script src="/app.js" type="module"></script>

View File

@@ -64,7 +64,7 @@ button:disabled {
.entry-stack { .entry-stack {
display: grid; display: grid;
width: min(760px, 100%); width: min(980px, 100%);
gap: 0.75rem; gap: 0.75rem;
} }
@@ -96,7 +96,12 @@ button:disabled {
} }
.url-form button, .url-form button,
.control-button { .control-button,
.small-button,
.secondary-button,
.primary-button,
.icon-button,
.remove-favorite {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
color: var(--fg); color: var(--fg);
@@ -111,28 +116,61 @@ button:disabled {
color: #070707; color: #070707;
} }
.recent-panel { .library-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.url-column {
overflow: hidden; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 28px; border-radius: 24px;
background: rgba(255, 255, 255, 0.07); background: rgba(255, 255, 255, 0.07);
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28); box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px); 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; display: grid;
max-height: min(42vh, 24rem); max-height: min(42vh, 24rem);
overflow: auto; overflow: auto;
padding: 0.35rem; padding: 0.35rem;
} }
.recent-url { .url-item {
display: block; display: block;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
border: 0; border: 0;
border-radius: 20px; border-radius: 16px;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
color: var(--soft); color: var(--soft);
text-align: left; text-align: left;
@@ -141,13 +179,19 @@ button:disabled {
background: transparent; background: transparent;
} }
.recent-url:hover, .url-item:hover,
.recent-url:focus { .url-item:focus {
color: var(--fg); color: var(--fg);
background: rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.09);
outline: none; outline: none;
} }
.empty-list {
margin: 0;
padding: 0.85rem 1rem;
color: rgba(246, 241, 232, 0.42);
}
.message { .message {
position: fixed; position: fixed;
bottom: 2rem; bottom: 2rem;
@@ -242,12 +286,13 @@ canvas {
top: -1.75rem; top: -1.75rem;
right: 1.1rem; right: 1.1rem;
left: 1.1rem; left: 1.1rem;
height: 2.2rem; height: 2.45rem;
} }
.time-badge { .time-badge {
position: absolute; position: absolute;
top: 0; top: 0;
pointer-events: none;
min-width: 3.6rem; min-width: 3.6rem;
padding: 0.22rem 0.45rem; padding: 0.22rem 0.45rem;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -274,10 +319,10 @@ canvas {
.seek-slider { .seek-slider {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: 0.18rem; bottom: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 1rem; height: 1.35rem;
margin: 0; margin: 0;
appearance: none; appearance: none;
background: transparent; background: transparent;
@@ -365,6 +410,107 @@ audio {
display: none; 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] { [hidden] {
display: none !important; display: none !important;
} }
@@ -386,6 +532,29 @@ audio {
width: 100%; 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 { .controls {
right: 0.75rem; right: 0.75rem;
bottom: 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 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_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 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 SESSION_TTL_MS = 60 * 60 * 1000;
const METADATA_PROBE_TIMEOUT_MS = 8 * 1000; const METADATA_PROBE_TIMEOUT_MS = 8 * 1000;
const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000; const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000;
@@ -46,9 +48,11 @@ const sourceTokens = new Map();
const playbacks = new Map(); const playbacks = new Map();
let recentUrls = []; let recentUrls = [];
let recentWrite = Promise.resolve(); let recentWrite = Promise.resolve();
let favorites = [];
let favoritesWrite = Promise.resolve();
app.disable('x-powered-by'); app.disable('x-powered-by');
app.use(express.json({ limit: '32kb' })); app.use(express.json({ limit: '256kb' }));
app.use(express.static(publicDir)); app.use(express.static(publicDir));
app.get('/api/health', (_request, response) => { 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) => { app.post('/api/session', async (request, response) => {
let url; let url;
@@ -237,11 +269,14 @@ app.get('/audio/:sessionId', (request, response) => {
return; return;
} }
if (PLAYBACK_CONNECTION_MODE === 'single' || PLAYBACK_CONNECTION_MODE === 'relay') { const playbackMode = getSessionPlaybackConnectionMode(session);
getOrCreatePlayback(session).attachAudio(request, response);
if (playbackMode === 'single' || playbackMode === 'relay') {
getOrCreatePlayback(session, playbackMode).attachAudio(request, response);
return; return;
} }
stopSharedPlaybackIfNeeded(session, playbackMode);
streamSplitAudio(request, response, session); streamSplitAudio(request, response, session);
}); });
@@ -269,11 +304,14 @@ wss.on('connection', (websocket, _request, sessionId) => {
return; return;
} }
if (PLAYBACK_CONNECTION_MODE === 'single' || PLAYBACK_CONNECTION_MODE === 'relay') { const playbackMode = getSessionPlaybackConnectionMode(session);
getOrCreatePlayback(session).attachFrames(websocket);
if (playbackMode === 'single' || playbackMode === 'relay') {
getOrCreatePlayback(session, playbackMode).attachFrames(websocket);
return; return;
} }
stopSharedPlaybackIfNeeded(session, playbackMode);
streamSplitFrames(websocket, session); streamSplitFrames(websocket, session);
}); });
@@ -293,7 +331,10 @@ setInterval(() => {
} }
}, 60 * 1000).unref(); }, 60 * 1000).unref();
await loadRecentUrls(); await Promise.all([
loadRecentUrls(),
loadFavorites(),
]);
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`Frame stream app listening at http://localhost:${PORT} mode=${PLAYBACK_CONNECTION_MODE}`); console.log(`Frame stream app listening at http://localhost:${PORT} mode=${PLAYBACK_CONNECTION_MODE}`);
@@ -378,7 +419,19 @@ function formatSessionPayload(session) {
} }
function isSessionSeekable(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) { 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) { async function addRecentUrl(url) {
const lastPlayedAt = new Date().toISOString(); const lastPlayedAt = new Date().toISOString();
recentUrls = [ recentUrls = [
@@ -522,6 +594,85 @@ async function saveRecentUrls() {
await fs.rename(temporaryPath, RECENT_URLS_PATH); 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) { function getSession(sessionId) {
const session = sessions.get(sessionId); const session = sessions.get(sessionId);
@@ -533,18 +684,34 @@ function getSession(sessionId) {
return session; return session;
} }
function getOrCreatePlayback(session) { function getOrCreatePlayback(session, playbackMode = getSessionPlaybackConnectionMode(session)) {
const existing = playbacks.get(session.id); const existing = playbacks.get(session.id);
if (existing && !existing.closed) { if (existing && !existing.closed && existing.mode === playbackMode) {
return existing; 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); playbacks.set(session.id, playback);
return 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) { function streamSplitAudio(request, response, session) {
const worker = createAudioWorker(session); const worker = createAudioWorker(session);
const releaseWorker = once(worker.release); const releaseWorker = once(worker.release);
@@ -617,11 +784,11 @@ function streamSplitFrames(websocket, session) {
const stderrTail = createFfmpegStderrTail(); const stderrTail = createFfmpegStderrTail();
const { fps, quality, width } = session.options; const { fps, quality, width } = session.options;
const label = `frames:${shortId(session.id)}`; const label = `frames:${shortId(session.id)}`;
let frameBuffer = Buffer.alloc(0);
let frameIndex = 0; let frameIndex = 0;
let skippedFrames = 0; let skippedFrames = 0;
let stopReason = 'process_exit'; let stopReason = 'process_exit';
let serverClosingWebSocket = false; let serverClosingWebSocket = false;
const frameParser = createJpegFrameParser(handleJpegFrame);
logger.start(); logger.start();
@@ -694,45 +861,27 @@ function streamSplitFrames(websocket, session) {
}); });
function handleFrameData(chunk) { function handleFrameData(chunk) {
frameBuffer = Buffer.concat([frameBuffer, chunk]); frameParser.write(chunk);
}
for (;;) { function handleJpegFrame(jpeg) {
const start = frameBuffer.indexOf(JPEG_SOI); const timestamp = frameIndex / fps;
frameIndex += 1;
if (start === -1) { const sendResult = sendFramePacket(websocket, timestamp, jpeg, () => {
frameBuffer = Buffer.alloc(0); stopReason = 'websocket_send_error';
return; stopProcess(ffmpeg);
} });
const end = frameBuffer.indexOf(JPEG_EOI, start + JPEG_SOI.length); if (sendResult === 'closed') {
return false;
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 === 'skipped') {
skippedFrames += 1;
}
return true;
} }
} }
@@ -749,7 +898,6 @@ function createRelayPlayback(session) {
let frameLogger = null; let frameLogger = null;
let sourceController = null; let sourceController = null;
let sourceBytes = 0; let sourceBytes = 0;
let frameBuffer = Buffer.alloc(0);
let frameIndex = 0; let frameIndex = 0;
let skippedFrames = 0; let skippedFrames = 0;
let stopReason = 'process_exit'; let stopReason = 'process_exit';
@@ -767,8 +915,10 @@ function createRelayPlayback(session) {
let readyTimer = null; let readyTimer = null;
const audioStderr = createFfmpegStderrTail(); const audioStderr = createFfmpegStderrTail();
const frameStderr = createFfmpegStderrTail(); const frameStderr = createFfmpegStderrTail();
const frameParser = createJpegFrameParser(handleJpegFrame);
const playback = { const playback = {
mode: 'relay',
get closed() { get closed() {
return closed; return closed;
}, },
@@ -987,46 +1137,28 @@ function createRelayPlayback(session) {
} }
function handleFrameData(chunk) { function handleFrameData(chunk) {
frameBuffer = Buffer.concat([frameBuffer, chunk]); frameParser.write(chunk);
}
for (;;) { function handleJpegFrame(jpeg) {
const start = frameBuffer.indexOf(JPEG_SOI); const timestamp = frameIndex / fps;
frameIndex += 1;
if (start === -1) { const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
frameBuffer = Buffer.alloc(0); if (error && !closed) {
return; stop('websocket_send_error');
} }
});
const end = frameBuffer.indexOf(JPEG_EOI, start + JPEG_SOI.length); if (sendResult === 'closed') {
return false;
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 === 'skipped') {
skippedFrames += 1;
}
return true;
} }
function stop(reason) { function stop(reason) {
@@ -1136,7 +1268,6 @@ function createPlayback(session) {
let logger = null; let logger = null;
let releaseSource = () => {}; let releaseSource = () => {};
let stderr = ''; let stderr = '';
let frameBuffer = Buffer.alloc(0);
let frameIndex = 0; let frameIndex = 0;
let skippedFrames = 0; let skippedFrames = 0;
let stopReason = 'process_exit'; let stopReason = 'process_exit';
@@ -1144,8 +1275,10 @@ function createPlayback(session) {
let closed = false; let closed = false;
let readyTimer = null; let readyTimer = null;
const stderrTail = createFfmpegStderrTail(); const stderrTail = createFfmpegStderrTail();
const frameParser = createJpegFrameParser(handleJpegFrame);
const playback = { const playback = {
mode: 'single',
get closed() { get closed() {
return closed; return closed;
}, },
@@ -1339,46 +1472,28 @@ function createPlayback(session) {
} }
function handleFrameData(chunk) { function handleFrameData(chunk) {
frameBuffer = Buffer.concat([frameBuffer, chunk]); frameParser.write(chunk);
}
for (;;) { function handleJpegFrame(jpeg) {
const start = frameBuffer.indexOf(JPEG_SOI); const timestamp = frameIndex / fps;
frameIndex += 1;
if (start === -1) { const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
frameBuffer = Buffer.alloc(0); if (error && !closed) {
return; stop('websocket_send_error');
} }
});
const end = frameBuffer.indexOf(JPEG_EOI, start + JPEG_SOI.length); if (sendResult === 'closed') {
return false;
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 === 'skipped') {
skippedFrames += 1;
}
return true;
} }
function stop(reason) { function stop(reason) {
@@ -1436,12 +1551,101 @@ function isWebSocketOpen(websocket) {
return websocket?.readyState === WebSocket.OPEN; 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) { function sendFramePacket(websocket, timestamp, jpeg, onError) {
if (!isWebSocketOpen(websocket)) { if (!isWebSocketOpen(websocket)) {
return 'closed'; return 'closed';
} }
const packetBytes = 8 + jpeg.length; const packetBytes = 8 + getJpegFrameByteLength(jpeg);
if (websocket.bufferedAmount > 0 && websocket.bufferedAmount + packetBytes > MAX_WS_BUFFER_BYTES) { if (websocket.bufferedAmount > 0 && websocket.bufferedAmount + packetBytes > MAX_WS_BUFFER_BYTES) {
return 'skipped'; return 'skipped';
@@ -1449,7 +1653,7 @@ function sendFramePacket(websocket, timestamp, jpeg, onError) {
const packet = Buffer.allocUnsafe(packetBytes); const packet = Buffer.allocUnsafe(packetBytes);
packet.writeDoubleLE(timestamp, 0); packet.writeDoubleLE(timestamp, 0);
jpeg.copy(packet, 8); copyJpegFrame(jpeg, packet, 8);
websocket.send(packet, { binary: true }, (error) => { websocket.send(packet, { binary: true }, (error) => {
if (error) { if (error) {
onError(error); onError(error);
@@ -1458,6 +1662,24 @@ function sendFramePacket(websocket, timestamp, jpeg, onError) {
return 'sent'; 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) { function createSourceInput(sessionId, kind) {
const token = randomUUID(); const token = randomUUID();
sourceTokens.set(token, { sessionId, kind, createdAt: Date.now() }); sourceTokens.set(token, { sessionId, kind, createdAt: Date.now() });