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:
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
425
public/app.js
425
public/app.js
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">×</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
390
server/index.js
390
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 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 (;;) {
|
|
||||||
const start = frameBuffer.indexOf(JPEG_SOI);
|
|
||||||
|
|
||||||
if (start === -1) {
|
|
||||||
frameBuffer = Buffer.alloc(0);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const end = frameBuffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
|
function handleJpegFrame(jpeg) {
|
||||||
|
|
||||||
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;
|
const timestamp = frameIndex / fps;
|
||||||
frameIndex += 1;
|
frameIndex += 1;
|
||||||
|
|
||||||
if (!isWebSocketOpen(websocket)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendResult = sendFramePacket(websocket, timestamp, jpeg, () => {
|
const sendResult = sendFramePacket(websocket, timestamp, jpeg, () => {
|
||||||
stopReason = 'websocket_send_error';
|
stopReason = 'websocket_send_error';
|
||||||
stopProcess(ffmpeg);
|
stopProcess(ffmpeg);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sendResult === 'closed') {
|
if (sendResult === 'closed') {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sendResult === 'skipped') {
|
if (sendResult === 'skipped') {
|
||||||
skippedFrames += 1;
|
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,32 +1137,13 @@ function createRelayPlayback(session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFrameData(chunk) {
|
function handleFrameData(chunk) {
|
||||||
frameBuffer = Buffer.concat([frameBuffer, chunk]);
|
frameParser.write(chunk);
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const start = frameBuffer.indexOf(JPEG_SOI);
|
|
||||||
|
|
||||||
if (start === -1) {
|
|
||||||
frameBuffer = Buffer.alloc(0);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const end = frameBuffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
|
function handleJpegFrame(jpeg) {
|
||||||
|
|
||||||
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;
|
const timestamp = frameIndex / fps;
|
||||||
frameIndex += 1;
|
frameIndex += 1;
|
||||||
|
|
||||||
if (!isWebSocketOpen(websocket)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
|
const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
|
||||||
if (error && !closed) {
|
if (error && !closed) {
|
||||||
stop('websocket_send_error');
|
stop('websocket_send_error');
|
||||||
@@ -1020,13 +1151,14 @@ function createRelayPlayback(session) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (sendResult === 'closed') {
|
if (sendResult === 'closed') {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sendResult === 'skipped') {
|
if (sendResult === 'skipped') {
|
||||||
skippedFrames += 1;
|
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,32 +1472,13 @@ function createPlayback(session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFrameData(chunk) {
|
function handleFrameData(chunk) {
|
||||||
frameBuffer = Buffer.concat([frameBuffer, chunk]);
|
frameParser.write(chunk);
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const start = frameBuffer.indexOf(JPEG_SOI);
|
|
||||||
|
|
||||||
if (start === -1) {
|
|
||||||
frameBuffer = Buffer.alloc(0);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const end = frameBuffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
|
function handleJpegFrame(jpeg) {
|
||||||
|
|
||||||
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;
|
const timestamp = frameIndex / fps;
|
||||||
frameIndex += 1;
|
frameIndex += 1;
|
||||||
|
|
||||||
if (!isWebSocketOpen(websocket)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
|
const sendResult = sendFramePacket(websocket, timestamp, jpeg, (error) => {
|
||||||
if (error && !closed) {
|
if (error && !closed) {
|
||||||
stop('websocket_send_error');
|
stop('websocket_send_error');
|
||||||
@@ -1372,13 +1486,14 @@ function createPlayback(session) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (sendResult === 'closed') {
|
if (sendResult === 'closed') {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sendResult === 'skipped') {
|
if (sendResult === 'skipped') {
|
||||||
skippedFrames += 1;
|
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() });
|
||||||
|
|||||||
Reference in New Issue
Block a user