adds favorites

This commit is contained in:
2026-05-04 20:33:14 -07:00
parent 47dae48673
commit 5e2a2e1de7
7 changed files with 599 additions and 23 deletions

View File

@@ -6,18 +6,18 @@ This project is a web video player for clients that can decode audio and still i
The UI intentionally has only two screens: The UI intentionally has only two screens:
- URL entry screen with a stream URL input, a `Next` button, and globally stored recently played URLs. - URL entry screen with a stream URL input, a `Next` button, globally stored recently played URLs, and globally stored favorites.
- Fullscreen player screen with JPEG frames drawn to a canvas and native audio playback through an `<audio>` element. - Fullscreen player screen with JPEG frames drawn to a canvas and native audio playback through an `<audio>` element.
- Playback controls are overlay controls toggled by tapping/clicking the frame area, similar to YouTube. - Playback controls are overlay controls toggled by tapping/clicking the frame area, similar to YouTube.
- Do not reintroduce debug panels, frame counters, settings forms, explanatory marketing copy, or visible ffmpeg details into the normal UI. - Do not reintroduce debug panels, frame counters, settings forms, explanatory marketing copy, or visible ffmpeg details into the normal UI.
The backend stores recently played URLs globally, not per-browser. The default path is `data/recent-urls.json`, configurable with `RECENT_URLS_PATH`. Docker Compose persists this through the `frame-stream-data` volume. The backend stores recently played URLs and favorites globally, not per-browser. The default recent URL path is `data/recent-urls.json`, configurable with `RECENT_URLS_PATH`. The default favorites path is `data/favorites.json`, configurable with `FAVORITES_PATH`. Docker Compose persists both through the `frame-stream-data` volume.
## Core Architecture ## Core Architecture
The app is plain Node/Express plus browser JavaScript: The app is plain Node/Express plus browser JavaScript:
- `server/index.js`: API, WebSocket, source proxy/relay, ffmpeg process lifecycle, recent URL persistence. - `server/index.js`: API, WebSocket, source proxy/relay, ffmpeg process lifecycle, recent URL and favorites persistence.
- `public/index.html`: frontend markup. - `public/index.html`: frontend markup.
- `public/app.js`: URL submission, WebSocket frame receiving, audio element coordination, canvas drawing, overlay controls. - `public/app.js`: URL submission, WebSocket frame receiving, audio element coordination, canvas drawing, overlay controls.
- `public/styles.css`: two-screen player UI. - `public/styles.css`: two-screen player UI.
@@ -28,6 +28,8 @@ Main public endpoints:
- `POST /api/session`: validates the stream URL, stores recent URL, creates a short-lived playback session. - `POST /api/session`: validates the stream URL, stores recent URL, creates a short-lived playback session.
- `GET /api/recent-urls`: returns global recent URL entries with `url`, redacted `displayUrl`, and `lastPlayedAt`. - `GET /api/recent-urls`: returns global recent URL entries with `url`, redacted `displayUrl`, and `lastPlayedAt`.
- `GET /api/favorites`: returns global favorite entries with `title` and `url`.
- `PUT /api/favorites`: replaces the global favorites list. Each favorite has a user-provided `title` and stream `url`.
- `GET /audio/:sessionId`: serves MP3 audio to the browser audio element. - `GET /audio/:sessionId`: serves MP3 audio to the browser audio element.
- `WS /frames/:sessionId`: sends timed JPEG frame packets to the browser. - `WS /frames/:sessionId`: sends timed JPEG frame packets to the browser.
- `GET /api/health`: exposes basic health and active playback connection mode. - `GET /api/health`: exposes basic health and active playback connection mode.
@@ -170,6 +172,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 +222,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 +230,7 @@ PORT=3014 RECENT_URLS_PATH=/tmp/carplay-relay-recent.json \
After smoke testing, remove generated assets: After smoke testing, remove generated assets:
```sh ```sh
rm -f public/_relay-smoke.ts /tmp/carplay-*-recent.json rm -f public/_relay-smoke.ts /tmp/carplay-*-recent.json /tmp/carplay-*-favorites.json
``` ```
## Security Notes ## Security Notes

View File

@@ -39,7 +39,7 @@ docker compose -f docker-compose-example.yml up --build
The app uses CPU decoding by default, so no video device is required. The compose example includes commented VAAPI/NVIDIA passthrough options for future hardware-accelerated `ffmpeg` setups, but hardware acceleration is usually only useful when server CPU is saturated. The app uses CPU decoding by default, so no video device is required. The compose example includes commented VAAPI/NVIDIA passthrough options for future hardware-accelerated `ffmpeg` setups, but hardware acceleration is usually only useful when server CPU is saturated.
Recently played URLs are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume. Recently played URLs and favorites are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume.
`ffmpeg` worker lifecycle, stderr warnings/errors, and source proxy open/close events are written to stdout/stderr, so they appear in `docker logs`. For more detail while debugging a stream, set `FFMPEG_LOG_LEVEL=info` in Docker Compose and run: `ffmpeg` worker lifecycle, stderr warnings/errors, and source proxy open/close events are written to stdout/stderr, so they appear in `docker logs`. For more detail while debugging a stream, set `FFMPEG_LOG_LEVEL=info` in Docker Compose and run:

View File

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

View File

@@ -5,8 +5,20 @@ const elements = {
url: document.querySelector('#stream-url'), url: document.querySelector('#stream-url'),
next: document.querySelector('#next'), next: document.querySelector('#next'),
entryMessage: document.querySelector('#entry-message'), entryMessage: document.querySelector('#entry-message'),
libraryPanel: document.querySelector('#library-panel'),
recentPanel: document.querySelector('#recent-panel'), recentPanel: document.querySelector('#recent-panel'),
recentList: document.querySelector('#recent-list'), recentList: document.querySelector('#recent-list'),
favoritesPanel: document.querySelector('#favorites-panel'),
favoritesList: document.querySelector('#favorites-list'),
editFavorites: document.querySelector('#edit-favorites'),
favoritesModal: document.querySelector('#favorites-modal'),
favoritesForm: document.querySelector('#favorites-form'),
closeFavorites: document.querySelector('#close-favorites'),
cancelFavorites: document.querySelector('#cancel-favorites'),
addFavorite: document.querySelector('#add-favorite'),
saveFavorites: document.querySelector('#save-favorites'),
favoritesEditorList: document.querySelector('#favorites-editor-list'),
favoritesMessage: document.querySelector('#favorites-message'),
audio: document.querySelector('#audio'), audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'), canvas: document.querySelector('#screen'),
stage: document.querySelector('#video-stage'), stage: document.querySelector('#video-stage'),
@@ -49,6 +61,8 @@ 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,
@@ -60,7 +74,7 @@ const state = {
lastPlaybackRestartAt: 0, lastPlaybackRestartAt: 0,
}; };
void loadRecentUrls(); void loadEntryLists();
elements.form.addEventListener('submit', async (event) => { elements.form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
@@ -110,6 +124,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;
@@ -939,7 +1015,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();
} }
@@ -964,11 +1040,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' });
@@ -991,12 +1074,170 @@ function renderRecentUrls() {
...state.recentUrls.map((item, index) => { ...state.recentUrls.map((item, index) => {
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.className = 'recent-url'; button.className = 'url-item';
button.dataset.recentIndex = String(index); button.dataset.recentIndex = String(index);
button.textContent = item.displayUrl || item.url; button.textContent = item.displayUrl || item.url;
return button; return button;
}), }),
); );
elements.recentPanel.hidden = state.recentUrls.length === 0; if (state.recentUrls.length === 0) {
elements.recentList.replaceChildren(createEmptyListMessage('No recents'));
}
}
async function loadFavorites() {
try {
const response = await fetch('/api/favorites', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to load favorites.');
}
const payload = await response.json();
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites();
} catch {
state.favorites = [];
renderFavorites();
}
}
function renderFavorites() {
elements.favoritesList.replaceChildren(
...state.favorites.map((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'url-item';
button.dataset.favoriteIndex = String(index);
button.textContent = item.title;
return button;
}),
);
if (state.favorites.length === 0) {
elements.favoritesList.replaceChildren(createEmptyListMessage('No favorites'));
}
}
function createEmptyListMessage(text) {
const message = document.createElement('p');
message.className = 'empty-list';
message.textContent = text;
return message;
}
function openFavoritesModal() {
state.favoriteModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
elements.favoritesMessage.textContent = '';
renderFavoriteEditorRows(state.favorites);
elements.favoritesModal.hidden = false;
elements.favoritesModal.classList.add('is-open');
focusFirstFavoriteField();
}
function closeFavoritesModal() {
elements.favoritesModal.hidden = true;
elements.favoritesModal.classList.remove('is-open');
elements.favoritesMessage.textContent = '';
state.favoriteModalTrigger?.focus();
state.favoriteModalTrigger = null;
}
function renderFavoriteEditorRows(items) {
const rows = items.length > 0 ? items : [{ title: '', url: '' }];
elements.favoritesEditorList.replaceChildren(
...rows.map((item) => createFavoriteEditorRow(item)),
);
}
function createFavoriteEditorRow(item = { title: '', url: '' }) {
const row = document.createElement('div');
row.className = 'favorite-editor-row';
const title = document.createElement('input');
title.type = 'text';
title.placeholder = 'Title';
title.setAttribute('aria-label', 'Favorite title');
title.autocomplete = 'off';
title.maxLength = 120;
title.value = item.title ?? '';
title.dataset.favoriteTitle = '';
const url = document.createElement('input');
url.type = 'url';
url.placeholder = 'URL';
url.setAttribute('aria-label', 'Favorite URL');
url.autocomplete = 'off';
url.value = item.url ?? '';
url.dataset.favoriteUrl = '';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'remove-favorite';
remove.dataset.removeFavorite = '';
remove.setAttribute('aria-label', 'Remove favorite');
remove.textContent = 'Remove';
row.append(title, url, remove);
return row;
}
function ensureFavoriteEditorRows() {
if (elements.favoritesEditorList.children.length === 0) {
elements.favoritesEditorList.append(createFavoriteEditorRow());
}
}
function focusFirstFavoriteField() {
const field = elements.favoritesEditorList.querySelector('[data-favorite-title], [data-favorite-url]');
field?.focus();
}
function focusLastFavoriteTitle() {
const rows = elements.favoritesEditorList.querySelectorAll('.favorite-editor-row');
const row = rows[rows.length - 1];
row?.querySelector('[data-favorite-title]')?.focus();
}
async function saveFavoritesFromModal() {
elements.favoritesMessage.textContent = '';
setFavoritesModalBusy(true);
try {
const favorites = readFavoriteEditorRows();
const response = await fetch('/api/favorites', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ favorites }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to save favorites.');
}
state.favorites = Array.isArray(payload.favorites) ? payload.favorites : [];
renderFavorites();
closeFavoritesModal();
} catch (error) {
elements.favoritesMessage.textContent = error.message;
} finally {
setFavoritesModalBusy(false);
}
}
function readFavoriteEditorRows() {
return [...elements.favoritesEditorList.querySelectorAll('.favorite-editor-row')]
.map((row) => ({
title: row.querySelector('[data-favorite-title]')?.value.trim() ?? '',
url: row.querySelector('[data-favorite-url]')?.value.trim() ?? '',
}))
.filter((item) => item.title || item.url);
}
function setFavoritesModalBusy(isBusy) {
for (const field of elements.favoritesForm.querySelectorAll('input, button')) {
field.disabled = isBusy;
}
} }

View File

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

View File

@@ -64,7 +64,7 @@ button:disabled {
.entry-stack { .entry-stack {
display: grid; display: grid;
width: min(760px, 100%); width: min(980px, 100%);
gap: 0.75rem; gap: 0.75rem;
} }
@@ -96,7 +96,12 @@ button:disabled {
} }
.url-form button, .url-form button,
.control-button { .control-button,
.small-button,
.secondary-button,
.primary-button,
.icon-button,
.remove-favorite {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
color: var(--fg); color: var(--fg);
@@ -111,28 +116,61 @@ button:disabled {
color: #070707; color: #070707;
} }
.recent-panel { .library-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.url-column {
overflow: hidden; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 28px; border-radius: 24px;
background: rgba(255, 255, 255, 0.07); background: rgba(255, 255, 255, 0.07);
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28); box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
} }
.recent-list { .column-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 3.2rem;
padding: 0.55rem 0.75rem 0.2rem 1rem;
}
.column-heading h2,
.modal-heading h2 {
margin: 0;
font-size: 0.86rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
color: var(--soft);
}
.small-button {
flex: 0 0 auto;
min-width: 4.2rem;
padding: 0.45rem 0.8rem;
font-size: 0.84rem;
font-weight: 800;
}
.url-list {
display: grid; display: grid;
max-height: min(42vh, 24rem); max-height: min(42vh, 24rem);
overflow: auto; overflow: auto;
padding: 0.35rem; padding: 0.35rem;
} }
.recent-url { .url-item {
display: block; display: block;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
border: 0; border: 0;
border-radius: 20px; border-radius: 16px;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
color: var(--soft); color: var(--soft);
text-align: left; text-align: left;
@@ -141,13 +179,19 @@ button:disabled {
background: transparent; background: transparent;
} }
.recent-url:hover, .url-item:hover,
.recent-url:focus { .url-item:focus {
color: var(--fg); color: var(--fg);
background: rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.09);
outline: none; outline: none;
} }
.empty-list {
margin: 0;
padding: 0.85rem 1rem;
color: rgba(246, 241, 232, 0.42);
}
.message { .message {
position: fixed; position: fixed;
bottom: 2rem; bottom: 2rem;
@@ -365,6 +409,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 +531,29 @@ audio {
width: 100%; width: 100%;
} }
.library-panel {
grid-template-columns: 1fr;
}
.url-list {
max-height: 28vh;
}
.favorite-editor-row {
grid-template-columns: 1fr;
}
.remove-favorite,
.secondary-button,
.primary-button {
width: 100%;
}
.modal-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.controls { .controls {
right: 0.75rem; right: 0.75rem;
bottom: 0.75rem; bottom: 0.75rem;

View File

@@ -24,6 +24,8 @@ const FFMPEG_INPUT_SEEKABLE = process.env.FFMPEG_INPUT_SEEKABLE ?? '0';
const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE); const PLAYBACK_CONNECTION_MODE = parsePlaybackConnectionMode(process.env.PLAYBACK_CONNECTION_MODE ?? process.env.PLAYBACK_MODE);
const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '..', 'data', 'recent-urls.json'); const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '..', 'data', 'recent-urls.json');
const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50); const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50);
const FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', 'data', 'favorites.json');
const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200);
const SESSION_TTL_MS = 60 * 60 * 1000; const SESSION_TTL_MS = 60 * 60 * 1000;
const METADATA_PROBE_TIMEOUT_MS = 8 * 1000; const METADATA_PROBE_TIMEOUT_MS = 8 * 1000;
const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000; const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000;
@@ -46,9 +48,11 @@ const sourceTokens = new Map();
const playbacks = new Map(); const playbacks = new Map();
let recentUrls = []; let recentUrls = [];
let recentWrite = Promise.resolve(); let recentWrite = Promise.resolve();
let favorites = [];
let favoritesWrite = Promise.resolve();
app.disable('x-powered-by'); app.disable('x-powered-by');
app.use(express.json({ limit: '32kb' })); app.use(express.json({ limit: '256kb' }));
app.use(express.static(publicDir)); app.use(express.static(publicDir));
app.get('/api/health', (_request, response) => { app.get('/api/health', (_request, response) => {
@@ -65,6 +69,34 @@ app.get('/api/recent-urls', (_request, response) => {
}); });
}); });
app.get('/api/favorites', (_request, response) => {
response.json({ favorites: formatFavoritesPayload() });
});
app.put('/api/favorites', async (request, response) => {
let nextFavorites;
try {
nextFavorites = parseFavoritesPayload(request.body?.favorites);
} catch (error) {
response.status(400).json({ error: error.message });
return;
}
favorites = nextFavorites;
favoritesWrite = favoritesWrite.catch(() => {}).then(() => saveFavorites());
try {
await favoritesWrite;
} catch (error) {
console.warn(`Failed to store favorites: ${error.message}`);
response.status(500).json({ error: 'Failed to store favorites.' });
return;
}
response.json({ favorites: formatFavoritesPayload() });
});
app.post('/api/session', async (request, response) => { app.post('/api/session', async (request, response) => {
let url; let url;
@@ -293,7 +325,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}`);
@@ -501,6 +536,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 +576,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);