This commit is contained in:
2026-05-01 22:08:50 -07:00
parent 8b7a1f81ad
commit e51aeae54e
9 changed files with 196 additions and 15 deletions

View File

@@ -5,4 +5,5 @@ npm-debug.log*
.env .env
.DS_Store .DS_Store
Dockerfile Dockerfile
data
public/_smoke.avi public/_smoke.avi

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/ node_modules/
data/recent-urls.json
.env .env
.DS_Store .DS_Store
npm-debug.log* npm-debug.log*

View File

@@ -17,7 +17,8 @@ COPY public ./public
COPY server ./server COPY server ./server
COPY README.md ./ COPY README.md ./
RUN chown -R node:node /app RUN mkdir -p /app/data \
&& chown -R node:node /app
USER node USER node

View File

@@ -39,6 +39,8 @@ 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. 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.
Recently played URLs are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume.
## Tuning ## Tuning
The UI intentionally hides these settings, but the backend still supports them through `POST /api/session`. The UI intentionally hides these settings, but the backend still supports them through `POST /api/session`.

View File

@@ -9,8 +9,11 @@ services:
environment: environment:
PORT: "3000" PORT: "3000"
NODE_ENV: production NODE_ENV: production
RECENT_URLS_PATH: /app/data/recent-urls.json
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes:
- frame-stream-data:/app/data
# CPU decoding is the default and does not need device passthrough. # CPU decoding is the default and does not need device passthrough.
# #
@@ -20,3 +23,6 @@ services:
# #
# Optional NVIDIA passthrough with Docker Compose v2: # Optional NVIDIA passthrough with Docker Compose v2:
# gpus: all # gpus: all
volumes:
frame-stream-data:

View File

@@ -5,6 +5,8 @@ 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'),
recentPanel: document.querySelector('#recent-panel'),
recentList: document.querySelector('#recent-list'),
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,8 +30,11 @@ const state = {
frameCount: 0, frameCount: 0,
controlsVisible: true, controlsVisible: true,
hideControlsTimer: 0, hideControlsTimer: 0,
recentUrls: [],
}; };
void loadRecentUrls();
elements.form.addEventListener('submit', async (event) => { elements.form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
setEntryMessage(''); setEntryMessage('');
@@ -51,6 +56,7 @@ elements.form.addEventListener('submit', async (event) => {
} }
showPlayer(); showPlayer();
void loadRecentUrls();
startSession(payload); startSession(payload);
} catch (error) { } catch (error) {
stopSession(); stopSession();
@@ -60,6 +66,23 @@ elements.form.addEventListener('submit', async (event) => {
} }
}); });
elements.recentList.addEventListener('click', (event) => {
const button = event.target.closest('[data-recent-index]');
if (!button) {
return;
}
const item = state.recentUrls[Number(button.dataset.recentIndex)];
if (!item) {
return;
}
elements.url.value = item.url;
elements.form.requestSubmit();
});
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;
@@ -387,6 +410,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();
elements.url.focus(); elements.url.focus();
} }
@@ -410,4 +434,40 @@ function setEntryMessage(message) {
function setFormBusy(isBusy) { 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')) {
button.disabled = isBusy;
}
}
async function loadRecentUrls() {
try {
const response = await fetch('/api/recent-urls', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to load recent URLs.');
}
const payload = await response.json();
state.recentUrls = Array.isArray(payload.urls) ? payload.urls : [];
renderRecentUrls();
} catch {
state.recentUrls = [];
renderRecentUrls();
}
}
function renderRecentUrls() {
elements.recentList.replaceChildren(
...state.recentUrls.map((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'recent-url';
button.dataset.recentIndex = String(index);
button.textContent = item.displayUrl || item.url;
return button;
}),
);
elements.recentPanel.hidden = state.recentUrls.length === 0;
} }

View File

@@ -9,6 +9,7 @@
<body> <body>
<main class="app"> <main class="app">
<section id="entry-screen" class="entry-screen" aria-label="Stream URL"> <section id="entry-screen" class="entry-screen" aria-label="Stream URL">
<div class="entry-stack">
<form id="stream-form" class="url-form"> <form id="stream-form" class="url-form">
<label class="sr-only" for="stream-url">Video stream URL</label> <label class="sr-only" for="stream-url">Video stream URL</label>
<input <input
@@ -21,6 +22,10 @@
> >
<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="recent-list" class="recent-list"></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>
</section> </section>

View File

@@ -62,9 +62,15 @@ button:disabled {
linear-gradient(135deg, #070707 0%, #11100d 48%, #030303 100%); linear-gradient(135deg, #070707 0%, #11100d 48%, #030303 100%);
} }
.entry-stack {
display: grid;
width: min(760px, 100%);
gap: 0.75rem;
}
.url-form { .url-form {
display: flex; display: flex;
width: min(760px, 100%); width: 100%;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -105,6 +111,43 @@ button:disabled {
color: #070707; color: #070707;
} }
.recent-panel {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 28px;
background: rgba(255, 255, 255, 0.07);
box-shadow: 0 18px 80px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.recent-list {
display: grid;
max-height: min(42vh, 24rem);
overflow: auto;
padding: 0.35rem;
}
.recent-url {
display: block;
width: 100%;
overflow: hidden;
border: 0;
border-radius: 20px;
padding: 0.85rem 1rem;
color: var(--soft);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
background: transparent;
}
.recent-url:hover,
.recent-url:focus {
color: var(--fg);
background: rgba(255, 255, 255, 0.09);
outline: none;
}
.message { .message {
position: fixed; position: fixed;
bottom: 2rem; bottom: 2rem;

View File

@@ -1,5 +1,6 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import path from 'node:path'; import path from 'node:path';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
@@ -17,6 +18,8 @@ const wss = new WebSocketServer({ noServer: true });
const PORT = Number(process.env.PORT ?? 3000); const PORT = Number(process.env.PORT ?? 3000);
const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg'; const FFMPEG_PATH = process.env.FFMPEG_PATH ?? 'ffmpeg';
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 SESSION_TTL_MS = 60 * 60 * 1000; const SESSION_TTL_MS = 60 * 60 * 1000;
const MAX_WS_BUFFER_BYTES = 12 * 1024 * 1024; const MAX_WS_BUFFER_BYTES = 12 * 1024 * 1024;
const JPEG_SOI = Buffer.from([0xff, 0xd8]); const JPEG_SOI = Buffer.from([0xff, 0xd8]);
@@ -31,6 +34,8 @@ const defaults = {
const sessions = new Map(); const sessions = new Map();
const sourceTokens = new Map(); const sourceTokens = new Map();
let recentUrls = [];
let recentWrite = Promise.resolve();
app.disable('x-powered-by'); app.disable('x-powered-by');
app.use(express.json({ limit: '32kb' })); app.use(express.json({ limit: '32kb' }));
@@ -40,7 +45,17 @@ app.get('/api/health', (_request, response) => {
response.json({ ok: true, ffmpeg: FFMPEG_PATH }); response.json({ ok: true, ffmpeg: FFMPEG_PATH });
}); });
app.post('/api/session', (request, response) => { app.get('/api/recent-urls', (_request, response) => {
response.json({
urls: recentUrls.map((item) => ({
url: item.url,
displayUrl: redactSecrets(item.url),
lastPlayedAt: item.lastPlayedAt,
})),
});
});
app.post('/api/session', async (request, response) => {
let url; let url;
try { try {
@@ -61,6 +76,12 @@ app.post('/api/session', (request, response) => {
lastUsedAt: Date.now(), lastUsedAt: Date.now(),
}); });
try {
await addRecentUrl(url);
} catch (error) {
console.warn(`Failed to store recent URL: ${error.message}`);
}
response.status(201).json({ id, options }); response.status(201).json({ id, options });
}); });
@@ -313,6 +334,8 @@ setInterval(() => {
} }
}, 60 * 1000).unref(); }, 60 * 1000).unref();
await loadRecentUrls();
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`Frame stream app listening at http://localhost:${PORT}`); console.log(`Frame stream app listening at http://localhost:${PORT}`);
}); });
@@ -362,6 +385,45 @@ function parseAudioBitrate(value) {
return /^\d{2,3}k$/i.test(value) ? value.toLowerCase() : defaults.audioBitrate; return /^\d{2,3}k$/i.test(value) ? value.toLowerCase() : defaults.audioBitrate;
} }
async function loadRecentUrls() {
try {
const raw = await fs.readFile(RECENT_URLS_PATH, 'utf8');
const parsed = JSON.parse(raw);
const items = Array.isArray(parsed?.urls) ? parsed.urls : [];
recentUrls = items
.filter((item) => typeof item?.url === 'string' && typeof item?.lastPlayedAt === 'string')
.slice(0, RECENT_URL_LIMIT);
} catch (error) {
if (error.code !== 'ENOENT') {
console.warn(`Failed to read recent URLs: ${error.message}`);
}
recentUrls = [];
}
}
async function addRecentUrl(url) {
const lastPlayedAt = new Date().toISOString();
recentUrls = [
{ url, lastPlayedAt },
...recentUrls.filter((item) => item.url !== url),
].slice(0, RECENT_URL_LIMIT);
recentWrite = recentWrite.catch(() => {}).then(() => saveRecentUrls());
await recentWrite;
}
async function saveRecentUrls() {
const directory = path.dirname(RECENT_URLS_PATH);
const temporaryPath = `${RECENT_URLS_PATH}.${process.pid}.tmp`;
const payload = `${JSON.stringify({ urls: recentUrls }, null, 2)}\n`;
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(temporaryPath, payload, 'utf8');
await fs.rename(temporaryPath, RECENT_URLS_PATH);
}
function getSession(sessionId) { function getSession(sessionId) {
const session = sessions.get(sessionId); const session = sessions.get(sessionId);