diff --git a/.dockerignore b/.dockerignore index 7478707..8c2a663 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,5 @@ npm-debug.log* .env .DS_Store Dockerfile +data public/_smoke.avi diff --git a/.gitignore b/.gitignore index 0c97d47..adcc854 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +data/recent-urls.json .env .DS_Store npm-debug.log* diff --git a/Dockerfile b/Dockerfile index e0cc450..6778f9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,8 @@ COPY public ./public COPY server ./server COPY README.md ./ -RUN chown -R node:node /app +RUN mkdir -p /app/data \ + && chown -R node:node /app USER node diff --git a/README.md b/README.md index 7fb8553..d842ee8 100644 --- a/README.md +++ b/README.md @@ -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. +Recently played URLs are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume. + ## Tuning The UI intentionally hides these settings, but the backend still supports them through `POST /api/session`. diff --git a/docker-compose-example.yml b/docker-compose-example.yml index 74192aa..1c591e9 100644 --- a/docker-compose-example.yml +++ b/docker-compose-example.yml @@ -9,8 +9,11 @@ services: environment: PORT: "3000" NODE_ENV: production + RECENT_URLS_PATH: /app/data/recent-urls.json extra_hosts: - "host.docker.internal:host-gateway" + volumes: + - frame-stream-data:/app/data # CPU decoding is the default and does not need device passthrough. # @@ -20,3 +23,6 @@ services: # # Optional NVIDIA passthrough with Docker Compose v2: # gpus: all + +volumes: + frame-stream-data: diff --git a/public/app.js b/public/app.js index 2d8165f..346eb74 100644 --- a/public/app.js +++ b/public/app.js @@ -5,6 +5,8 @@ const elements = { url: document.querySelector('#stream-url'), next: document.querySelector('#next'), entryMessage: document.querySelector('#entry-message'), + recentPanel: document.querySelector('#recent-panel'), + recentList: document.querySelector('#recent-list'), audio: document.querySelector('#audio'), canvas: document.querySelector('#screen'), stage: document.querySelector('#video-stage'), @@ -28,8 +30,11 @@ const state = { frameCount: 0, controlsVisible: true, hideControlsTimer: 0, + recentUrls: [], }; +void loadRecentUrls(); + elements.form.addEventListener('submit', async (event) => { event.preventDefault(); setEntryMessage(''); @@ -51,6 +56,7 @@ elements.form.addEventListener('submit', async (event) => { } showPlayer(); + void loadRecentUrls(); startSession(payload); } catch (error) { 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) => { if (!state.session || event.target.closest('.controls')) { return; @@ -387,6 +410,7 @@ function syncControlLabels() { function showEntry() { elements.playerScreen.hidden = true; elements.entryScreen.hidden = false; + void loadRecentUrls(); elements.url.focus(); } @@ -410,4 +434,40 @@ function setEntryMessage(message) { function setFormBusy(isBusy) { elements.url.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; } diff --git a/public/index.html b/public/index.html index 59815ed..628f447 100644 --- a/public/index.html +++ b/public/index.html @@ -9,18 +9,23 @@
-
- - - -
+
+
+ + + +
+ +
diff --git a/public/styles.css b/public/styles.css index 19d866d..0cc0346 100644 --- a/public/styles.css +++ b/public/styles.css @@ -62,9 +62,15 @@ button:disabled { linear-gradient(135deg, #070707 0%, #11100d 48%, #030303 100%); } +.entry-stack { + display: grid; + width: min(760px, 100%); + gap: 0.75rem; +} + .url-form { display: flex; - width: min(760px, 100%); + width: 100%; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--line); @@ -105,6 +111,43 @@ button:disabled { 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 { position: fixed; bottom: 2rem; diff --git a/server/index.js b/server/index.js index 2deff47..5a29dc6 100644 --- a/server/index.js +++ b/server/index.js @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; import { createServer } from 'node:http'; import path from 'node:path'; import { Readable } from 'node:stream'; @@ -17,6 +18,8 @@ const wss = new WebSocketServer({ noServer: true }); const PORT = Number(process.env.PORT ?? 3000); 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 MAX_WS_BUFFER_BYTES = 12 * 1024 * 1024; const JPEG_SOI = Buffer.from([0xff, 0xd8]); @@ -31,6 +34,8 @@ const defaults = { const sessions = new Map(); const sourceTokens = new Map(); +let recentUrls = []; +let recentWrite = Promise.resolve(); app.disable('x-powered-by'); app.use(express.json({ limit: '32kb' })); @@ -40,7 +45,17 @@ app.get('/api/health', (_request, response) => { 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; try { @@ -61,6 +76,12 @@ app.post('/api/session', (request, response) => { lastUsedAt: Date.now(), }); + try { + await addRecentUrl(url); + } catch (error) { + console.warn(`Failed to store recent URL: ${error.message}`); + } + response.status(201).json({ id, options }); }); @@ -313,6 +334,8 @@ setInterval(() => { } }, 60 * 1000).unref(); +await loadRecentUrls(); + server.listen(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; } +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) { const session = sessions.get(sessionId);