Compare commits
4 Commits
13b1d768dc
...
8b74d25954
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b74d25954 | |||
| 5e2a2e1de7 | |||
| 47dae48673 | |||
| 81d9cfc1c2 |
15
AGENTS.md
15
AGENTS.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
425
public/app.js
425
public/app.js
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">×</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
462
server/index.js
462
server/index.js
@@ -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() });
|
||||
|
||||
Reference in New Issue
Block a user