fixed API and stopping delay

This commit is contained in:
Marco Mooren
2026-05-27 12:54:56 +02:00
parent 470d4e8e76
commit 7b8d78ddaf
22 changed files with 495 additions and 98 deletions

View File

@@ -1,5 +1,5 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto'; import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
import { getDb } from './db/index.js'; import { getDb } from './db/index.js';
const SESSION_DAYS = 30; const SESSION_DAYS = 30;
@@ -66,7 +66,82 @@ function appendSetCookieRaw(res, value) {
else res.setHeader('Set-Cookie', [prev, value]); else res.setHeader('Set-Cookie', [prev, value]);
} }
// ---------------------------------------------------------------------------
// API key auth
// ---------------------------------------------------------------------------
function hashApiKey(key) {
return createHash('sha256').update(key).digest('hex');
}
export function createApiKey(userId, label = '') {
const key = 'oradio_' + randomBytes(32).toString('hex');
getDb().prepare('INSERT INTO api_keys (key_hash, label, user_id) VALUES (?, ?, ?)')
.run(hashApiKey(key), label, userId);
return key; // shown exactly once — never stored in plain text
}
export function getUserByApiKey(key) {
if (!key || typeof key !== 'string') return null;
// Constant-time-safe: derive the hash first, then look it up by hash.
const hash = hashApiKey(key);
const row = getDb().prepare(`
SELECT u.id, u.username, u.role, u.is_main, u.avatar_color, u.avatar_emoji,
ak.id AS ak_id
FROM api_keys ak JOIN users u ON u.id = ak.user_id
WHERE ak.key_hash = ?
`).get(hash);
if (!row) return null;
// Bump last_used_at asynchronously so auth stays fast
setImmediate(() => {
getDb().prepare('UPDATE api_keys SET last_used_at = datetime(\'now\') WHERE id = ?')
.run(row.ak_id);
});
const { ak_id: _drop, ...user } = row;
return user;
}
export function listApiKeys() {
return getDb().prepare(
'SELECT ak.id, ak.label, ak.created_at, ak.last_used_at, u.username ' +
'FROM api_keys ak JOIN users u ON u.id = ak.user_id ORDER BY ak.id'
).all();
}
export function revokeApiKey(id) {
getDb().prepare('DELETE FROM api_keys WHERE id = ?').run(id);
}
// Registers a raw key from the environment (e.g. ORADIO_API_KEY) so it can
// be used immediately without going through the admin UI. Idempotent.
export function ensureBootstrapApiKey(rawKey) {
if (!rawKey) return;
const db = getDb();
const hash = hashApiKey(rawKey);
if (db.prepare('SELECT 1 FROM api_keys WHERE key_hash = ?').get(hash)) return;
const main = db.prepare('SELECT id FROM users WHERE is_main = 1').get()
|| db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get();
if (!main) return;
db.prepare('INSERT INTO api_keys (key_hash, label, user_id) VALUES (?, ?, ?)')
.run(hash, 'env-bootstrap', main.id);
console.log('[auth] bootstrap API key registered from ORADIO_API_KEY');
}
// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------
export function authMiddleware(req, _res, next) { export function authMiddleware(req, _res, next) {
// 1. API key: X-Api-Key header or Authorization: Bearer <key>
const rawAuth = req.headers.authorization || '';
const apiKey = req.headers['x-api-key']
|| (rawAuth.startsWith('Bearer ') ? rawAuth.slice(7) : null);
if (apiKey) {
req.session = {};
req.user = getUserByApiKey(apiKey);
return next();
}
// 2. Session cookie (browser UI)
const token = readSessionToken(req); const token = readSessionToken(req);
req.session = { token }; req.session = { token };
req.user = getUserBySession(token); req.user = getUserBySession(token);

22
server/companion.js Normal file
View File

@@ -0,0 +1,22 @@
// Bitfocus Companion integration.
// Keeps the `currentRadio` custom variable on the Companion instance
// in sync with the currently playing station.
//
// Set COMPANION_URL in your .env to override the default host/port.
// e.g. COMPANION_URL=http://10.99.0.110:8000
const BASE = process.env.COMPANION_URL || 'http://10.99.0.110:8000';
const VARIABLE_PATH = '/api/custom-variable/currentRadio/value';
/**
* Push a new value to Companion's `currentRadio` custom variable.
* @param {number|null} stationId Pass a numeric station id when playing;
* pass null/undefined to clear (stop/pause).
*/
export function notifyCompanion(stationId) {
const value = stationId != null ? String(stationId) : '';
const url = `${BASE}${VARIABLE_PATH}?value=${encodeURIComponent(value)}`;
fetch(url, { method: 'POST' }).catch((err) => {
console.warn('[companion] notify failed:', err.message);
});
}

View File

@@ -158,3 +158,15 @@ CREATE TABLE IF NOT EXISTS room_state (
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
-- API keys for programmatic / script / StreamDeck access.
-- The actual key is shown only once at creation; only a SHA-256 hash is stored.
-- Send via: X-Api-Key: <key> or Authorization: Bearer <key>
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TEXT
);

View File

@@ -5,9 +5,9 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title> <title>Radio Admin</title>
<script type="module" crossorigin src="/assets/admin-DN0aiXMa.js"></script> <script type="module" crossorigin src="/assets/admin-DN0aiXMa.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js"> <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/debug-B-FoNBZ5.js"> <link rel="modulepreload" crossorigin href="/assets/debug-B-FoNBZ5.js">
<link rel="stylesheet" crossorigin href="/assets/admin-C-qnWY0z.css"> <link rel="stylesheet" crossorigin href="/assets/admin-C-qnWY0z.css">
</head> </head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
function x(l,t={}){let e,i=0,r=!1;function m(){const h=location.protocol==="https:"?"wss":"ws",o=new URLSearchParams;t.room&&o.set("room",t.room),t.kind&&o.set("kind",t.kind);const c=o.toString();e=new WebSocket(`${h}://${location.host}/ws${c?"?"+c:""}`),e.addEventListener("open",()=>{var n;i=0,(n=t.onOpen)==null||n.call(t)}),e.addEventListener("message",n=>{try{l(JSON.parse(n.data))}catch{}}),e.addEventListener("close",()=>{var n;(n=t.onClose)==null||n.call(t),!r&&(i=Math.min(i+1,6),setTimeout(m,500*2**i))}),e.addEventListener("error",()=>e.close())}return m(),{send(h){return(e==null?void 0:e.readyState)!==WebSocket.OPEN?!1:(e.send(JSON.stringify(h)),!0)},close(){r=!0,e==null||e.close()},get readyState(){return e==null?void 0:e.readyState}}}const w=1e3,N=5e3,C=5,I=8,v=16;function S(l){if(!l.length)return 0;const t=l.slice().sort((i,r)=>i-r),e=t.length>>1;return t.length%2?t[e]:(t[e-1]+t[e])/2}class M{constructor(){this.offset=0,this.rtt=1/0,this.offsetStd=1/0,this.samples=[],this.synced=!1,this._pending=new Map,this._listeners=new Set,this._timeoutId=null,this._ws=null}attachWs(t){this._ws=t,this.reset();let e=0;const i=()=>{if(e++>=5){this._scheduleNext();return}this._sendPing(),setTimeout(i,150)};i()}detach(){this._timeoutId&&clearTimeout(this._timeoutId),this._timeoutId=null,this._pending.clear(),this._ws=null}reset(){this.samples=[],this.synced=!1,this.offsetStd=1/0,this._pending.clear(),this._timeoutId&&(clearTimeout(this._timeoutId),this._timeoutId=null)}now(){return Date.now()+this.offset}isStable(){return this.synced&&this.samples.length>=I&&this.offsetStd<=C}onUpdate(t){return this._listeners.add(t),()=>this._listeners.delete(t)}handlePong(t){if(this._pending.get(t.t1)==null)return;this._pending.delete(t.t1);const i=Date.now(),r=i-t.t1,m=t.t2-(t.t1+i)/2;this.samples.push({offset:m,rtt:r}),this.samples.length>v&&this.samples.shift();const h=S(this.samples.map(s=>s.rtt)),o=Math.max(h*2,h+10),c=this.samples.filter(s=>s.rtt<=o),n=c.length?c.map(s=>s.offset):this.samples.map(s=>s.offset),_=S(n),p=n.reduce((s,d)=>s+d,0)/n.length,f=n.reduce((s,d)=>s+(d-p)**2,0)/n.length;this.offsetStd=Math.sqrt(f),this.offset=_,this.rtt=r,this.synced=!0;for(const s of this._listeners)s({offset:this.offset,rtt:this.rtt,offsetStd:this.offsetStd,samples:this.samples.length,accepted:c.length,stable:this.isStable()})}_sendPing(){if(!this._ws)return;const t=Date.now();this._pending.set(t,t);for(const e of this._pending.keys())t-e>5e3&&this._pending.delete(e);this._ws.send({type:"clock-ping",t1:t})}_scheduleNext(){this._timeoutId&&clearTimeout(this._timeoutId);const t=this.isStable()?N:w;this._timeoutId=setTimeout(()=>{this._sendPing(),this._scheduleNext()},t)}}const E="oradio.autoplayDismissed";function b(){var l;try{return!!(typeof window<"u"&&((l=window.oradioNative)!=null&&l.isElectron))}catch{return!1}}function L(){if(!b())return!1;try{return localStorage.getItem(E)==="1"}catch{return!1}}let u=null;function T({stationName:l="Radio",subtitle:t="",onStart:e,onCancel:i}={}){if(u)return u.promise;let r,m;const h=new Promise((a,g)=>{r=a,m=g}),o=document.createElement("div");o.className="play-gate-backdrop",o.setAttribute("role","dialog"),o.setAttribute("aria-modal","true"),o.setAttribute("aria-label","Tap to start audio");const c=document.createElement("div");c.className="play-gate-card";const n=document.createElement("h2");n.className="play-gate-title",n.textContent="Tap to start audio",c.appendChild(n);const _=document.createElement("div");if(_.className="play-gate-station",_.textContent=l,c.appendChild(_),t){const a=document.createElement("div");a.className="play-gate-sub",a.textContent=t,c.appendChild(a)}const p=document.createElement("div");p.className="play-gate-row";const f=document.createElement("button");f.className="play-gate-start",f.textContent="▶ Start";const s=document.createElement("button");s.className="play-gate-cancel",s.textContent="Cancel",p.appendChild(f),p.appendChild(s),c.appendChild(p);let d=null;if(b()){const a=document.createElement("label");a.className="play-gate-dismiss",d=document.createElement("input"),d.type="checkbox",d.id="play-gate-dismiss-cb",a.appendChild(d);const g=document.createElement("span");g.textContent=" Don't show again on this device",a.appendChild(g),c.appendChild(a)}o.appendChild(c),document.body.appendChild(o),queueMicrotask(()=>f.focus());function y(){var a;(a=u==null?void 0:u.backdrop)!=null&&a.parentNode&&u.backdrop.parentNode.removeChild(u.backdrop),u=null}function k(){if(d&&d.checked)try{localStorage.setItem(E,"1")}catch{}}return f.addEventListener("click",()=>{k(),y();try{e&&e()}catch{}r()}),s.addEventListener("click",()=>{y();try{i&&i()}catch{}m(new Error("autoplay-cancelled"))}),o.addEventListener("keydown",a=>{a.key==="Escape"&&s.click()}),u={backdrop:o,promise:h},h}export{M as R,L as a,x as c,T as s};

View File

@@ -1 +0,0 @@
function x(l,t={}){let e,i=0,r=!1;function m(){const h=location.protocol==="https:"?"wss":"ws",o=new URLSearchParams;t.room&&o.set("room",t.room),t.kind&&o.set("kind",t.kind);const c=o.toString();e=new WebSocket(`${h}://${location.host}/ws${c?"?"+c:""}`),e.addEventListener("open",()=>{var n;i=0,(n=t.onOpen)==null||n.call(t)}),e.addEventListener("message",n=>{try{l(JSON.parse(n.data))}catch{}}),e.addEventListener("close",()=>{var n;(n=t.onClose)==null||n.call(t),!r&&(i=Math.min(i+1,6),setTimeout(m,500*2**i))}),e.addEventListener("error",()=>e.close())}return m(),{send(h){(e==null?void 0:e.readyState)===WebSocket.OPEN&&e.send(JSON.stringify(h))},close(){r=!0,e==null||e.close()},get readyState(){return e==null?void 0:e.readyState}}}const w=1e3,N=5e3,C=5,I=8,v=16;function S(l){if(!l.length)return 0;const t=l.slice().sort((i,r)=>i-r),e=t.length>>1;return t.length%2?t[e]:(t[e-1]+t[e])/2}class M{constructor(){this.offset=0,this.rtt=1/0,this.offsetStd=1/0,this.samples=[],this.synced=!1,this._pending=new Map,this._listeners=new Set,this._timeoutId=null,this._ws=null}attachWs(t){this._ws=t,this.reset();let e=0;const i=()=>{if(e++>=5){this._scheduleNext();return}this._sendPing(),setTimeout(i,150)};i()}detach(){this._timeoutId&&clearTimeout(this._timeoutId),this._timeoutId=null,this._pending.clear(),this._ws=null}reset(){this.samples=[],this.synced=!1,this.offsetStd=1/0,this._pending.clear(),this._timeoutId&&(clearTimeout(this._timeoutId),this._timeoutId=null)}now(){return Date.now()+this.offset}isStable(){return this.synced&&this.samples.length>=I&&this.offsetStd<=C}onUpdate(t){return this._listeners.add(t),()=>this._listeners.delete(t)}handlePong(t){if(this._pending.get(t.t1)==null)return;this._pending.delete(t.t1);const i=Date.now(),r=i-t.t1,m=t.t2-(t.t1+i)/2;this.samples.push({offset:m,rtt:r}),this.samples.length>v&&this.samples.shift();const h=S(this.samples.map(s=>s.rtt)),o=Math.max(h*2,h+10),c=this.samples.filter(s=>s.rtt<=o),n=c.length?c.map(s=>s.offset):this.samples.map(s=>s.offset),_=S(n),p=n.reduce((s,d)=>s+d,0)/n.length,f=n.reduce((s,d)=>s+(d-p)**2,0)/n.length;this.offsetStd=Math.sqrt(f),this.offset=_,this.rtt=r,this.synced=!0;for(const s of this._listeners)s({offset:this.offset,rtt:this.rtt,offsetStd:this.offsetStd,samples:this.samples.length,accepted:c.length,stable:this.isStable()})}_sendPing(){if(!this._ws)return;const t=Date.now();this._pending.set(t,t);for(const e of this._pending.keys())t-e>5e3&&this._pending.delete(e);this._ws.send({type:"clock-ping",t1:t})}_scheduleNext(){this._timeoutId&&clearTimeout(this._timeoutId);const t=this.isStable()?N:w;this._timeoutId=setTimeout(()=>{this._sendPing(),this._scheduleNext()},t)}}const E="oradio.autoplayDismissed";function b(){var l;try{return!!(typeof window<"u"&&((l=window.oradioNative)!=null&&l.isElectron))}catch{return!1}}function L(){if(!b())return!1;try{return localStorage.getItem(E)==="1"}catch{return!1}}let u=null;function T({stationName:l="Radio",subtitle:t="",onStart:e,onCancel:i}={}){if(u)return u.promise;let r,m;const h=new Promise((a,g)=>{r=a,m=g}),o=document.createElement("div");o.className="play-gate-backdrop",o.setAttribute("role","dialog"),o.setAttribute("aria-modal","true"),o.setAttribute("aria-label","Tap to start audio");const c=document.createElement("div");c.className="play-gate-card";const n=document.createElement("h2");n.className="play-gate-title",n.textContent="Tap to start audio",c.appendChild(n);const _=document.createElement("div");if(_.className="play-gate-station",_.textContent=l,c.appendChild(_),t){const a=document.createElement("div");a.className="play-gate-sub",a.textContent=t,c.appendChild(a)}const p=document.createElement("div");p.className="play-gate-row";const f=document.createElement("button");f.className="play-gate-start",f.textContent="▶ Start";const s=document.createElement("button");s.className="play-gate-cancel",s.textContent="Cancel",p.appendChild(f),p.appendChild(s),c.appendChild(p);let d=null;if(b()){const a=document.createElement("label");a.className="play-gate-dismiss",d=document.createElement("input"),d.type="checkbox",d.id="play-gate-dismiss-cb",a.appendChild(d);const g=document.createElement("span");g.textContent=" Don't show again on this device",a.appendChild(g),c.appendChild(a)}o.appendChild(c),document.body.appendChild(o),queueMicrotask(()=>f.focus());function y(){var a;(a=u==null?void 0:u.backdrop)!=null&&a.parentNode&&u.backdrop.parentNode.removeChild(u.backdrop),u=null}function k(){if(d&&d.checked)try{localStorage.setItem(E,"1")}catch{}}return f.addEventListener("click",()=>{k(),y();try{e&&e()}catch{}r()}),s.addEventListener("click",()=>{y();try{i&&i()}catch{}m(new Error("autoplay-cancelled"))}),o.addEventListener("keydown",a=>{a.key==="Escape"&&s.click()}),u={backdrop:o,promise:h},h}export{M as R,L as a,x as c,T as s};

View File

@@ -5,10 +5,10 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" /> <meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title> <title>Radio Kiosk</title>
<script type="module" crossorigin src="/assets/kiosk-Cc8AKKkv.js"></script> <script type="module" crossorigin src="/assets/kiosk-Cc8AKKkv.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js"> <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/debug-DBzSAgZo.js"> <link rel="modulepreload" crossorigin href="/assets/debug-B-FoNBZ5.js">
<link rel="modulepreload" crossorigin href="/assets/playGate-BFH7IScH.js"> <link rel="modulepreload" crossorigin href="/assets/playGate-BFH7IScH.js">
<link rel="stylesheet" crossorigin href="/assets/kiosk-DuoYH-tL.css"> <link rel="stylesheet" crossorigin href="/assets/kiosk-DuoYH-tL.css">
</head> </head>

View File

@@ -5,10 +5,10 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Master</title> <title>Radio Master</title>
<script type="module" crossorigin src="/assets/master-Bb7HGec-.js"></script> <script type="module" crossorigin src="/assets/master-Bb7HGec-.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js"> <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/debug-DBzSAgZo.js"> <link rel="modulepreload" crossorigin href="/assets/debug-B-FoNBZ5.js">
<link rel="modulepreload" crossorigin href="/assets/playGate-BFH7IScH.js"> <link rel="modulepreload" crossorigin href="/assets/playGate-BFH7IScH.js">
<link rel="stylesheet" crossorigin href="/assets/master-B8Vyo4--.css"> <link rel="stylesheet" crossorigin href="/assets/master-B8Vyo4--.css">
</head> </head>

View File

@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import express from 'express'; import express from 'express';
import { requireAdmin } from '../auth.js'; import { requireAdmin, createApiKey, listApiKeys, revokeApiKey } from '../auth.js';
import { runHealthCheck } from '../streams/checker.js'; import { runHealthCheck } from '../streams/checker.js';
import { probeStream } from '../streams/probe.js'; import { probeStream } from '../streams/probe.js';
import { applySeedIfEmpty } from '../sources/seed.js'; import { applySeedIfEmpty } from '../sources/seed.js';
@@ -19,6 +19,25 @@ import { broadcastGlobal } from '../ws.js';
export const router = Router(); export const router = Router();
router.use(requireAdmin); router.use(requireAdmin);
// --- API key management ---
router.get('/api-keys', (_req, res) => {
res.json(listApiKeys());
});
router.post('/api-keys', (req, res) => {
const label = String(req.body?.label || '').trim();
const userId = Number(req.body?.userId) || req.user.id;
const key = createApiKey(userId, label);
res.status(201).json({ key }); // plaintext key shown exactly once
});
router.delete('/api-keys/:id', (req, res) => {
revokeApiKey(Number(req.params.id));
res.json({ ok: true });
});
// Raw body parser used only by the image upload route. The global JSON // Raw body parser used only by the image upload route. The global JSON
// parser is mounted before us so we have to opt-out for `image/*`. // parser is mounted before us so we have to opt-out for `image/*`.
const rawImageBody = express.raw({ type: ['image/*', 'application/octet-stream'], limit: '5mb' }); const rawImageBody = express.raw({ type: ['image/*', 'application/octet-stream'], limit: '5mb' });

View File

@@ -117,7 +117,7 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' }); if (resolved.format === 'hls') return res.status(415).json({ error: 'hls not proxied' });
const controller = new AbortController(); const controller = new AbortController();
req.on('close', () => controller.abort()); req.on('close', () => { try { controller.abort(); } catch { } });
let upstream; let upstream;
try { try {
@@ -127,7 +127,9 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' } headers: { 'User-Agent': 'oradio-kiosk/1.0', 'Icy-MetaData': '0' }
}); });
} catch (err) { } catch (err) {
return res.status(502).json({ error: `upstream: ${err.message || err}` }); if (err.name === 'AbortError') { res.end(); return; }
if (!res.headersSent) res.status(502).json({ error: `upstream: ${err.message || err}` });
return;
} }
if (!upstream.ok || !upstream.body) { if (!upstream.ok || !upstream.body) {
return res.status(502).json({ error: `upstream HTTP ${upstream.status}` }); return res.status(502).json({ error: `upstream HTTP ${upstream.status}` });
@@ -141,7 +143,11 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
res.set('Access-Control-Expose-Headers', 'Content-Type'); res.set('Access-Control-Expose-Headers', 'Content-Type');
// Pipe the WHATWG ReadableStream into the Express response. // Pipe the WHATWG ReadableStream into the Express response.
// We cancel the reader directly on client-close — equivalent to aborting
// the fetch but without the AbortController rejection that escapes the
// async route in older Node/Electron versions.
const reader = upstream.body.getReader(); const reader = upstream.body.getReader();
req.on('close', () => { reader.cancel().catch(() => {}); });
const pump = async () => { const pump = async () => {
try { try {
while (true) { while (true) {
@@ -151,13 +157,13 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
await new Promise((r) => res.once('drain', r)); await new Promise((r) => res.once('drain', r));
} }
} }
} catch { /* client disconnect or upstream abort */ } } catch { /* client disconnect or upstream error */ }
finally { finally {
try { reader.cancel(); } catch { } try { reader.cancel(); } catch { }
res.end(); try { res.end(); } catch { }
} }
}; };
pump(); pump().catch(() => {});
}); });
function guessContentType(format) { function guessContentType(format) {

View File

@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { initDb } from './db/index.js'; import { initDb } from './db/index.js';
import { authMiddleware, ensureBootstrapAdmin, ensureMainUser } from './auth.js'; import { authMiddleware, ensureBootstrapAdmin, ensureMainUser, ensureBootstrapApiKey } from './auth.js';
import { applySeedIfEmpty } from './sources/seed.js'; import { applySeedIfEmpty } from './sources/seed.js';
import { scheduleHealthCheck } from './streams/checker.js'; import { scheduleHealthCheck } from './streams/checker.js';
import { attachWs } from './ws.js'; import { attachWs } from './ws.js';
@@ -39,6 +39,7 @@ export async function startServer(opts = {}) {
password: process.env.ADMIN_BOOTSTRAP_PASSWORD password: process.env.ADMIN_BOOTSTRAP_PASSWORD
}); });
ensureMainUser(process.env.MAIN_USER || 'morphix'); ensureMainUser(process.env.MAIN_USER || 'morphix');
ensureBootstrapApiKey(process.env.ORADIO_API_KEY);
const seedResult = applySeedIfEmpty(); const seedResult = applySeedIfEmpty();
console.log('[seed]', seedResult); console.log('[seed]', seedResult);
ensureImageDirs(); ensureImageDirs();

View File

@@ -27,6 +27,7 @@ import {
getRoomState, setRoomState getRoomState, setRoomState
} from './rooms.js'; } from './rooms.js';
import { getStation } from './stations.js'; import { getStation } from './stations.js';
import { notifyCompanion } from './companion.js';
const DEBUG = !!process.env.ORADIO_DEBUG_SYNC; const DEBUG = !!process.env.ORADIO_DEBUG_SYNC;
function dlog(...args) { if (DEBUG) console.log('[ws]', ...args); } function dlog(...args) { if (DEBUG) console.log('[ws]', ...args); }
@@ -124,6 +125,7 @@ export function dispatchRoomCommand(room, msg, { except = null } = {}) {
if (msg.action === 'play' && Number.isFinite(msg.stationId)) { if (msg.action === 'play' && Number.isFinite(msg.stationId)) {
const newStation = Number(msg.stationId); const newStation = Number(msg.stationId);
notifyCompanion(newStation);
const sameAndPlaying = cur.playing && cur.station_id === newStation && cur.started_at; const sameAndPlaying = cur.playing && cur.station_id === newStation && cur.started_at;
const patch = { station_id: newStation, playing: true }; const patch = { station_id: newStation, playing: true };
if (!displayPresent) { if (!displayPresent) {
@@ -134,8 +136,10 @@ export function dispatchRoomCommand(room, msg, { except = null } = {}) {
setRoomState(room.id, patch); setRoomState(room.id, patch);
} else if (msg.action === 'pause') { } else if (msg.action === 'pause') {
setRoomState(room.id, { playing: false }); setRoomState(room.id, { playing: false });
notifyCompanion(null);
} else if (msg.action === 'stop') { } else if (msg.action === 'stop') {
setRoomState(room.id, { playing: false, started_at: null }); setRoomState(room.id, { playing: false, started_at: null });
notifyCompanion(null);
} else if (msg.action === 'volume' && typeof msg.value === 'number') { } else if (msg.action === 'volume' && typeof msg.value === 'number') {
setRoomState(room.id, { volume: Math.max(0, Math.min(1, msg.value)) }); setRoomState(room.id, { volume: Math.max(0, Math.min(1, msg.value)) });
} else { } else {
@@ -291,6 +295,10 @@ function handleClientMessage(ws, msg) {
const next = setRoomState(ws.room.id, patch); const next = setRoomState(ws.room.id, patch);
// Station change invalidates any cached master position. // Station change invalidates any cached master position.
if (prev.station_id !== next.station_id) lastSyncPos.delete(slug); if (prev.station_id !== next.station_id) lastSyncPos.delete(slug);
// Notify Companion whenever the effective now-playing state changes.
if (prev.station_id !== next.station_id || prev.playing !== next.playing) {
notifyCompanion(next.playing && next.station_id ? next.station_id : null);
}
const station = next.station_id ? getStation(next.station_id) : null; const station = next.station_id ? getStation(next.station_id) : null;
broadcastToRoom(slug, { type: 'state', ...next, station, server_now: Date.now() }); broadcastToRoom(slug, { type: 'state', ...next, station, server_now: Date.now() });
return; return;

View File

@@ -43,11 +43,15 @@ function readLastStation() {
} catch { /* ignore */ } } catch { /* ignore */ }
return null; return null;
} }
function writeLastStation(station) { // Persist the last station along with whether it was playing at the time.
// On reload we only auto-resume audio when `playing` was true — a user who
// hit Stop explicitly should not be ambushed by the stream restarting.
function writeLastStation(station, { playing = true } = {}) {
if (!station || !Number.isFinite(station.id)) return; if (!station || !Number.isFinite(station.id)) return;
try { try {
localStorage.setItem('oradio.lastStation', JSON.stringify({ localStorage.setItem('oradio.lastStation', JSON.stringify({
id: station.id, name: station.name, genres: station.genres || [] id: station.id, name: station.name, genres: station.genres || [],
playing: !!playing
})); }));
} catch { /* private mode */ } } catch { /* private mode */ }
} }
@@ -175,6 +179,22 @@ function sendBufferCommandSoon(ms) {
ws?.send({ type: 'command', action: 'setSyncBuffer', value }); ws?.send({ type: 'command', action: 'setSyncBuffer', value });
}, 150); }, 150);
} }
// Single entry point for transport commands (play/pause/stop/etc). Returns
// false when the WS isn't OPEN — callers use the return value to roll back
// optimistic state so a transient disconnect is visible to the user instead
// of silently dropping the click.
function sendCommand(payload) {
const ok = !!ws?.send({ type: 'command', ...payload });
if (!ok) {
// Roll back any optimistic UI flip — the command never reached the
// server, so the room state we'd be predicting will not arrive.
clearPendingCommand();
state.player = { ...state.player, error: 'Disconnected — reconnecting…' };
render();
}
return ok;
}
// Apply restored local volume immediately so the very first stream honours it. // Apply restored local volume immediately so the very first stream honours it.
player.setLocalVolume(readLocalVolume()); player.setLocalVolume(readLocalVolume());
player.onSyncChange = (info) => { player.onSyncChange = (info) => {
@@ -251,8 +271,11 @@ async function bootstrap() {
mountDebugPane({ player, clock, getWs: () => ws, role: 'kiosk' }); mountDebugPane({ player, clock, getWs: () => ws, role: 'kiosk' });
render(); render();
requestWakeLock(); requestWakeLock();
// Solo restore: try to resume the last station. Use the autoplay gate so // Solo restore: try to resume the last station. Only auto-resume audio
// a blocked browser shows the click-to-start modal. // when the last-known state was `playing: true` — a user who hit Stop
// and reloaded should see the station selected but silent. If the
// playing flag is missing (older localStorage entries) we treat it as
// true for backwards compatibility on first reload after upgrading.
if (state.mode === 'solo') { if (state.mode === 'solo') {
const last = readLastStation(); const last = readLastStation();
if (last && Number.isFinite(last.id)) { if (last && Number.isFinite(last.id)) {
@@ -260,7 +283,25 @@ async function bootstrap() {
const full = state.favorites.find((s) => s.id === last.id) const full = state.favorites.find((s) => s.id === last.id)
|| state.stations.find((s) => s.id === last.id) || state.stations.find((s) => s.id === last.id)
|| await api.get(`/api/stations/${last.id}`).catch(() => null); || await api.get(`/api/stations/${last.id}`).catch(() => null);
if (full) await soloResumeStation(full); if (full) {
const wasPlaying = last.playing !== false;
if (wasPlaying) {
await soloResumeStation(full);
} else {
// Adopt metadata so the card shows the station as
// selected, but do not start audio.
state.player = {
...state.player,
stationId: full.id,
stationName: full.name,
genres: full.genres || [],
playing: false,
loading: false,
error: null
};
render();
}
}
} catch { /* ignore */ } } catch { /* ignore */ }
} }
} }
@@ -503,10 +544,17 @@ function handleWs(msg) {
case 'state': case 'state':
state.roomState = { ...state.roomState, ...msg }; state.roomState = { ...state.roomState, ...msg };
logSync('state', msg); logSync('state', msg);
// A real state echo from the server is the convergence point — // The server emits a synthetic `state` immediately after handling
// drop any optimistic pending command and let the canonical // any command (with `started_at: null` while a display is present)
// state drive the buttons. // BEFORE the master has actually anchored on the new station. Only
clearPendingCommand(); // clear the optimistic pending command when this is a truly
// authoritative echo: the master has anchored (`started_at` set)
// OR the room moved to stopped/paused. Otherwise the kiosk would
// briefly snap back to a stale "loading" view, then snap forward
// again 100-500 ms later when the master actually starts.
if (msg.started_at || msg.playing === false || !msg.station_id) {
clearPendingCommand();
}
if (state.mode === 'synced') applyRoomStateToUI(); if (state.mode === 'synced') applyRoomStateToUI();
// Update the sync anchor if the server is telling us one. A null // Update the sync anchor if the server is telling us one. A null
// `started_at` just means the master hasn't (re-)anchored yet // `started_at` just means the master hasn't (re-)anchored yet
@@ -836,8 +884,9 @@ function render() {
markGesture(); markGesture();
if (state.mode === 'synced' && !state.syncedAudio) { if (state.mode === 'synced' && !state.syncedAudio) {
const target = view.stationId || state.favorites[0]?.id; const target = view.stationId || state.favorites[0]?.id;
if (!target) return;
setPendingCommand({ stationId: target, playing: !view.playing }); setPendingCommand({ stationId: target, playing: !view.playing });
ws?.send({ type: 'command', action: view.playing ? 'pause' : 'play', stationId: target }); sendCommand({ action: view.playing ? 'pause' : 'play', stationId: target });
render(); render();
return; return;
} }
@@ -854,11 +903,23 @@ function render() {
if (view.stationId) { if (view.stationId) {
const wasPlaying = view.playing; const wasPlaying = view.playing;
if (state.mode === 'synced' && state.syncedAudio) { if (state.mode === 'synced' && state.syncedAudio) {
// Synced+audio: send command first; only flip local
// audio after the WS send actually succeeded. The
// canonical `state` echo will mirror back to the
// local <audio> via the state handler so both ends
// stay in lockstep.
setPendingCommand({ stationId: view.stationId, playing: !wasPlaying }); setPendingCommand({ stationId: view.stationId, playing: !wasPlaying });
} const ok = sendCommand({ action: wasPlaying ? 'pause' : 'play', stationId: view.stationId });
player.togglePause(); if (!ok) return;
if (broadcasts()) { player.togglePause();
ws?.send({ type: 'command', action: wasPlaying ? 'pause' : 'play', stationId: view.stationId }); } else {
// Solo: local only.
player.togglePause();
// Persist new playing state so reload honours it.
const last = readLastStation();
if (last && last.id === view.stationId) {
writeLastStation({ id: last.id, name: last.name, genres: last.genres }, { playing: !wasPlaying });
}
} }
} else if (state.favorites[0]) { } else if (state.favorites[0]) {
playStation(state.favorites[0]); playStation(state.favorites[0]);
@@ -874,15 +935,26 @@ function render() {
markGesture(); markGesture();
if (state.mode === 'synced' && !state.syncedAudio) { if (state.mode === 'synced' && !state.syncedAudio) {
setPendingCommand({ stationId: null, playing: false }); setPendingCommand({ stationId: null, playing: false });
ws?.send({ type: 'command', action: 'stop' }); sendCommand({ action: 'stop' });
render(); render();
} else { return;
if (state.mode === 'synced' && state.syncedAudio) { }
setPendingCommand({ stationId: null, playing: false }); if (state.mode === 'synced' && state.syncedAudio) {
} setPendingCommand({ stationId: null, playing: false });
const ok = sendCommand({ action: 'stop' });
if (!ok) return;
player.stop(); player.stop();
endCurrentSession(); endCurrentSession();
if (broadcasts()) ws?.send({ type: 'command', action: 'stop' }); return;
}
// Solo: local only. Mark last-station as not playing so
// the next reload doesn't surprise the user with audio.
const stopping = view.stationId;
player.stop();
endCurrentSession();
const last = readLastStation();
if (last && last.id === stopping) {
writeLastStation({ id: last.id, name: last.name, genres: last.genres }, { playing: false });
} }
} }
}, '■'), }, '■'),
@@ -1215,13 +1287,24 @@ async function playStation(station) {
// Close whatever was playing before; the upcoming POST opens a fresh row. // Close whatever was playing before; the upcoming POST opens a fresh row.
endCurrentSession(); endCurrentSession();
// Remember this as the last station for Solo restore-on-reload. // Remember this as the last station for Solo restore-on-reload.
if (state.mode === 'solo') writeLastStation(station); if (state.mode === 'solo') writeLastStation(station, { playing: true });
if (state.mode === 'synced' && !state.syncedAudio) { if (state.mode === 'synced') {
// Don't touch local audio — ask the room's display to play and let // Synced mode (audio on OR off): the room is the source of truth.
// the resulting `state` message update our UI. // Send the command, optimistically flip the card, and let the
// resulting `state` echo + autoJoinRoomPlayback drive any local
// audio start (when syncedAudio is on). Single code path = no
// race between the optimistic local player.play and the join
// triggered by the state echo.
setPendingCommand({ stationId: station.id, playing: true }); setPendingCommand({ stationId: station.id, playing: true });
ws?.send({ type: 'command', action: 'play', stationId: station.id }); const ok = sendCommand({ action: 'play', stationId: station.id });
// Optimistically reflect locally so the card highlights immediately. if (!ok) return;
// Optimistically flip the card to the new station name immediately.
// Do NOT set loading=true here — joinStationSilently uses
// (loading && stationId===id) as its re-entry guard, and we want
// the state-echo-driven autoJoinRoomPlayback to actually start the
// local <audio>. The "On air — local audio loading…" sub-line
// covers the brief window where view.playing is true but the local
// audio hasn't begun yet.
state.player = { state.player = {
...state.player, ...state.player,
stationId: station.id, stationId: station.id,
@@ -1232,7 +1315,9 @@ async function playStation(station) {
error: null error: null
}; };
render(); render();
// No local audio means no local session — the master records its own. // No local audio = no local session — the master records its own.
// When syncedAudio is on the local session will be opened by
// joinStationSilently once autoJoin kicks in.
try { try {
const stats = await api.get(`/api/stations/${station.id}/votes`, ac ? { signal: ac.signal } : undefined); const stats = await api.get(`/api/stations/${station.id}/votes`, ac ? { signal: ac.signal } : undefined);
if (state.player.stationId === station.id) { if (state.player.stationId === station.id) {
@@ -1244,9 +1329,9 @@ async function playStation(station) {
return; return;
} }
// Solo path: drive local audio directly.
player.play(station); player.play(station);
recordHistory(station.id); recordHistory(station.id);
if (broadcasts()) ws?.send({ type: 'command', action: 'play', stationId: station.id });
try { try {
const stats = await api.post(`/api/stations/${station.id}/play`, null, ac ? { signal: ac.signal } : undefined); const stats = await api.post(`/api/stations/${station.id}/play`, null, ac ? { signal: ac.signal } : undefined);
// Only apply if user hasn't switched stations in the meantime. // Only apply if user hasn't switched stations in the meantime.

View File

@@ -272,11 +272,20 @@ function handleWs(msg) {
if (msg.you?.kind && msg.you.kind !== 'display') { if (msg.you?.kind && msg.you.kind !== 'display') {
state.np.error = `This room already has a display (${countDisplays(msg.peers)} active). You were joined as ${msg.you.kind}.`; state.np.error = `This room already has a display (${countDisplays(msg.peers)} active). You were joined as ${msg.you.kind}.`;
} }
// Resume room state when (re-)connecting: play whatever the room // Resume room state when (re-)connecting: only auto-start audio
// thinks is current, unless we're already on it. // if the room was actually playing — a user who hit Stop and
// reloaded should NOT get audio back. We still adopt the station
// metadata for the card so the UI shows what's "selected".
const rs = msg.state; const rs = msg.state;
if (rs?.station_id && rs.station && rs.station_id !== state.np.stationId) { if (rs?.station_id && rs.station && rs.station_id !== state.np.stationId) {
playStation(rs.station, { silent: true }); if (rs.playing) {
playStation(rs.station, { silent: true });
} else {
state.np.station = rs.station;
state.np.stationId = rs.station_id;
state.np.playing = false;
state.np.loading = false;
}
} }
if (typeof rs?.volume === 'number') { if (typeof rs?.volume === 'number') {
player.setVolume(rs.volume); player.setVolume(rs.volume);
@@ -321,12 +330,12 @@ function handleCommand(msg) {
case 'play': { case 'play': {
const id = Number(msg.stationId); const id = Number(msg.stationId);
if (!Number.isFinite(id)) return; if (!Number.isFinite(id)) return;
// Idempotency: ignore a play for the station already current OR // Idempotency: only skip if we're already on this station AND
// already pending. Without these guards a flood of commands (e.g. // audio is actually playing or loading it. If a previous play
// multiple kiosks racing, or repeated clicks) would call // attempt failed (gesture refused, resolve error), state.np was
// playStation() in a loop, each one doing stop()+play() and // cleared by clearNowPlaying() so this guard correctly lets the
// producing audible start/stop thrashing. // retry through.
if (id === state.np.stationId) return; if (id === state.np.stationId && (state.np.playing || state.np.loading)) return;
if (id === _pendingStationId) return; if (id === _pendingStationId) return;
_pendingStationId = id; _pendingStationId = id;
const gen = ++_cmdGen; const gen = ++_cmdGen;
@@ -345,11 +354,16 @@ function handleCommand(msg) {
if (state.np.playing) player.togglePause(); if (state.np.playing) player.togglePause();
return; return;
case 'stop': case 'stop':
if (!state.np.stationId) return; // Always idempotent: even if local state thinks nothing is
player.stop(); // playing, the command may be a retry from a kiosk that saw a
// stale "playing" — stop unconditionally so the room converges.
try { player.stop(); } catch { /* ignore */ }
endCurrentSession(); endCurrentSession();
state.np.playing = false; state.np.playing = false;
state.np.stationId = null; state.np.stationId = null;
state.np.station = null;
state.np.loading = false;
_pendingStationId = null;
sendState(); sendState();
render(); render();
return; return;
@@ -388,6 +402,8 @@ async function playStation(station, { silent } = {}) {
endCurrentSession(); endCurrentSession();
state.np.station = station; state.np.station = station;
state.np.stationId = station.id; state.np.stationId = station.id;
state.np.loading = true;
state.np.error = null;
state.voteStats = { state.voteStats = {
up: station.up || 0, down: station.down || 0, up: station.up || 0, down: station.down || 0,
plays: station.plays || 0, score: station.score || 0 plays: station.plays || 0, score: station.score || 0
@@ -397,8 +413,21 @@ async function playStation(station, { silent } = {}) {
// would otherwise call audio.play() without one and iOS/Chromium // would otherwise call audio.play() without one and iOS/Chromium
// would refuse. ensureGesture short-circuits once unlocked. // would refuse. ensureGesture short-circuits once unlocked.
const ok = await ensureGesture(station.name, silent ? 'Tap Start to resume the group audio.' : 'Tap Start to play.'); const ok = await ensureGesture(station.name, silent ? 'Tap Start to resume the group audio.' : 'Tap Start to play.');
if (!ok) return; if (!ok) {
await player.play(station); // Gesture refused: don't leave state pointing at a station we never
// actually played, otherwise the dedupe in handleCommand('play')
// would wedge — every future command for this id would be skipped.
// Only clear if we're still the active station; a newer playStation
// may have superseded us during the modal await.
if (state.np.stationId === station.id) clearNowPlaying();
return;
}
try {
await player.play(station);
} catch {
if (state.np.stationId === station.id) clearNowPlaying();
return;
}
if (player.audio.paused) { if (player.audio.paused) {
// Gesture confirmed but browser hasn't started yet — try once more. // Gesture confirmed but browser hasn't started yet — try once more.
player.audio.play().catch(() => { }); player.audio.play().catch(() => { });
@@ -421,6 +450,21 @@ async function playStation(station, { silent } = {}) {
} }
} }
// Wipe local now-playing state and broadcast the cleared state to the room.
// Used when a play attempt fails (gesture refused, resolve error) so the
// dedupe in handleCommand doesn't trap us pointing at a station we never
// actually played.
function clearNowPlaying() {
state.np.stationId = null;
state.np.station = null;
state.np.playing = false;
state.np.loading = false;
state.voteStats = null;
try { player?.stop(); } catch { /* ignore */ }
sendState();
render();
}
// Close whichever session is currently open. Idempotent. // Close whichever session is currently open. Idempotent.
function endCurrentSession({ beacon = false } = {}) { function endCurrentSession({ beacon = false } = {}) {
const s = state.session; const s = state.session;

View File

@@ -111,6 +111,38 @@ export class Player {
}; };
this.onSyncChange = null; // optional sync status listener this.onSyncChange = null; // optional sync status listener
this.audio.addEventListener('playing', () => { this.audio.addEventListener('playing', () => {
// Restore output gain in case a prior pause/stop muted the
// post-DelayNode GainNode for instant silence.
if (this._outputMuted) {
// When sync is active the delay node was reset to 0 in stop().
// Keep the gain muted while the delay line pre-fills with
// bufferMs of the new station's audio, then atomically snap
// the delay to bufferMs and unmute. This gives clean silence
// during the buffer fill instead of the old "playing empty"
// sensation (gain open but only zeros coming through the delay).
if (this.sync.enabled && this.audio[DELAY_KEY] && this.sync.bufferMs > 0) {
clearTimeout(this._delayPrefillTimer);
const targetMs = this.sync.bufferMs;
this._delayPrefillTimer = setTimeout(() => {
this._delayPrefillTimer = null;
if (this.audio.paused || !this.sync.enabled) return;
const d = this.audio[DELAY_KEY];
const c = this.audio[CTX_KEY];
if (d && c) {
try {
d.delayTime.cancelScheduledValues(c.currentTime);
d.delayTime.value = targetMs / 1000;
} catch { /* ignore */ }
}
this.sync.currentDelay = targetMs / 1000;
this._setOutputMuted(false);
}, targetMs);
} else {
// Solo mode or no delay graph — unmute immediately so
// audio kicks in the moment the first sample is decoded.
this._setOutputMuted(false);
}
}
// First decoded sample after a fresh load — anchor the drift // First decoded sample after a fresh load — anchor the drift
// reference. From now on we expect audio.currentTime to track // reference. From now on we expect audio.currentTime to track
// (clock.now() - t0Wall) / 1000 + t0Audio. Any divergence is // (clock.now() - t0Wall) / 1000 + t0Audio. Any divergence is
@@ -174,13 +206,18 @@ export class Player {
if (gain && ctx) { if (gain && ctx) {
// Route via the post-DelayNode GainNode so the change is audible // Route via the post-DelayNode GainNode so the change is audible
// immediately, not queued behind `bufferMs` of buffered audio. // immediately, not queued behind `bufferMs` of buffered audio.
try { // When the output is currently muted (pause/stop) skip the
const t = ctx.currentTime; // hardware-gain write — _setOutputMuted owns the gain in that
gain.gain.cancelScheduledValues(t); // state and would otherwise un-mute the speaker mid-pause.
gain.gain.setValueAtTime(gain.gain.value, t); if (!this._outputMuted) {
gain.gain.linearRampToValueAtTime(clamped, t + 0.03); try {
} catch { const t = ctx.currentTime;
gain.gain.value = clamped; gain.gain.cancelScheduledValues(t);
gain.gain.setValueAtTime(gain.gain.value, t);
gain.gain.linearRampToValueAtTime(clamped, t + 0.03);
} catch {
gain.gain.value = clamped;
}
} }
// Audio element is pinned to 1 once the graph exists — its volume // Audio element is pinned to 1 once the graph exists — its volume
// is applied pre-delay and would compound with the GainNode. // is applied pre-delay and would compound with the GainNode.
@@ -191,6 +228,29 @@ export class Player {
this.emit({}); this.emit({});
} }
// Cut (or restore) the post-DelayNode gain. Used by pause/stop so the
// user gets instant silence instead of waiting for the DelayNode's
// buffered samples (up to bufferMs) to drain out the speaker. Cheap
// no-op when the WebAudio graph hasn't been built (solo / no-sync
// path — there the audio element's own pause IS instant).
_setOutputMuted(muted) {
this._outputMuted = !!muted;
const gain = this.audio[GAIN_KEY];
const ctx = this.audio[CTX_KEY];
if (!gain || !ctx) return;
const target = muted ? 0 : (this._logicalVolume ?? 1);
try {
const t = ctx.currentTime;
gain.gain.cancelScheduledValues(t);
gain.gain.setValueAtTime(gain.gain.value, t);
// 30 ms ramp matches setVolume — feels instant, avoids the
// click that an abrupt setValueAtTime can produce.
gain.gain.linearRampToValueAtTime(target, t + 0.03);
} catch {
gain.gain.value = target;
}
}
/** Alias kept for clarity: local <audio>.volume only; never broadcasts. */ /** Alias kept for clarity: local <audio>.volume only; never broadcasts. */
setLocalVolume(v) { this.setVolume(v); } setLocalVolume(v) { this.setVolume(v); }
@@ -224,17 +284,60 @@ export class Player {
// Invalidate any in-flight play() continuation immediately. // Invalidate any in-flight play() continuation immediately.
this._playGen++; this._playGen++;
if (this._playAbort) { try { this._playAbort.abort(); } catch { /* ignore */ } this._playAbort = null; } if (this._playAbort) { try { this._playAbort.abort(); } catch { /* ignore */ } this._playAbort = null; }
// Cancel any pending delay-prefill unmute so a timer from a previous
// station doesn't fire and unmute mid-switch.
clearTimeout(this._delayPrefillTimer);
this._delayPrefillTimer = null;
// Hard-cut the output gain immediately. We bypass the 30 ms ramp that
// _setOutputMuted uses (appropriate for pause/resume to avoid pops)
// and go straight to zero — we're about to suspend the AudioContext
// anyway, so the ramp would just be cancelled a quantum later.
this._outputMuted = true;
const gain = this.audio[GAIN_KEY];
const ctx = this.audio[CTX_KEY];
if (gain && ctx) {
try {
gain.gain.cancelScheduledValues(ctx.currentTime);
gain.gain.setValueAtTime(0, ctx.currentTime);
} catch { gain.gain.value = 0; }
} else if (gain) {
gain.gain.value = 0;
}
this.audio.pause(); this.audio.pause();
this.audio.removeAttribute('src'); this.audio.removeAttribute('src');
this.audio.load(); this.audio.load();
if (this.hls) { this.hls.destroy(); this.hls = null; } if (this.hls) { this.hls.destroy(); this.hls = null; }
// Reset the DelayNode to 0 so residual buffered audio doesn't linger.
const delay = this.audio[DELAY_KEY];
if (delay && ctx) {
try {
delay.delayTime.cancelScheduledValues(ctx.currentTime);
delay.delayTime.value = 0;
} catch { /* ignore */ }
}
// Suspend the AudioContext: immediately freezes all WebAudio rendering
// (delay drain, analyser updates, etc.). play() / _ensureAudioGraph()
// will resume it when the next station starts.
if (ctx) { try { ctx.suspend().catch(() => { }); } catch { /* ignore */ } }
this._resetSyncAnchor(); this._resetSyncAnchor();
} }
togglePause() { togglePause() {
if (!this.station) return; if (!this.station) return;
if (this.audio.paused) this.audio.play().catch(() => { }); if (this.audio.paused) {
else this.audio.pause(); // Un-mute first so the speaker is live when the new samples
// begin to flow through the DelayNode. (No-op if no graph.)
this._setOutputMuted(false);
this.audio.play().catch(() => { });
} else {
// Mute the post-DelayNode gain BEFORE pausing so the speaker
// goes silent immediately. Without this, the DelayNode keeps
// draining its buffered samples (up to bufferMs) out the
// speaker after audio.pause() — what the user perceives as
// "the stream kept playing after I clicked pause".
this._setOutputMuted(true);
this.audio.pause();
}
} }
async play(station) { async play(station) {
@@ -253,14 +356,20 @@ export class Player {
// station name immediately. Otherwise on slow phones the old // station name immediately. Otherwise on slow phones the old
// name stays visible for the duration of /resolve + connect. // name stays visible for the duration of /resolve + connect.
this.emit({ playing: false, loading: true, error: null }); this.emit({ playing: false, loading: true, error: null });
// Pre-warm AudioContext while we still have the user-gesture // Pre-warm / resume AudioContext while we still have the user-gesture
// credit. iOS will keep it running for the rest of the session. // credit. iOS will keep it running for the rest of the session.
if (this.sync.enabled) { // Also resumes a context that was suspended by stop() — required even
// in solo mode if a WebAudio graph was built during a previous sync
// session (audio is routed through the graph, not the raw element).
{
const Ctor = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext); const Ctor = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext);
if (Ctor) { if (this.sync.enabled && Ctor) {
let ctx = this.audio[CTX_KEY]; let ctx = this.audio[CTX_KEY];
if (!ctx) { try { ctx = new Ctor(); this.audio[CTX_KEY] = ctx; } catch { /* ignore */ } } if (!ctx) { try { ctx = new Ctor(); this.audio[CTX_KEY] = ctx; } catch { /* ignore */ } }
if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => { }); if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => { });
} else {
const existingCtx = this.audio[CTX_KEY];
if (existingCtx && existingCtx.state === 'suspended') existingCtx.resume().catch(() => { });
} }
} }
let resolved, streamMeta; let resolved, streamMeta;
@@ -378,6 +487,13 @@ export class Player {
this.sync.startedAt = null; this.sync.startedAt = null;
this.sync.status = 'off'; this.sync.status = 'off';
if (this.sync.timer) { clearInterval(this.sync.timer); this.sync.timer = null; } if (this.sync.timer) { clearInterval(this.sync.timer); this.sync.timer = null; }
// If a prefill timer was waiting to unmute, cancel it and unmute now
// since we're leaving sync mode.
if (this._delayPrefillTimer) {
clearTimeout(this._delayPrefillTimer);
this._delayPrefillTimer = null;
if (this._outputMuted && !this.audio.paused) this._setOutputMuted(false);
}
try { this.audio.playbackRate = 1.0; } catch { /* ignore */ } try { this.audio.playbackRate = 1.0; } catch { /* ignore */ }
// Drain the DelayNode so we don't keep holding several seconds of // Drain the DelayNode so we don't keep holding several seconds of
// audio when the user switches back to solo mode. // audio when the user switches back to solo mode.
@@ -462,12 +578,10 @@ export class Player {
/** /**
* Build (once per <audio>) the Web Audio chain: * Build (once per <audio>) the Web Audio chain:
* MediaElementSource -> DelayNode -> AnalyserNode -> destination * MediaElementSource -> DelayNode -> GainNode -> AnalyserNode -> destination
* The analyser is placed AFTER the delay so the spectrum it produces * The analyser is placed AFTER the gain so the spectrum it produces
* matches the audio the user actually hears (the speakers emit the * is only active when audio is actually audible — it stays silent
* delayed signal). Consequence: the visualiser is silent for `bufferMs` * during the muted prefill period (gain=0) after a station change.
* after a station change while the delay refills with new audio —
* that's correct, not a bug.
* If the audio is cross-origin without CORS, `createMediaElementSource` * If the audio is cross-origin without CORS, `createMediaElementSource`
* throws and we silently bail — sync degrades to 'no-buffer' in that * throws and we silently bail — sync degrades to 'no-buffer' in that
* environment but plain playback continues. * environment but plain playback continues.
@@ -513,9 +627,9 @@ export class Player {
gain.gain.value = (this._logicalVolume ?? a.volume); gain.gain.value = (this._logicalVolume ?? a.volume);
a.volume = 1; a.volume = 1;
src.connect(delay); src.connect(delay);
delay.connect(analyser); delay.connect(gain);
analyser.connect(gain); gain.connect(analyser);
gain.connect(ctx.destination); analyser.connect(ctx.destination);
a[ANALYSER_KEY] = analyser; a[ANALYSER_KEY] = analyser;
a[DELAY_KEY] = delay; a[DELAY_KEY] = delay;
a[GAIN_KEY] = gain; a[GAIN_KEY] = gain;
@@ -606,7 +720,10 @@ export class Player {
if (graph?.delay) { if (graph?.delay) {
const cur = graph.delay.delayTime.value; const cur = graph.delay.delayTime.value;
if (Math.abs(cur - clamped) > 0.01) { // While the delay-prefill timer is running (station just switched,
// delay was reset to 0), do not ramp the delay back up — the timer
// will snap it to the target value once the line has pre-filled.
if (Math.abs(cur - clamped) > 0.01 && !this._delayPrefillTimer) {
try { try {
const t = graph.ctx.currentTime; const t = graph.ctx.currentTime;
graph.delay.delayTime.cancelScheduledValues(t); graph.delay.delayTime.cancelScheduledValues(t);

View File

@@ -21,7 +21,15 @@ export function connectWs(onMessage, opts = {}) {
} }
open(); open();
return { return {
send(msg) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }, // Returns true when the message was actually queued on a live socket,
// false when the socket is closing/reconnecting. Callers that need to
// surface a transport failure (transport buttons) inspect the result;
// fire-and-forget callers can ignore it.
send(msg) {
if (ws?.readyState !== WebSocket.OPEN) return false;
ws.send(JSON.stringify(msg));
return true;
},
close() { closed = true; ws?.close(); }, close() { closed = true; ws?.close(); },
get readyState() { return ws?.readyState; } get readyState() { return ws?.readyState; }
}; };