feat: add multi-user support for favorites management and room clock synchronization
- Implemented a new API endpoint for retrieving and managing user favorites in /api/users. - Added functionality for admins to edit the shared "main" user's favorites. - Created a one-shot DB smoke test script for verifying multi-user kiosk migrations. - Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
// source; controllers/panels on other devices keep working over the LAN
|
||||
// because the server still binds 0.0.0.0.
|
||||
|
||||
import { app, BrowserWindow, Menu, shell } from 'electron';
|
||||
import { app, BrowserWindow, Menu, session, shell } from 'electron';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
@@ -43,6 +43,43 @@ async function bootServer() {
|
||||
return serverHandle;
|
||||
}
|
||||
|
||||
// Inject permissive CORS headers on cross-origin MEDIA responses so the
|
||||
// renderer's AnalyserNode can read PCM from Icecast/SHOUTcast streams that
|
||||
// otherwise omit Access-Control-Allow-Origin. Without this, the master's
|
||||
// spectrum visualiser sees only zeros even though the audio plays fine.
|
||||
// Scoped to media responses (audio/* content-type or resourceType 'media')
|
||||
// so we don't accidentally weaken non-audio security guarantees.
|
||||
function installMediaCorsRewrite(sess) {
|
||||
sess.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
const headers = details.responseHeaders || {};
|
||||
const ct = (headers['content-type'] || headers['Content-Type'] || [''])[0] || '';
|
||||
const isMedia = details.resourceType === 'media'
|
||||
|| /^audio\/|^application\/(ogg|x-mpegurl|vnd\.apple\.mpegurl)|^video\/mp2t/i.test(ct);
|
||||
if (!isMedia) {
|
||||
callback({ responseHeaders: headers });
|
||||
return;
|
||||
}
|
||||
// Strip any existing variants so case differences don't leave two copies.
|
||||
for (const k of Object.keys(headers)) {
|
||||
if (/^access-control-allow-origin$/i.test(k)) delete headers[k];
|
||||
if (/^access-control-allow-headers$/i.test(k)) delete headers[k];
|
||||
if (/^timing-allow-origin$/i.test(k)) delete headers[k];
|
||||
}
|
||||
headers['Access-Control-Allow-Origin'] = ['*'];
|
||||
headers['Access-Control-Allow-Headers'] = ['*'];
|
||||
headers['Timing-Allow-Origin'] = ['*'];
|
||||
callback({ responseHeaders: headers });
|
||||
});
|
||||
|
||||
// Auto-grant the `media` permission for our own origin so the preload's
|
||||
// one-shot getUserMedia (used to unlock audio-output device labels) does
|
||||
// not prompt the user.
|
||||
sess.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
if (permission === 'media') return callback(true);
|
||||
return callback(false);
|
||||
});
|
||||
}
|
||||
|
||||
async function createMainWindow(port) {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
@@ -52,11 +89,14 @@ async function createMainWindow(port) {
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
sandbox: true,
|
||||
preload: resolve(__dirname, 'preload.cjs')
|
||||
}
|
||||
});
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
installMediaCorsRewrite(mainWindow.webContents.session);
|
||||
|
||||
// Open target/external links in the OS browser instead of inside Electron.
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
|
||||
107
electron/preload.cjs
Normal file
107
electron/preload.cjs
Normal file
@@ -0,0 +1,107 @@
|
||||
// Preload for the Master Electron window.
|
||||
//
|
||||
// Exposes `window.oradioNative` to the renderer via `contextBridge`. This is
|
||||
// the single source of truth for "are we running inside Electron?" — the
|
||||
// master UI uses `oradioNative?.isElectron` to gate features that don't work
|
||||
// in a plain browser tab (real audio-output enumeration/selection, the live
|
||||
// spectrum visualizer's cross-origin PCM tap, etc.).
|
||||
//
|
||||
// Runs in a sandboxed isolated world (sandbox: true, contextIsolation: true),
|
||||
// so it has access to a limited subset of Electron APIs and to the renderer's
|
||||
// `navigator.mediaDevices`. No Node built-ins.
|
||||
//
|
||||
// Written in CommonJS (`.cjs`) on purpose: the repo's package.json sets
|
||||
// `"type": "module"`, and Electron's preload loader expects CJS.
|
||||
|
||||
const { contextBridge } = require('electron');
|
||||
|
||||
// ---- Audio output enumeration / selection ------------------------------
|
||||
|
||||
// Classify a device label into a coarse `kind` so the master UI can show an
|
||||
// appropriate icon. Falls back to 'speakers' for the system default.
|
||||
function classifyKind(label) {
|
||||
const s = String(label || '').toLowerCase();
|
||||
if (/bluetooth|\bbt\b|airpods|buds|stanmore|bose|sonos/.test(s)) return 'bluetooth';
|
||||
if (/hdmi|display|tv|monitor/.test(s)) return 'hdmi';
|
||||
if (/usb|audient|focusrite|scarlett|presonus|motu/.test(s)) return 'usb';
|
||||
if (/headphone|headset|earphone|jack/.test(s)) return 'headphones';
|
||||
return 'speakers';
|
||||
}
|
||||
|
||||
let labelsUnlocked = false;
|
||||
const currentListeners = new Set();
|
||||
let lastCurrentId = 'default';
|
||||
|
||||
async function unlockDeviceLabels() {
|
||||
if (labelsUnlocked) return;
|
||||
// Asking for an audio input stream gives Chromium permission to expose
|
||||
// device LABELS in subsequent enumerateDevices() calls. The Electron main
|
||||
// process auto-grants the `media` permission so this is silent.
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
labelsUnlocked = true;
|
||||
} catch (err) {
|
||||
// Permission denied or no input device — labels will be empty but we
|
||||
// can still surface the IDs (and the system default).
|
||||
console.warn('[preload] could not unlock device labels:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async function listOutputs() {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return [];
|
||||
await unlockDeviceLabels();
|
||||
const all = await navigator.mediaDevices.enumerateDevices();
|
||||
const out = all
|
||||
.filter((d) => d.kind === 'audiooutput')
|
||||
.map((d, i) => ({
|
||||
id: d.deviceId,
|
||||
label: d.label || (d.deviceId === 'default' ? 'System default' : `Audio output ${i + 1}`),
|
||||
kind: classifyKind(d.label)
|
||||
}));
|
||||
// Ensure 'default' is always first when present.
|
||||
out.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : 0));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function setOutput(id) {
|
||||
lastCurrentId = String(id || 'default');
|
||||
// The actual `audio.setSinkId(id)` call has to happen on the renderer's
|
||||
// <audio> element, which lives in the master's main-world context. The
|
||||
// master code calls setSinkId directly after this resolves — preload only
|
||||
// tracks the selection and notifies listeners.
|
||||
for (const cb of currentListeners) {
|
||||
try { cb(lastCurrentId); } catch { /* ignore listener errors */ }
|
||||
}
|
||||
return lastCurrentId;
|
||||
}
|
||||
|
||||
function getCurrent() {
|
||||
return lastCurrentId;
|
||||
}
|
||||
|
||||
function onCurrentChanged(cb) {
|
||||
if (typeof cb !== 'function') return () => { };
|
||||
currentListeners.add(cb);
|
||||
return () => currentListeners.delete(cb);
|
||||
}
|
||||
|
||||
// Re-notify when the OS device set changes (cable plug/unplug, Bluetooth
|
||||
// connect/disconnect). The renderer can call listOutputs() again on this.
|
||||
if (navigator.mediaDevices?.addEventListener) {
|
||||
navigator.mediaDevices.addEventListener('devicechange', () => {
|
||||
for (const cb of currentListeners) {
|
||||
try { cb(lastCurrentId); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Bridge ------------------------------------------------------------
|
||||
|
||||
contextBridge.exposeInMainWorld('oradioNative', {
|
||||
isElectron: true,
|
||||
listOutputs,
|
||||
setOutput,
|
||||
getCurrent,
|
||||
onCurrentChanged
|
||||
});
|
||||
@@ -73,4 +73,4 @@
|
||||
"category": "AudioVideo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
server/auth.js
119
server/auth.js
@@ -3,7 +3,9 @@ import { randomBytes } from 'node:crypto';
|
||||
import { getDb } from './db/index.js';
|
||||
|
||||
const SESSION_DAYS = 30;
|
||||
const DEVICE_DAYS = 365;
|
||||
const COOKIE_NAME = 'oradio_sid';
|
||||
const DEVICE_COOKIE = 'oradio_device';
|
||||
|
||||
export function hashPassword(plain) {
|
||||
return bcrypt.hashSync(plain, 10);
|
||||
@@ -27,7 +29,7 @@ export function destroySession(token) {
|
||||
export function getUserBySession(token) {
|
||||
if (!token) return null;
|
||||
return getDb().prepare(`
|
||||
SELECT u.id, u.username, u.role
|
||||
SELECT u.id, u.username, u.role, u.is_main, u.avatar_color, u.avatar_emoji
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ? AND s.expires_at > datetime('now')
|
||||
`).get(token);
|
||||
@@ -50,11 +52,18 @@ export function setSessionCookie(res, token, expires) {
|
||||
'SameSite=Lax',
|
||||
`Expires=${new Date(expires).toUTCString()}`
|
||||
];
|
||||
res.setHeader('Set-Cookie', attrs.join('; '));
|
||||
appendSetCookieRaw(res, attrs.join('; '));
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res) {
|
||||
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
appendSetCookieRaw(res, `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
}
|
||||
|
||||
function appendSetCookieRaw(res, value) {
|
||||
const prev = res.getHeader('Set-Cookie');
|
||||
if (!prev) res.setHeader('Set-Cookie', value);
|
||||
else if (Array.isArray(prev)) res.setHeader('Set-Cookie', [...prev, value]);
|
||||
else res.setHeader('Set-Cookie', [prev, value]);
|
||||
}
|
||||
|
||||
export function authMiddleware(req, _res, next) {
|
||||
@@ -86,3 +95,107 @@ export function ensureBootstrapAdmin({ username, password }) {
|
||||
.run(info.lastInsertRowid, username);
|
||||
console.log(`[auth] bootstrap admin '${username}' created`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure exactly one user is flagged `is_main = 1`. Resolution order:
|
||||
* 1. The user named `mainUsername` (if it exists)
|
||||
* 2. The current main user (if any)
|
||||
* 3. The first admin
|
||||
* 4. The first user
|
||||
* Idempotent.
|
||||
*/
|
||||
export function ensureMainUser(mainUsername) {
|
||||
const db = getDb();
|
||||
let main = null;
|
||||
if (mainUsername) {
|
||||
main = db.prepare('SELECT id, username FROM users WHERE username = ?').get(mainUsername);
|
||||
}
|
||||
if (!main) main = db.prepare('SELECT id, username FROM users WHERE is_main = 1').get();
|
||||
if (!main) main = db.prepare("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
|
||||
if (!main) main = db.prepare('SELECT id, username FROM users ORDER BY id LIMIT 1').get();
|
||||
if (!main) return null;
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE users SET is_main = 0 WHERE is_main = 1 AND id != ?').run(main.id);
|
||||
db.prepare('UPDATE users SET is_main = 1 WHERE id = ?').run(main.id);
|
||||
})();
|
||||
console.log(`[auth] main user is '${main.username}' (id=${main.id})`);
|
||||
return main;
|
||||
}
|
||||
|
||||
/** Read the public-shape main user, or null. */
|
||||
export function getMainUser() {
|
||||
const row = getDb().prepare(
|
||||
'SELECT id, username, avatar_color, avatar_emoji FROM users WHERE is_main = 1'
|
||||
).get();
|
||||
return row || null;
|
||||
}
|
||||
|
||||
// ---------- Trusted devices (fast user switching) ----------
|
||||
|
||||
export function readDeviceToken(req) {
|
||||
const raw = req.headers.cookie || '';
|
||||
for (const part of raw.split(';')) {
|
||||
const [k, v] = part.trim().split('=');
|
||||
if (k === DEVICE_COOKIE) return decodeURIComponent(v || '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setDeviceCookie(res, token) {
|
||||
const expires = new Date(Date.now() + DEVICE_DAYS * 86400e3);
|
||||
const attrs = [
|
||||
`${DEVICE_COOKIE}=${encodeURIComponent(token)}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
`Expires=${expires.toUTCString()}`
|
||||
];
|
||||
appendSetCookieRaw(res, attrs.join('; '));
|
||||
}
|
||||
|
||||
function appendSetCookie(res, value) {
|
||||
const prev = res.getHeader('Set-Cookie');
|
||||
if (!prev) res.setHeader('Set-Cookie', value);
|
||||
else if (Array.isArray(prev)) res.setHeader('Set-Cookie', [...prev, value]);
|
||||
else res.setHeader('Set-Cookie', [prev, value]);
|
||||
}
|
||||
|
||||
/** Return the trusted device row matching the request's device cookie, or null. */
|
||||
export function getTrustedDevice(req) {
|
||||
const token = readDeviceToken(req);
|
||||
if (!token) return null;
|
||||
const db = getDb();
|
||||
const dev = db.prepare('SELECT * FROM kiosk_devices WHERE token = ?').get(token);
|
||||
if (!dev) return null;
|
||||
db.prepare("UPDATE kiosk_devices SET last_seen_at = datetime('now') WHERE id = ?").run(dev.id);
|
||||
return dev;
|
||||
}
|
||||
|
||||
/** Return the list of users this device is allowed to fast-switch to. */
|
||||
export function listDeviceUsers(deviceId) {
|
||||
return getDb().prepare(`
|
||||
SELECT u.id, u.username, u.role, u.is_main, u.avatar_color, u.avatar_emoji
|
||||
FROM kiosk_device_users kdu JOIN users u ON u.id = kdu.user_id
|
||||
WHERE kdu.device_id = ?
|
||||
ORDER BY u.is_main DESC, u.username COLLATE NOCASE
|
||||
`).all(deviceId);
|
||||
}
|
||||
|
||||
/** Create (or replace) a trusted device with the given user whitelist. */
|
||||
export function trustDevice({ label, userIds }) {
|
||||
const db = getDb();
|
||||
const token = randomBytes(24).toString('hex');
|
||||
return db.transaction(() => {
|
||||
const info = db.prepare('INSERT INTO kiosk_devices (token, label) VALUES (?, ?)')
|
||||
.run(token, label || null);
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO kiosk_device_users (device_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of userIds || []) stmt.run(info.lastInsertRowid, uid);
|
||||
return { id: info.lastInsertRowid, token };
|
||||
})();
|
||||
}
|
||||
|
||||
export function isUserAllowedOnDevice(deviceId, userId) {
|
||||
return !!getDb().prepare(
|
||||
'SELECT 1 FROM kiosk_device_users WHERE device_id = ? AND user_id = ?'
|
||||
).get(deviceId, userId);
|
||||
}
|
||||
|
||||
@@ -71,5 +71,25 @@ function runMigrations(db) {
|
||||
if (!playCols.has('total_play_ms')) {
|
||||
db.exec('ALTER TABLE station_plays ADD COLUMN total_play_ms INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
// Multi-user kiosk: per-user metadata + designation of "main" identity.
|
||||
const userCols = new Set(db.prepare("PRAGMA table_info(users)").all().map((c) => c.name));
|
||||
if (!userCols.has('is_main')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN is_main INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (!userCols.has('avatar_color')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT');
|
||||
}
|
||||
if (!userCols.has('avatar_emoji')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN avatar_emoji TEXT');
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_only_one_main ON users(is_main) WHERE is_main = 1');
|
||||
|
||||
// Cross-client stream sync: when a room enters 'play', record the wall-clock
|
||||
// moment so every client can target the same playhead.
|
||||
const stateCols = new Set(db.prepare("PRAGMA table_info(room_state)").all().map((c) => c.name));
|
||||
if (!stateCols.has('started_at')) {
|
||||
db.exec('ALTER TABLE room_state ADD COLUMN started_at INTEGER');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,30 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
|
||||
is_main INTEGER NOT NULL DEFAULT 0, -- exactly one user is the shared "main" identity
|
||||
avatar_color TEXT, -- cosmetic, used by avatar picker
|
||||
avatar_emoji TEXT, -- cosmetic, used by avatar picker
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
-- Note: the partial unique index on users(is_main) is created by runMigrations()
|
||||
-- after legacy DBs have the is_main column added via ALTER TABLE.
|
||||
|
||||
-- Trusted kiosk/browser devices. A device cookie (random token) is set after
|
||||
-- an admin marks the device trusted. Users on the device's whitelist can
|
||||
-- fast-switch without re-entering a password.
|
||||
CREATE TABLE IF NOT EXISTS kiosk_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
label TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS kiosk_device_users (
|
||||
device_id INTEGER NOT NULL REFERENCES kiosk_devices(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (device_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_kiosk_device_users_user ON kiosk_device_users(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
@@ -132,6 +154,7 @@ CREATE TABLE IF NOT EXISTS room_state (
|
||||
station_id INTEGER REFERENCES stations(id) ON DELETE SET NULL,
|
||||
playing INTEGER NOT NULL DEFAULT 0,
|
||||
volume REAL NOT NULL DEFAULT 0.7,
|
||||
started_at INTEGER, -- epoch ms when playback (or current station) began; anchors cross-client sync
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Radio Admin</title>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/admin-BnGhtAku.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="stylesheet" crossorigin href="/assets/admin-C-qnWY0z.css">
|
||||
</head>
|
||||
|
||||
1
server/public/assets/admin-BnGhtAku.js
Normal file
1
server/public/assets/admin-BnGhtAku.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
93
server/public/assets/debug-DBzSAgZo.js
Normal file
93
server/public/assets/debug-DBzSAgZo.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
server/public/assets/kiosk-DIx-PLJP.js
Normal file
1
server/public/assets/kiosk-DIx-PLJP.js
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/kiosk-DuoYH-tL.css
Normal file
1
server/public/assets/kiosk-DuoYH-tL.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
server/public/assets/master-B8Vyo4--.css
Normal file
1
server/public/assets/master-B8Vyo4--.css
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/master-BGIwPPRC.js
Normal file
1
server/public/assets/master-BGIwPPRC.js
Normal file
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
1
server/public/assets/playGate-C1e0nYli.js
Normal file
1
server/public/assets/playGate-C1e0nYli.js
Normal 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){(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};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
function l(s,n={}){let e,r=0,c=!1;function i(){const a=location.protocol==="https:"?"wss":"ws",o=new URLSearchParams;n.room&&o.set("room",n.room),n.kind&&o.set("kind",n.kind);const d=o.toString();e=new WebSocket(`${a}://${location.host}/ws${d?"?"+d:""}`),e.addEventListener("open",()=>{var t;r=0,(t=n.onOpen)==null||t.call(n)}),e.addEventListener("message",t=>{try{s(JSON.parse(t.data))}catch{}}),e.addEventListener("close",()=>{var t;(t=n.onClose)==null||t.call(n),!c&&(r=Math.min(r+1,6),setTimeout(i,500*2**r))}),e.addEventListener("error",()=>e.close())}return i(),{send(a){(e==null?void 0:e.readyState)===WebSocket.OPEN&&e.send(JSON.stringify(a))},close(){c=!0,e==null||e.close()},get readyState(){return e==null?void 0:e.readyState}}}export{l as c};
|
||||
@@ -5,11 +5,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<title>Radio Kiosk</title>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/kiosk-DIx-PLJP.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ws-BM1PmMVd.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/playGate-C1e0nYli.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/kiosk-DuoYH-tL.css">
|
||||
</head>
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Radio Master</title>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/master-BGIwPPRC.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ws-BM1PmMVd.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/playGate-C1e0nYli.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/master-B8Vyo4--.css">
|
||||
</head>
|
||||
|
||||
|
||||
@@ -117,11 +117,12 @@ export function ensurePersonalRoom(user) {
|
||||
|
||||
export function getRoomState(roomId) {
|
||||
const row = getDb().prepare('SELECT * FROM room_state WHERE room_id = ?').get(roomId);
|
||||
if (!row) return { station_id: null, playing: false, volume: 0.7, updated_at: null };
|
||||
if (!row) return { station_id: null, playing: false, volume: 0.7, started_at: null, updated_at: null };
|
||||
return {
|
||||
station_id: row.station_id,
|
||||
playing: !!row.playing,
|
||||
volume: row.volume,
|
||||
started_at: row.started_at ?? null,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
@@ -130,13 +131,14 @@ export function setRoomState(roomId, patch) {
|
||||
const cur = getRoomState(roomId);
|
||||
const next = { ...cur, ...patch };
|
||||
getDb().prepare(`
|
||||
INSERT INTO room_state (room_id, station_id, playing, volume, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
INSERT INTO room_state (room_id, station_id, playing, volume, started_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(room_id) DO UPDATE SET
|
||||
station_id = excluded.station_id,
|
||||
playing = excluded.playing,
|
||||
volume = excluded.volume,
|
||||
started_at = excluded.started_at,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(roomId, next.station_id ?? null, next.playing ? 1 : 0, next.volume ?? 0.7);
|
||||
`).run(roomId, next.station_id ?? null, next.playing ? 1 : 0, next.volume ?? 0.7, next.started_at ?? null);
|
||||
return getRoomState(roomId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
verifyPassword, createSession, destroySession, setSessionCookie, clearSessionCookie,
|
||||
hashPassword, requireAdmin
|
||||
hashPassword, requireAdmin, requireUser,
|
||||
getMainUser, getTrustedDevice, listDeviceUsers, trustDevice, setDeviceCookie,
|
||||
isUserAllowedOnDevice
|
||||
} from '../auth.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
|
||||
@@ -27,12 +29,107 @@ router.post('/logout', (req, res) => {
|
||||
|
||||
router.get('/me', (req, res) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'not signed in' });
|
||||
res.json(req.user);
|
||||
const row = getDb().prepare(
|
||||
'SELECT id, username, role, is_main, avatar_color, avatar_emoji FROM users WHERE id = ?'
|
||||
).get(req.user.id);
|
||||
res.json(row || req.user);
|
||||
});
|
||||
|
||||
// Public list of users (for the favorite-tabs strip and avatar picker).
|
||||
// Returns only safe cosmetic fields, no roles or timestamps.
|
||||
router.get('/users/public', requireUser, (_req, res) => {
|
||||
const rows = getDb().prepare(`
|
||||
SELECT id, username, is_main, avatar_color, avatar_emoji
|
||||
FROM users
|
||||
ORDER BY is_main DESC, username COLLATE NOCASE
|
||||
`).all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// The designated "main" user (the shared/house identity). Anyone signed in
|
||||
// may read it so the UI can pin the main tab and offer "Follow main".
|
||||
router.get('/main', requireUser, (_req, res) => {
|
||||
const main = getMainUser();
|
||||
if (!main) return res.status(404).json({ error: 'no main user configured' });
|
||||
res.json(main);
|
||||
});
|
||||
|
||||
// ----- Trusted-device fast-switching -----
|
||||
|
||||
// What does *this* device know about itself? Returns the whitelist of users
|
||||
// that can be switched to without a password. Empty object if untrusted.
|
||||
router.get('/devices/me', (req, res) => {
|
||||
const dev = getTrustedDevice(req);
|
||||
if (!dev) return res.json({ trusted: false, users: [] });
|
||||
res.json({
|
||||
trusted: true,
|
||||
id: dev.id,
|
||||
label: dev.label,
|
||||
users: listDeviceUsers(dev.id)
|
||||
});
|
||||
});
|
||||
|
||||
// Trust this device with a user whitelist. Admin-only; sets a long-lived
|
||||
// `oradio_device` cookie. The current admin is auto-included.
|
||||
router.post('/devices/trust', requireAdmin, (req, res) => {
|
||||
const { label, user_ids } = req.body || {};
|
||||
const ids = Array.isArray(user_ids) ? user_ids.map(Number).filter(Boolean) : [];
|
||||
if (!ids.includes(req.user.id)) ids.push(req.user.id);
|
||||
// Validate
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const valid = getDb().prepare(
|
||||
`SELECT id FROM users WHERE id IN (${placeholders})`
|
||||
).all(...ids).map((r) => r.id);
|
||||
if (!valid.length) return res.status(400).json({ error: 'no valid users' });
|
||||
const { id, token } = trustDevice({ label: label?.trim() || null, userIds: valid });
|
||||
setDeviceCookie(res, token);
|
||||
res.status(201).json({ id, label: label || null, users: listDeviceUsers(id) });
|
||||
});
|
||||
|
||||
// Update the whitelist of the current trusted device (admin only).
|
||||
router.patch('/devices/me', requireAdmin, (req, res) => {
|
||||
const dev = getTrustedDevice(req);
|
||||
if (!dev) return res.status(404).json({ error: 'device not trusted' });
|
||||
const { label, user_ids } = req.body || {};
|
||||
const db = getDb();
|
||||
if (typeof label === 'string') {
|
||||
db.prepare('UPDATE kiosk_devices SET label = ? WHERE id = ?').run(label.trim() || null, dev.id);
|
||||
}
|
||||
if (Array.isArray(user_ids)) {
|
||||
const ids = user_ids.map(Number).filter(Boolean);
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM kiosk_device_users WHERE device_id = ?').run(dev.id);
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO kiosk_device_users (device_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of ids) stmt.run(dev.id, uid);
|
||||
})();
|
||||
}
|
||||
res.json({ id: dev.id, users: listDeviceUsers(dev.id) });
|
||||
});
|
||||
|
||||
// Fast switch: trade a trusted-device cookie + whitelisted username for a
|
||||
// fresh session. No password required. The previous session is destroyed.
|
||||
router.post('/switch', (req, res) => {
|
||||
const dev = getTrustedDevice(req);
|
||||
if (!dev) return res.status(403).json({ error: 'device not trusted' });
|
||||
const username = String(req.body?.username || '').trim();
|
||||
if (!username) return res.status(400).json({ error: 'username required' });
|
||||
const user = getDb().prepare('SELECT id, username, role FROM users WHERE username = ?').get(username);
|
||||
if (!user) return res.status(404).json({ error: 'unknown user' });
|
||||
if (!isUserAllowedOnDevice(dev.id, user.id)) {
|
||||
return res.status(403).json({ error: 'user not allowed on this device' });
|
||||
}
|
||||
// Kill the previous session before issuing a new one.
|
||||
if (req.session?.token) destroySession(req.session.token);
|
||||
const { token, expires } = createSession(user.id);
|
||||
setSessionCookie(res, token, expires);
|
||||
res.json({ id: user.id, username: user.username, role: user.role });
|
||||
});
|
||||
|
||||
// Admin-only user management
|
||||
router.get('/users', requireAdmin, (_req, res) => {
|
||||
const users = getDb().prepare('SELECT id, username, role, created_at FROM users ORDER BY username').all();
|
||||
const users = getDb().prepare(
|
||||
'SELECT id, username, role, is_main, avatar_color, avatar_emoji, created_at FROM users ORDER BY username'
|
||||
).all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
@@ -54,12 +151,24 @@ router.post('/users', requireAdmin, (req, res) => {
|
||||
|
||||
router.patch('/users/:id', requireAdmin, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const { password, role } = req.body || {};
|
||||
const { password, role, avatar_color, avatar_emoji, is_main } = req.body || {};
|
||||
const db = getDb();
|
||||
if (password) db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
|
||||
if (role && ['admin', 'user'].includes(role)) {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||||
}
|
||||
if (typeof avatar_color === 'string') {
|
||||
db.prepare('UPDATE users SET avatar_color = ? WHERE id = ?').run(avatar_color || null, id);
|
||||
}
|
||||
if (typeof avatar_emoji === 'string') {
|
||||
db.prepare('UPDATE users SET avatar_emoji = ? WHERE id = ?').run(avatar_emoji || null, id);
|
||||
}
|
||||
if (is_main === true) {
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE users SET is_main = 0 WHERE is_main = 1 AND id != ?').run(id);
|
||||
db.prepare('UPDATE users SET is_main = 1 WHERE id = ?').run(id);
|
||||
})();
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ensurePersonalRoom
|
||||
} from '../rooms.js';
|
||||
import { getStation } from '../stations.js';
|
||||
import { dispatchRoomCommand, hasDisplay } from '../ws.js';
|
||||
|
||||
export const router = Router();
|
||||
router.use(requireUser);
|
||||
@@ -59,3 +60,38 @@ router.delete('/:slug/members/:userId', (req, res) => {
|
||||
removeMember(room.id, Number(req.params.userId));
|
||||
res.json(listMembers(room.id));
|
||||
});
|
||||
|
||||
// Inject a playback intent for the room from outside the WS hub. Used by
|
||||
// smart-home/scripts/integrations: the request behaves like a synthetic
|
||||
// controller — the master (display) actually plays the audio and emits its
|
||||
// authoritative `state`, which the server rebroadcasts so every other peer
|
||||
// stays in sync. 409 when no display is connected, because without a master
|
||||
// there's no audio source to honour the intent.
|
||||
router.post('/:slug/command', (req, res) => {
|
||||
const room = getRoomBySlug(req.params.slug);
|
||||
if (!room) return res.status(404).json({ error: 'not found' });
|
||||
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
|
||||
|
||||
const action = String(req.body?.action || '');
|
||||
if (!['play', 'pause', 'stop', 'volume'].includes(action)) {
|
||||
return res.status(400).json({ error: 'action must be one of play|pause|stop|volume' });
|
||||
}
|
||||
const msg = { type: 'command', action };
|
||||
if (action === 'play') {
|
||||
const stationId = Number(req.body?.stationId);
|
||||
if (!Number.isFinite(stationId)) return res.status(400).json({ error: 'stationId required for play' });
|
||||
msg.stationId = stationId;
|
||||
} else if (action === 'volume') {
|
||||
const value = Number(req.body?.value);
|
||||
if (!Number.isFinite(value)) return res.status(400).json({ error: 'numeric value required for volume' });
|
||||
msg.value = Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
if (!hasDisplay(room.slug)) {
|
||||
return res.status(409).json({ error: 'no display connected to room', slug: room.slug });
|
||||
}
|
||||
|
||||
const result = dispatchRoomCommand(room, msg);
|
||||
if (!result) return res.status(400).json({ error: 'command rejected' });
|
||||
res.json({ ok: true, room: { id: room.id, slug: room.slug, name: room.name }, state: { ...result.state, station: result.station } });
|
||||
});
|
||||
|
||||
70
server/routes/users.js
Normal file
70
server/routes/users.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// /api/users — cross-user reads for the favorites-as-tabs UI.
|
||||
//
|
||||
// Writes are still owned by /api/me/favorites. The one exception is the
|
||||
// shared "main" user (e.g. morphix) which any admin may edit so the house
|
||||
// favorites tab is collaboratively curated without revealing morphix's
|
||||
// password.
|
||||
|
||||
import { Router } from 'express';
|
||||
import { requireUser, requireAdmin, getMainUser } from '../auth.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
import { getStatsMap } from '../stats.js';
|
||||
|
||||
export const router = Router();
|
||||
router.use(requireUser);
|
||||
|
||||
function findUserByName(username) {
|
||||
return getDb().prepare(
|
||||
'SELECT id, username, is_main, avatar_color, avatar_emoji FROM users WHERE username = ?'
|
||||
).get(username);
|
||||
}
|
||||
|
||||
router.get('/:username/favorites', (req, res) => {
|
||||
const user = findUserByName(req.params.username);
|
||||
if (!user) return res.status(404).json({ error: 'unknown user' });
|
||||
const rows = getDb().prepare(`
|
||||
SELECT s.*, f.position
|
||||
FROM favorites f JOIN stations s ON s.id = f.station_id
|
||||
WHERE f.user_id = ? AND s.enabled = 1
|
||||
ORDER BY f.position ASC, f.created_at ASC
|
||||
`).all(user.id);
|
||||
// Stats are scoped to the *viewer* (so my_vote reflects me, not the owner).
|
||||
const stats = getStatsMap(req.user.id);
|
||||
res.json(rows.map((r) => {
|
||||
const st = stats.get(r.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 };
|
||||
return {
|
||||
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
|
||||
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position,
|
||||
up: st.up, down: st.down, plays: st.plays, my_vote: st.myVote, score: st.score
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
// Admin shortcut for editing the shared "main" user's favorites without
|
||||
// logging in as them. Any other user is rejected so we don't accidentally
|
||||
// mutate someone else's library.
|
||||
function canWriteFavoritesFor(viewer, target) {
|
||||
if (!target) return false;
|
||||
if (viewer.id === target.id) return true;
|
||||
return viewer.role === 'admin' && target.is_main === 1;
|
||||
}
|
||||
|
||||
router.put('/:username/favorites/:stationId', (req, res) => {
|
||||
const user = findUserByName(req.params.username);
|
||||
if (!canWriteFavoritesFor(req.user, user)) return res.status(403).json({ error: 'forbidden' });
|
||||
const stationId = Number(req.params.stationId);
|
||||
const position = Number(req.body?.position ?? 0);
|
||||
getDb().prepare(`
|
||||
INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position
|
||||
`).run(user.id, stationId, position);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:username/favorites/:stationId', (req, res) => {
|
||||
const user = findUserByName(req.params.username);
|
||||
if (!canWriteFavoritesFor(req.user, user)) return res.status(403).json({ error: 'forbidden' });
|
||||
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
|
||||
.run(user.id, Number(req.params.stationId));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
24
server/scripts/check-multiuser.js
Normal file
24
server/scripts/check-multiuser.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// One-shot DB smoke test for the multi-user kiosk migrations.
|
||||
import { initDb, getDb } from '../db/index.js';
|
||||
import { ensureMainUser, getMainUser } from '../auth.js';
|
||||
import { app } from 'electron';
|
||||
|
||||
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
|
||||
const db = getDb();
|
||||
ensureMainUser(process.env.MAIN_USER || 'morphix');
|
||||
|
||||
const userCols = db.prepare("PRAGMA table_info(users)").all().map((c) => c.name);
|
||||
const deviceCols = db.prepare("PRAGMA table_info(kiosk_devices)").all().map((c) => c.name);
|
||||
const wlCols = db.prepare("PRAGMA table_info(kiosk_device_users)").all().map((c) => c.name);
|
||||
const roomStateCols = db.prepare("PRAGMA table_info(room_state)").all().map((c) => c.name);
|
||||
const users = db.prepare('SELECT id, username, role, is_main, avatar_color, avatar_emoji FROM users').all();
|
||||
const mainCount = db.prepare('SELECT count(*) AS c FROM users WHERE is_main = 1').get().c;
|
||||
|
||||
console.log('users cols: ', userCols.join(', '));
|
||||
console.log('kiosk_devices cols: ', deviceCols.join(', '));
|
||||
console.log('kiosk_device_users: ', wlCols.join(', '));
|
||||
console.log('room_state cols: ', roomStateCols.join(', '));
|
||||
console.log('users: ', JSON.stringify(users, null, 2));
|
||||
console.log('main user count: ', mainCount);
|
||||
console.log('getMainUser(): ', getMainUser());
|
||||
app.quit();
|
||||
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { initDb } from './db/index.js';
|
||||
import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
|
||||
import { authMiddleware, ensureBootstrapAdmin, ensureMainUser } from './auth.js';
|
||||
import { applySeedIfEmpty } from './sources/seed.js';
|
||||
import { scheduleHealthCheck } from './streams/checker.js';
|
||||
import { attachWs } from './ws.js';
|
||||
@@ -24,6 +24,7 @@ import { router as meRoutes } from './routes/me.js';
|
||||
import { router as adminRoutes } from './routes/admin.js';
|
||||
import { router as v1Routes } from './routes/v1.js';
|
||||
import { router as roomRoutes } from './routes/rooms.js';
|
||||
import { router as userRoutes } from './routes/users.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -37,6 +38,7 @@ export async function startServer(opts = {}) {
|
||||
username: process.env.ADMIN_BOOTSTRAP_USER,
|
||||
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
|
||||
});
|
||||
ensureMainUser(process.env.MAIN_USER || 'morphix');
|
||||
const seedResult = applySeedIfEmpty();
|
||||
console.log('[seed]', seedResult);
|
||||
ensureImageDirs();
|
||||
@@ -48,6 +50,7 @@ export async function startServer(opts = {}) {
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/stations', stationRoutes);
|
||||
app.use('/api/me', meRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
app.use('/api/v1', v1Routes);
|
||||
|
||||
133
server/ws.js
133
server/ws.js
@@ -13,6 +13,8 @@
|
||||
// server->room: forwarded as-is
|
||||
// - state display->server: ground-truth playback snapshot
|
||||
// server->room: persisted snapshot
|
||||
// - sync-pos display->server: { stationId, masterCT, atServerNow }
|
||||
// server->room: forwarded; latest cached for hello replay
|
||||
// - devices display->server: { list, current } server->room: same
|
||||
// - vote server->room: { stationId, stats } emitted after castVote
|
||||
// - plays server->room: { stationId, plays } emitted after recordPlay
|
||||
@@ -26,10 +28,17 @@ import {
|
||||
} from './rooms.js';
|
||||
import { getStation } from './stations.js';
|
||||
|
||||
const DEBUG = !!process.env.ORADIO_DEBUG_SYNC;
|
||||
function dlog(...args) { if (DEBUG) console.log('[ws]', ...args); }
|
||||
|
||||
// roomSlug -> Set<ws>
|
||||
const rooms = new Map();
|
||||
// userId -> Set<ws>
|
||||
const byUser = new Map();
|
||||
// roomSlug -> last `sync-pos` payload from this room's display, plus a
|
||||
// server-clock timestamp so receivers can age it. In-memory only; this churns
|
||||
// every couple of seconds and never needs to outlive a process.
|
||||
const lastSyncPos = new Map();
|
||||
|
||||
function addToIndex(map, key, ws) {
|
||||
if (!map.has(key)) map.set(key, new Set());
|
||||
@@ -91,13 +100,55 @@ export function broadcastGlobal(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasDisplay(roomSlug) {
|
||||
export function hasDisplay(roomSlug) {
|
||||
const set = rooms.get(roomSlug);
|
||||
if (!set) return false;
|
||||
for (const ws of set) if (ws.kind === 'display') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mutates room_state for a play|pause|stop|volume intent and broadcasts both
|
||||
// the command (so the display actually changes audio) and the resulting state
|
||||
// (so every other peer's UI mirrors). Shared by the WS `command` handler and
|
||||
// the HTTP `POST /api/rooms/:slug/command` route so both paths produce
|
||||
// byte-identical broadcasts.
|
||||
//
|
||||
// `except` lets the WS path exclude the original sender from the command
|
||||
// rebroadcast (kiosks in linked mode would otherwise loop play→stop→play).
|
||||
// The state broadcast always goes to everyone — including the sender — so
|
||||
// UIs stay in sync.
|
||||
export function dispatchRoomCommand(room, msg, { except = null } = {}) {
|
||||
const slug = room.slug;
|
||||
const cur = getRoomState(room.id);
|
||||
const displayPresent = hasDisplay(slug);
|
||||
|
||||
if (msg.action === 'play' && Number.isFinite(msg.stationId)) {
|
||||
const newStation = Number(msg.stationId);
|
||||
const sameAndPlaying = cur.playing && cur.station_id === newStation && cur.started_at;
|
||||
const patch = { station_id: newStation, playing: true };
|
||||
if (!displayPresent) {
|
||||
patch.started_at = sameAndPlaying ? cur.started_at : Date.now();
|
||||
} else if (cur.station_id !== newStation) {
|
||||
patch.started_at = null;
|
||||
}
|
||||
setRoomState(room.id, patch);
|
||||
} else if (msg.action === 'pause') {
|
||||
setRoomState(room.id, { playing: false });
|
||||
} else if (msg.action === 'stop') {
|
||||
setRoomState(room.id, { playing: false, started_at: null });
|
||||
} else if (msg.action === 'volume' && typeof msg.value === 'number') {
|
||||
setRoomState(room.id, { volume: Math.max(0, Math.min(1, msg.value)) });
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next = getRoomState(room.id);
|
||||
const station = next.station_id ? getStation(next.station_id) : null;
|
||||
broadcastToRoom(slug, msg, except);
|
||||
broadcastToRoom(slug, { type: 'state', ...next, station, server_now: Date.now() });
|
||||
return { state: next, station };
|
||||
}
|
||||
|
||||
export function attachWs(server) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
@@ -146,6 +197,11 @@ export function attachWs(server) {
|
||||
ws.on('close', () => {
|
||||
removeFromIndex(rooms, room.slug, ws);
|
||||
removeFromIndex(byUser, user.id, ws);
|
||||
// If the departing socket was the display, its sync-pos cache
|
||||
// is stale (no source until the new display starts emitting).
|
||||
if (ws.kind === 'display' && !hasDisplay(room.slug)) {
|
||||
lastSyncPos.delete(room.slug);
|
||||
}
|
||||
broadcastToRoom(room.slug, { type: 'presence', peers: presenceFor(room.slug) });
|
||||
});
|
||||
|
||||
@@ -163,7 +219,9 @@ export function attachWs(server) {
|
||||
you: { id: user.id, username: user.username, role: user.role, kind },
|
||||
room: { id: room.id, slug: room.slug, name: room.name },
|
||||
state: { ...state, station },
|
||||
peers: presenceFor(room.slug)
|
||||
server_now: Date.now(),
|
||||
peers: presenceFor(room.slug),
|
||||
last_sync_pos: lastSyncPos.get(room.slug) || null
|
||||
});
|
||||
broadcastToRoom(room.slug, { type: 'presence', peers: presenceFor(room.slug) }, ws);
|
||||
});
|
||||
@@ -183,14 +241,40 @@ function handleClientMessage(ws, msg) {
|
||||
// optimistically reflect simple intents into room_state so a
|
||||
// late-joining peer sees the latest target station/volume even
|
||||
// before the display emits a confirmation `state`.
|
||||
if (msg.action === 'play' && Number.isFinite(msg.stationId)) {
|
||||
setRoomState(ws.room.id, { station_id: Number(msg.stationId), playing: true });
|
||||
} else if (msg.action === 'stop') {
|
||||
setRoomState(ws.room.id, { playing: false });
|
||||
} else if (msg.action === 'volume' && typeof msg.value === 'number') {
|
||||
setRoomState(ws.room.id, { volume: Math.max(0, Math.min(1, msg.value)) });
|
||||
dlog('cmd', msg.action, 'from', ws.user.username, 'kind', ws.kind, 'display?', hasDisplay(slug));
|
||||
if (msg.action === 'setSyncBuffer' && Number.isFinite(msg.value)) {
|
||||
// Room-wide sync buffer. Forward to everyone (including the
|
||||
// master, which will adopt it and include in the next
|
||||
// sync-pos so late joiners pick it up). Not persisted to
|
||||
// room_state — it's ephemeral per session.
|
||||
const v = Math.max(500, Math.min(60000, Math.round(msg.value)));
|
||||
broadcastToRoom(slug, { type: 'command', action: 'setSyncBuffer', value: v }, ws);
|
||||
return;
|
||||
}
|
||||
broadcastToRoom(slug, msg, null); // include sender so its UI mirrors
|
||||
if (msg.action === 'peerVolume' && Number.isFinite(msg.userId) && typeof msg.value === 'number') {
|
||||
// Targeted per-zone hint: do NOT persist in room_state. Just
|
||||
// forward to that user's connections in this room. Receiving
|
||||
// clients apply it as their LOCAL volume.
|
||||
const v = Math.max(0, Math.min(1, msg.value));
|
||||
const set = rooms.get(slug);
|
||||
if (set) {
|
||||
const payload = JSON.stringify({ type: 'peerVolume', userId: msg.userId, value: v, from: ws.user.id });
|
||||
for (const peer of set) {
|
||||
if (peer.user.id === msg.userId && peer.readyState === peer.OPEN) {
|
||||
peer.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// play/pause/stop/volume share their effect with the HTTP route,
|
||||
// so the mutation + broadcasts live in dispatchRoomCommand. The
|
||||
// command itself is NOT echoed back to `ws`: the sender already
|
||||
// acted locally, and echoing would re-trigger their command
|
||||
// handler (kiosks in linked mode would loop play→stop→play).
|
||||
// Other clients still see the command, and EVERYONE — including
|
||||
// the sender — sees the resulting `state` so UIs stay in sync.
|
||||
dispatchRoomCommand(ws.room, msg, { except: ws });
|
||||
return;
|
||||
}
|
||||
case 'state': {
|
||||
@@ -200,9 +284,38 @@ function handleClientMessage(ws, msg) {
|
||||
if ('stationId' in msg) patch.station_id = msg.stationId ?? null;
|
||||
if ('playing' in msg) patch.playing = !!msg.playing;
|
||||
if (typeof msg.volume === 'number') patch.volume = msg.volume;
|
||||
// Display can re-anchor started_at (e.g. when it begins playback).
|
||||
if ('started_at' in msg) patch.started_at = msg.started_at ?? null;
|
||||
dlog('state from display', patch);
|
||||
const prev = getRoomState(ws.room.id);
|
||||
const next = setRoomState(ws.room.id, patch);
|
||||
// Station change invalidates any cached master position.
|
||||
if (prev.station_id !== next.station_id) lastSyncPos.delete(slug);
|
||||
const station = next.station_id ? getStation(next.station_id) : null;
|
||||
broadcastToRoom(slug, { type: 'state', ...next, station });
|
||||
broadcastToRoom(slug, { type: 'state', ...next, station, server_now: Date.now() });
|
||||
return;
|
||||
}
|
||||
case 'clock-ping': {
|
||||
// NTP-lite: echo client's t1 + server t2; client computes offset.
|
||||
send(ws, { type: 'clock-pong', t1: msg.t1, t2: Date.now() });
|
||||
return;
|
||||
}
|
||||
case 'sync-pos': {
|
||||
// Only the room's display has an authoritative stream position.
|
||||
// Forward to every other peer and cache so reconnecting peers can
|
||||
// anchor immediately from `hello.last_sync_pos`.
|
||||
if (ws.kind !== 'display') return;
|
||||
const payload = {
|
||||
stationId: Number.isFinite(msg.stationId) ? Number(msg.stationId) : null,
|
||||
masterCT: Number.isFinite(msg.masterCT) ? Number(msg.masterCT) : null,
|
||||
atServerNow: Number.isFinite(msg.atServerNow) ? Number(msg.atServerNow) : Date.now(),
|
||||
pdtMs: Number.isFinite(msg.pdtMs) ? Number(msg.pdtMs) : null,
|
||||
bufferMs: Number.isFinite(msg.bufferMs) ? Number(msg.bufferMs) : null
|
||||
};
|
||||
if (payload.masterCT == null || payload.stationId == null) return;
|
||||
lastSyncPos.set(slug, payload);
|
||||
dlog('sync-pos', payload);
|
||||
broadcastToRoom(slug, { type: 'sync-pos', ...payload }, ws);
|
||||
return;
|
||||
}
|
||||
case 'devices': {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { api } from '../shared/api.js';
|
||||
import { el, clear } from '../shared/dom.js';
|
||||
import { Player } from '../player.js';
|
||||
import { mountDebugPane } from '../shared/debug.js';
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const state = {
|
||||
@@ -50,6 +51,9 @@ async function bootstrap() {
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
// Admin doesn't run a RoomClock or WS — the pane mostly shows the
|
||||
// audition player state (useful for diagnosing broken stations).
|
||||
mountDebugPane({ player: preview, clock: null, ws: null, role: 'admin' });
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -932,13 +936,48 @@ function renderRooms(root) {
|
||||
function renderUsers(root) {
|
||||
root.appendChild(el('div', { class: 'bar' },
|
||||
el('h2', { style: { margin: 0, flex: 1 } }, 'Users'),
|
||||
el('button', { class: 'btn', onClick: openTrustDeviceDialog }, '🔑 Trust this device'),
|
||||
' ',
|
||||
el('button', { class: 'btn primary', onClick: openUserDialog }, '+ Add user')
|
||||
));
|
||||
root.appendChild(el('table', {},
|
||||
el('thead', {}, el('tr', {}, el('th', {}, 'Username'), el('th', {}, 'Role'), el('th', {}, 'Created'), el('th', {}, ''))),
|
||||
el('thead', {}, el('tr', {},
|
||||
el('th', {}, 'Username'),
|
||||
el('th', {}, 'Role'),
|
||||
el('th', {}, 'Main'),
|
||||
el('th', {}, 'Avatar'),
|
||||
el('th', {}, 'Created'),
|
||||
el('th', {}, ''))),
|
||||
el('tbody', {}, ...state.users.map((u) => el('tr', {},
|
||||
el('td', {}, u.username),
|
||||
el('td', {}, u.role),
|
||||
el('td', {}, u.is_main ? '★ main' : el('button', {
|
||||
class: 'btn', title: 'Promote to main (shared) user',
|
||||
onClick: async () => {
|
||||
if (!confirm(`Make ${u.username} the shared/main user?`)) return;
|
||||
await api.patch(`/api/auth/users/${u.id}`, { is_main: true });
|
||||
await refresh(); render();
|
||||
}
|
||||
}, 'Make main')),
|
||||
el('td', {},
|
||||
el('span', {
|
||||
style: {
|
||||
display: 'inline-grid', placeItems: 'center', width: '24px', height: '24px',
|
||||
background: u.avatar_color || '#ff7a3d', color: '#1a0a00', fontWeight: '800', fontSize: '12px'
|
||||
}
|
||||
}, u.avatar_emoji || u.username.slice(0, 1).toUpperCase()),
|
||||
' ',
|
||||
el('button', {
|
||||
class: 'btn', onClick: async () => {
|
||||
const emoji = prompt(`Avatar emoji / letter for ${u.username} (leave empty to clear):`, u.avatar_emoji || '');
|
||||
if (emoji === null) return;
|
||||
const color = prompt(`Avatar color (hex, e.g. #ff7a3d):`, u.avatar_color || '#ff7a3d');
|
||||
if (color === null) return;
|
||||
await api.patch(`/api/auth/users/${u.id}`, { avatar_emoji: emoji, avatar_color: color });
|
||||
await refresh(); render();
|
||||
}
|
||||
}, 'Edit')
|
||||
),
|
||||
el('td', {}, u.created_at),
|
||||
el('td', {},
|
||||
el('button', {
|
||||
@@ -966,6 +1005,61 @@ function renderUsers(root) {
|
||||
));
|
||||
}
|
||||
|
||||
async function openTrustDeviceDialog() {
|
||||
// Snapshot of current state to pre-check existing whitelist.
|
||||
let info = { trusted: false, users: [], label: '' };
|
||||
try { info = await api.get('/api/auth/devices/me'); } catch { }
|
||||
const existing = new Set((info.users || []).map((u) => u.id));
|
||||
const dlg = el('dialog');
|
||||
dlg.appendChild(el('form', {
|
||||
method: 'dialog', onSubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const ids = [...fd.getAll('user_id')].map(Number);
|
||||
const label = fd.get('label') || null;
|
||||
try {
|
||||
if (info.trusted) {
|
||||
await api.patch('/api/auth/devices/me', { label, user_ids: ids });
|
||||
alert('Device whitelist updated.');
|
||||
} else {
|
||||
await api.post('/api/auth/devices/trust', { label, user_ids: ids });
|
||||
alert('This device is now trusted. Fast-switching is enabled for the selected users.');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed');
|
||||
return;
|
||||
}
|
||||
dlg.close();
|
||||
}
|
||||
},
|
||||
el('h2', {}, info.trusted ? 'Edit trusted device' : 'Trust this device'),
|
||||
el('p', { style: { color: '#8a90a0', fontSize: '13px' } },
|
||||
'Listed users can fast-switch on this device without a password. ',
|
||||
'The cookie is HttpOnly and lasts 1 year.'),
|
||||
el('div', { class: 'row' },
|
||||
el('label', {}, 'Device label'),
|
||||
el('input', { name: 'label', value: info.label || '', placeholder: 'e.g. Kitchen kiosk' })),
|
||||
el('div', { style: { maxHeight: '300px', overflowY: 'auto', border: '1px solid #262b36', padding: '8px' } },
|
||||
...state.users.map((u) => el('label', {
|
||||
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '4px 6px', cursor: 'pointer' }
|
||||
},
|
||||
el('input', {
|
||||
type: 'checkbox', name: 'user_id', value: String(u.id),
|
||||
checked: existing.has(u.id) || u.id === state.user.id
|
||||
}),
|
||||
el('span', {}, u.username),
|
||||
u.is_main ? el('span', { style: { color: '#ffb37a', fontSize: '11px' } }, ' ★ main') : null,
|
||||
u.id === state.user.id ? el('span', { style: { color: '#5d6373', fontSize: '11px' } }, ' (you)') : null,
|
||||
))),
|
||||
el('div', { class: 'actions' },
|
||||
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
|
||||
el('button', { class: 'btn primary', type: 'submit' }, info.trusted ? 'Update' : 'Trust device'))
|
||||
));
|
||||
document.body.appendChild(dlg);
|
||||
dlg.showModal();
|
||||
dlg.addEventListener('close', () => dlg.remove());
|
||||
}
|
||||
|
||||
function openUserDialog() {
|
||||
const dlg = el('dialog');
|
||||
dlg.appendChild(el('form', {
|
||||
|
||||
852
web/main.js
852
web/main.js
File diff suppressed because it is too large
Load Diff
@@ -17,21 +17,23 @@ import { api } from '../shared/api.js';
|
||||
import { connectWs } from '../shared/ws.js';
|
||||
import { el, clear } from '../shared/dom.js';
|
||||
import { Player } from '../player.js';
|
||||
import { RoomClock } from '../shared/clock.js';
|
||||
import { mountVisualizer } from './visualizer.js';
|
||||
import { showStartModal, autoplayDismissed } from '../shared/playGate.js';
|
||||
import { mountDebugPane } from '../shared/debug.js';
|
||||
|
||||
// Fake list mirrors what a typical desktop sees. Used only when no native
|
||||
// bridge is present (i.e. running in a normal browser tab, not Electron).
|
||||
const FAKE_DEVICES = [
|
||||
{ id: 'default', label: 'System default', kind: 'speakers' },
|
||||
{ id: 'speakers-internal', label: 'Built-in speakers', kind: 'speakers' },
|
||||
{ id: 'headphones-jack', label: 'Headphones (3.5mm)', kind: 'headphones' },
|
||||
{ id: 'hdmi-tv', label: 'HDMI – Living-room TV', kind: 'hdmi' },
|
||||
{ id: 'bt-marshall', label: 'Bluetooth – Marshall Stanmore', kind: 'bluetooth' },
|
||||
{ id: 'usb-audient', label: 'USB – Audient EVO 4', kind: 'usb' }
|
||||
];
|
||||
// The audio-output picker and the spectrum visualiser only work inside the
|
||||
// Electron shell (real device enumeration, plus the CORS-rewrite that lets
|
||||
// AnalyserNode read PCM from cross-origin radio streams). In a plain browser
|
||||
// tab those features are hidden — the master still functions as the room's
|
||||
// authoritative source for any connected kiosks.
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const state = {
|
||||
user: null,
|
||||
users: [], // public list of all users (for the tab strip + avatar picker)
|
||||
mainUser: null, // the shared/house identity, e.g. morphix
|
||||
device: { trusted: false, users: [] }, // trusted-device whitelist for fast switching
|
||||
rooms: [],
|
||||
roomSlug: null,
|
||||
room: null,
|
||||
@@ -42,15 +44,46 @@ const state = {
|
||||
loading: false, volume: 0.7, error: null
|
||||
},
|
||||
voteStats: null,
|
||||
favorites: [],
|
||||
favorites: [], // current viewer's *own* favorites (write target, heart indicator)
|
||||
tabUser: null, // username currently shown in the favorites strip (defaults to self)
|
||||
tabFavorites: [], // favorites for the active tab (== state.favorites when tabUser is self)
|
||||
tabLoading: false,
|
||||
favGenre: '', // active genre filter for favorites browser
|
||||
showOutputs: false, // output picker is hidden behind a button
|
||||
showAvatars: false, // avatar / user-switch popover
|
||||
session: null // { id, stationId, startedAt } for the open play_history row
|
||||
};
|
||||
|
||||
const native = window.oradioNative || null;
|
||||
let ws = null;
|
||||
let player = null;
|
||||
// Mandatory click-to-start in plain browsers — same scheme as the kiosk.
|
||||
// Auto-resume on cold-boot (hello-state replay) must wait for the user to
|
||||
// tap Start before calling player.audio.play(). Electron bypasses via
|
||||
// autoplayDismissed().
|
||||
let gestureUnlocked = false;
|
||||
function markGesture() { gestureUnlocked = true; }
|
||||
async function ensureGesture(stationName, subtitle) {
|
||||
if (gestureUnlocked) return true;
|
||||
if (autoplayDismissed()) { gestureUnlocked = true; return true; }
|
||||
try {
|
||||
await showStartModal({
|
||||
stationName: stationName || 'Radio',
|
||||
subtitle: subtitle || 'Tap Start to enable audio.',
|
||||
onStart: () => { gestureUnlocked = true; }
|
||||
});
|
||||
return gestureUnlocked;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Master command de-dup: track the station id of the in-flight play (so a
|
||||
// second cmd for the same id is a no-op) and a generation counter so a slow
|
||||
// /api/stations/:id fetch from an OLD cmd can't call playStation() after a
|
||||
// newer cmd has already been handled.
|
||||
let _pendingStationId = null;
|
||||
let _cmdGen = 0;
|
||||
const clock = new RoomClock();
|
||||
|
||||
async function bootstrap() {
|
||||
try { state.user = await api.get('/api/auth/me'); }
|
||||
@@ -66,30 +99,105 @@ async function bootstrap() {
|
||||
|| (state.rooms[0] && state.rooms[0].slug)
|
||||
|| `u-${state.user.id}`;
|
||||
|
||||
// Initial device list.
|
||||
if (native?.listOutputs) {
|
||||
state.devices.list = await native.listOutputs();
|
||||
state.devices.current = (await native.getCurrent()) || state.devices.list[0]?.id;
|
||||
// Initial device list — Electron only. In the browser the picker is
|
||||
// hidden, so we leave the list empty.
|
||||
if (native?.isElectron && native.listOutputs) {
|
||||
try {
|
||||
state.devices.list = await native.listOutputs();
|
||||
state.devices.current = (await native.getCurrent()) || state.devices.list[0]?.id || 'default';
|
||||
} catch (err) {
|
||||
console.warn('[master] listOutputs failed', err);
|
||||
state.devices.list = [];
|
||||
state.devices.current = 'default';
|
||||
}
|
||||
native.onCurrentChanged?.((id) => { state.devices.current = id; advertiseDevices(); render(); });
|
||||
} else {
|
||||
state.devices.list = FAKE_DEVICES;
|
||||
state.devices.current = 'default';
|
||||
state.devices.list = [];
|
||||
state.devices.current = null;
|
||||
}
|
||||
|
||||
player = new Player({
|
||||
onState: (s) => {
|
||||
// Capture the previous snapshot BEFORE merging so we can tell
|
||||
// whether the change is actually worth broadcasting.
|
||||
const prev = { ...state.np };
|
||||
Object.assign(state.np, s);
|
||||
// Push display truth out to the room.
|
||||
sendState();
|
||||
render();
|
||||
// Push display truth out to the room ONLY on meaningful changes:
|
||||
// station, playing flag, or volume. The Player emits during the
|
||||
// loading phase (playing:false, loading:true) used to thrash the
|
||||
// server-persisted state and cause all peers to see a
|
||||
// playing-false flicker. We also intentionally do NOT send while
|
||||
// `loading` is true — onPlayingOnce will send a single state
|
||||
// with started_at once audio actually starts.
|
||||
const station = state.np.stationId;
|
||||
const playing = !!state.np.playing;
|
||||
const volume = state.np.volume;
|
||||
const stationOrPlayingChanged = station !== prev.stationId || playing !== !!prev.playing;
|
||||
const volumeChanged = Math.abs((volume ?? 0) - (prev.volume ?? 0)) > 0.001;
|
||||
const isLoading = s.loading === true;
|
||||
// Defensive guard: Player.play() already swallows the pause
|
||||
// event from its internal stop() via _silentStop, but if any
|
||||
// other path produces a (stationId=null, playing=false) emit
|
||||
// mid-switch (e.g. an `error` event), don't broadcast that
|
||||
// ghost — the kiosks would briefly clear their UI.
|
||||
const ghostStop = s.playing === false && s.stationId == null && _pendingStationId != null;
|
||||
if (!isLoading && !ghostStop) {
|
||||
if (stationOrPlayingChanged) {
|
||||
// Station / playing transitions are user-visible — send now
|
||||
// and drop any pending throttled volume broadcast (the
|
||||
// state we send carries the latest volume).
|
||||
if (_volumeBroadcastT) { clearTimeout(_volumeBroadcastT); _volumeBroadcastT = null; }
|
||||
sendState();
|
||||
} else if (volumeChanged) {
|
||||
broadcastVolumeSoon();
|
||||
}
|
||||
}
|
||||
scheduleRender();
|
||||
}
|
||||
});
|
||||
// The master IS the anchor: when its <audio> reports its first decoded
|
||||
// sample for a (new) station, re-broadcast started_at so every peer
|
||||
// aligns on a real wall-clock instant instead of the server's
|
||||
// command-time estimate.
|
||||
player.onPlayingOnce = (station) => {
|
||||
if (!ws || !station) return;
|
||||
const startedAt = clock.now ? clock.now() : Date.now();
|
||||
try {
|
||||
ws.send({
|
||||
type: 'state',
|
||||
stationId: station.id,
|
||||
playing: true,
|
||||
volume: state.np.volume,
|
||||
started_at: startedAt
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
// Local sync engine should re-target too so the master's own output
|
||||
// honours the shared deadline.
|
||||
if (player.sync.enabled) player.updateSyncTarget(startedAt);
|
||||
else applyMasterSync(startedAt);
|
||||
};
|
||||
|
||||
// Load favorites so the touch browser + heart indicator work.
|
||||
try { state.favorites = await api.get('/api/me/favorites'); }
|
||||
catch { state.favorites = []; }
|
||||
|
||||
// Multi-user surface: list of all users (tab strip), the main/shared
|
||||
// identity (pinned tab + "Follow main" target), and this device's
|
||||
// trusted-user whitelist (for fast switching).
|
||||
try { state.users = await api.get('/api/auth/users/public'); }
|
||||
catch { state.users = []; }
|
||||
try { state.mainUser = await api.get('/api/auth/main'); }
|
||||
catch { state.mainUser = null; }
|
||||
try { state.device = await api.get('/api/auth/devices/me'); }
|
||||
catch { state.device = { trusted: false, users: [] }; }
|
||||
|
||||
// Default tab is yourself; the strip pins main first regardless.
|
||||
state.tabUser = state.user.username;
|
||||
state.tabFavorites = state.favorites;
|
||||
|
||||
openWs();
|
||||
startSyncPosBroadcast();
|
||||
mountDebugPane({ player, clock, getWs: () => ws, role: 'master' });
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -101,15 +209,60 @@ if (typeof window !== 'undefined') {
|
||||
|
||||
function openWs() {
|
||||
if (ws) { try { ws.close(); } catch { } }
|
||||
clock.detach();
|
||||
ws = connectWs(handleWs, {
|
||||
room: state.roomSlug,
|
||||
kind: 'display',
|
||||
onOpen: () => advertiseDevices()
|
||||
});
|
||||
clock.attachWs(ws);
|
||||
}
|
||||
|
||||
// Every 2 s while audio is actually playing, broadcast a sync-pos snapshot.
|
||||
// Peers on non-HLS streams use this as the timeline anchor: at server-clock
|
||||
// `atServerNow` the master's <audio>.currentTime was `masterCT`, so each
|
||||
// peer projects its own expected position forward from that point. HLS
|
||||
// peers prefer PROGRAM-DATE-TIME when available and ignore this; the
|
||||
// snapshot is still useful as a fallback if PDT is missing or skewed.
|
||||
let _syncPosTimer = null;
|
||||
function startSyncPosBroadcast() {
|
||||
if (_syncPosTimer) return;
|
||||
_syncPosTimer = setInterval(() => {
|
||||
if (!ws || !player) return;
|
||||
if (player.audio.paused || player.audio.readyState < 2) return;
|
||||
const station = state.np.stationId;
|
||||
if (!Number.isFinite(station)) return;
|
||||
try {
|
||||
ws.send({
|
||||
type: 'sync-pos',
|
||||
stationId: station,
|
||||
masterCT: player.audio.currentTime,
|
||||
atServerNow: clock.now ? clock.now() : Date.now(),
|
||||
pdtMs: player.hls?.playingDate?.getTime?.() ?? null,
|
||||
bufferMs: player.sync?.bufferMs ?? null
|
||||
});
|
||||
} catch { /* ignore transient send errors */ }
|
||||
}, 2000);
|
||||
}
|
||||
function stopSyncPosBroadcast() {
|
||||
if (_syncPosTimer) { clearInterval(_syncPosTimer); _syncPosTimer = null; }
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pagehide', stopSyncPosBroadcast);
|
||||
}
|
||||
|
||||
// The master is the room's authoritative audio source, but it still routes
|
||||
// its own output through a DelayNode so the shared room deadline
|
||||
// (startedAt + bufferMs) gives every late-joining listener room to align.
|
||||
function applyMasterSync(startedAt) {
|
||||
if (!player) return;
|
||||
if (startedAt) player.enableSync({ clock, startedAt });
|
||||
else player.disableSync();
|
||||
}
|
||||
|
||||
function handleWs(msg) {
|
||||
if (!msg || !msg.type) return;
|
||||
if (msg.type === 'clock-pong') { clock.handlePong(msg); return; }
|
||||
switch (msg.type) {
|
||||
case 'hello': {
|
||||
state.room = msg.room;
|
||||
@@ -128,9 +281,21 @@ function handleWs(msg) {
|
||||
if (typeof rs?.volume === 'number') {
|
||||
player.setVolume(rs.volume);
|
||||
}
|
||||
applyMasterSync(rs?.started_at || null);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
case 'state': {
|
||||
// Server echoes our own state plus its canonical started_at; the
|
||||
// master uses that as the sync anchor too.
|
||||
if (msg.started_at) {
|
||||
if (player?.sync?.enabled) player.updateSyncTarget(msg.started_at);
|
||||
else applyMasterSync(msg.started_at);
|
||||
} else {
|
||||
applyMasterSync(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'presence':
|
||||
state.peers = msg.peers || [];
|
||||
render();
|
||||
@@ -156,13 +321,31 @@ function handleCommand(msg) {
|
||||
case 'play': {
|
||||
const id = Number(msg.stationId);
|
||||
if (!Number.isFinite(id)) return;
|
||||
api.get(`/api/stations/${id}`).then((st) => playStation(st)).catch(() => { });
|
||||
// Idempotency: ignore a play for the station already current OR
|
||||
// already pending. Without these guards a flood of commands (e.g.
|
||||
// multiple kiosks racing, or repeated clicks) would call
|
||||
// playStation() in a loop, each one doing stop()+play() and
|
||||
// producing audible start/stop thrashing.
|
||||
if (id === state.np.stationId) return;
|
||||
if (id === _pendingStationId) return;
|
||||
_pendingStationId = id;
|
||||
const gen = ++_cmdGen;
|
||||
api.get(`/api/stations/${id}`).then((st) => {
|
||||
// Bail if a newer command superseded this one while the
|
||||
// station metadata fetch was in flight.
|
||||
if (gen !== _cmdGen) return;
|
||||
_pendingStationId = null;
|
||||
playStation(st);
|
||||
}).catch(() => {
|
||||
if (gen === _cmdGen) _pendingStationId = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'pause':
|
||||
player.togglePause();
|
||||
if (state.np.playing) player.togglePause();
|
||||
return;
|
||||
case 'stop':
|
||||
if (!state.np.stationId) return;
|
||||
player.stop();
|
||||
endCurrentSession();
|
||||
state.np.playing = false;
|
||||
@@ -171,11 +354,21 @@ function handleCommand(msg) {
|
||||
render();
|
||||
return;
|
||||
case 'volume':
|
||||
if (typeof msg.value === 'number') player.setVolume(msg.value);
|
||||
if (typeof msg.value === 'number' && Math.abs(msg.value - state.np.volume) > 0.001) {
|
||||
player.setVolume(msg.value);
|
||||
}
|
||||
return;
|
||||
case 'setSink':
|
||||
setSink(String(msg.deviceId || ''));
|
||||
return;
|
||||
case 'setSyncBuffer':
|
||||
if (Number.isFinite(msg.value) && player) {
|
||||
player.setSyncBufferMs(msg.value);
|
||||
// The next sync-pos broadcast (within 2s) carries the new
|
||||
// bufferMs so late joiners and reconnecting peers pick it up
|
||||
// from `hello.last_sync_pos`.
|
||||
}
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -183,6 +376,13 @@ function handleCommand(msg) {
|
||||
|
||||
async function playStation(station, { silent } = {}) {
|
||||
if (!station) return;
|
||||
// User-initiated playStation calls (station-card click) ARE the
|
||||
// gesture. Silent/hello-resume paths leave gestureUnlocked false so
|
||||
// ensureGesture below surfaces the Start modal.
|
||||
if (!silent) markGesture();
|
||||
// Any in-flight command for a different station is now stale.
|
||||
_cmdGen++;
|
||||
_pendingStationId = null;
|
||||
// Close any previous session before swapping. We compute the duration
|
||||
// locally so resumes-after-suspend don't get charged the whole gap.
|
||||
endCurrentSession();
|
||||
@@ -193,7 +393,16 @@ async function playStation(station, { silent } = {}) {
|
||||
plays: station.plays || 0, score: station.score || 0
|
||||
};
|
||||
render();
|
||||
// Mandatory gesture in plain browsers — silent/hello-resume paths
|
||||
// would otherwise call audio.play() without one and iOS/Chromium
|
||||
// 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.');
|
||||
if (!ok) return;
|
||||
await player.play(station);
|
||||
if (player.audio.paused) {
|
||||
// Gesture confirmed but browser hasn't started yet — try once more.
|
||||
player.audio.play().catch(() => { });
|
||||
}
|
||||
if (!silent) {
|
||||
try {
|
||||
const stats = await api.post(`/api/stations/${station.id}/play`);
|
||||
@@ -229,16 +438,7 @@ function endCurrentSession({ beacon = false } = {}) {
|
||||
}
|
||||
|
||||
function sendState() {
|
||||
if (!ws || !state.np.stationId) {
|
||||
ws?.send({
|
||||
type: 'state',
|
||||
stationId: state.np.stationId,
|
||||
playing: !!state.np.playing,
|
||||
volume: state.np.volume
|
||||
});
|
||||
return;
|
||||
}
|
||||
ws.send({
|
||||
ws?.send({
|
||||
type: 'state',
|
||||
stationId: state.np.stationId,
|
||||
playing: !!state.np.playing,
|
||||
@@ -246,6 +446,55 @@ function sendState() {
|
||||
});
|
||||
}
|
||||
|
||||
// Volume changes from the slider can fire at 60+ Hz. Coalesce the broadcast
|
||||
// onto a short trailing timer so dragging produces ~12 messages/sec instead
|
||||
// of hundreds. Cleared when a station/playing change forces an immediate send.
|
||||
let _volumeBroadcastT = null;
|
||||
function broadcastVolumeSoon() {
|
||||
if (_volumeBroadcastT) return;
|
||||
_volumeBroadcastT = setTimeout(() => {
|
||||
_volumeBroadcastT = null;
|
||||
sendState();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
// Single rAF gate around render() so a burst of state updates within the same
|
||||
// frame collapses to one DOM rebuild. Without this, dragging the volume
|
||||
// slider triggered a render per pointer event, tearing the slider out from
|
||||
// under the user's pointer.
|
||||
let _renderScheduled = false;
|
||||
function scheduleRender() {
|
||||
if (_renderScheduled) return;
|
||||
_renderScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
_renderScheduled = false;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
// `render()` does a full clear+rebuild of the DOM. If we re-render while the
|
||||
// user is dragging a range slider, the slider element they're holding gets
|
||||
// destroyed mid-drag and the browser drops the drag. Track active slider
|
||||
// drags at the document level and have render() bail until the pointer is
|
||||
// released. After release we trigger one catch-up render.
|
||||
let _dragLockCount = 0;
|
||||
function isRangeInput(t) {
|
||||
return t instanceof HTMLInputElement && t.type === 'range';
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (isRangeInput(e.target)) _dragLockCount++;
|
||||
}, true);
|
||||
const release = (e) => {
|
||||
if (isRangeInput(e.target) && _dragLockCount > 0) {
|
||||
_dragLockCount--;
|
||||
scheduleRender();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerup', release, true);
|
||||
document.addEventListener('pointercancel', release, true);
|
||||
}
|
||||
|
||||
function advertiseDevices() {
|
||||
ws?.send({
|
||||
type: 'devices',
|
||||
@@ -261,10 +510,12 @@ async function setSink(deviceId) {
|
||||
catch (err) { console.warn('[master] setOutput failed', err); return; }
|
||||
}
|
||||
state.devices.current = deviceId;
|
||||
// Browser-only fallback: try `audio.setSinkId` if the device id maps to a
|
||||
// real MediaDevices id. For the fake list this is a no-op visualisation.
|
||||
if (player?.audio?.setSinkId && /^[a-f0-9]{16,}$/.test(deviceId)) {
|
||||
try { await player.audio.setSinkId(deviceId); } catch { }
|
||||
// Route both the audio element AND the AudioContext. Once the WebAudio
|
||||
// graph is built (always, for the master) audio exits via the context's
|
||||
// destination — setSinkId on the element alone is silently ignored.
|
||||
if (native?.isElectron) {
|
||||
try { await player.setSinkId(deviceId); }
|
||||
catch (err) { console.warn('[master] setSinkId failed', err); }
|
||||
}
|
||||
advertiseDevices();
|
||||
state.showOutputs = false;
|
||||
@@ -278,6 +529,7 @@ function countDisplays(peers) {
|
||||
// ---------- Favorites ----------
|
||||
|
||||
function isFavorite(stationId) {
|
||||
// The heart always reflects the *viewer's* library, not the active tab.
|
||||
return !!stationId && state.favorites.some((f) => f.id === stationId);
|
||||
}
|
||||
|
||||
@@ -288,6 +540,8 @@ async function toggleFavorite(stationId) {
|
||||
if (has) await api.del(`/api/me/favorites/${stationId}`);
|
||||
else await api.put(`/api/me/favorites/${stationId}`, { position: state.favorites.length });
|
||||
state.favorites = await api.get('/api/me/favorites');
|
||||
// If you're currently viewing your own tab, refresh that view too.
|
||||
if (state.tabUser === state.user.username) state.tabFavorites = state.favorites;
|
||||
render();
|
||||
} catch (err) {
|
||||
console.warn('[master] toggleFavorite failed', err);
|
||||
@@ -296,7 +550,7 @@ async function toggleFavorite(stationId) {
|
||||
|
||||
function favoriteGenres() {
|
||||
const counts = new Map();
|
||||
for (const s of state.favorites) {
|
||||
for (const s of state.tabFavorites) {
|
||||
for (const g of (s.genres || [])) counts.set(g, (counts.get(g) || 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
@@ -305,13 +559,105 @@ function favoriteGenres() {
|
||||
}
|
||||
|
||||
function filteredFavorites() {
|
||||
if (!state.favGenre) return state.favorites;
|
||||
return state.favorites.filter((s) => (s.genres || []).includes(state.favGenre));
|
||||
if (!state.favGenre) return state.tabFavorites;
|
||||
return state.tabFavorites.filter((s) => (s.genres || []).includes(state.favGenre));
|
||||
}
|
||||
|
||||
// ---------- Favorite tabs (users-as-folders) ----------
|
||||
|
||||
/** Ordered list of tabs: main first (★), then self if not main, then others. */
|
||||
function favoriteTabs() {
|
||||
const tabs = [];
|
||||
const seen = new Set();
|
||||
if (state.mainUser) {
|
||||
tabs.push({ ...state.mainUser, main: true });
|
||||
seen.add(state.mainUser.username);
|
||||
}
|
||||
if (state.user && !seen.has(state.user.username)) {
|
||||
tabs.push({ ...state.user, self: true });
|
||||
seen.add(state.user.username);
|
||||
}
|
||||
for (const u of state.users) {
|
||||
if (!seen.has(u.username)) {
|
||||
tabs.push(u);
|
||||
seen.add(u.username);
|
||||
}
|
||||
}
|
||||
// Annotate "self" on whichever tab matches the current viewer.
|
||||
for (const t of tabs) if (t.username === state.user.username) t.self = true;
|
||||
return tabs;
|
||||
}
|
||||
|
||||
async function switchTab(username) {
|
||||
if (!username || username === state.tabUser) return;
|
||||
state.tabUser = username;
|
||||
state.favGenre = '';
|
||||
if (username === state.user.username) {
|
||||
state.tabFavorites = state.favorites;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
state.tabLoading = true;
|
||||
render();
|
||||
try {
|
||||
state.tabFavorites = await api.get(`/api/users/${encodeURIComponent(username)}/favorites`);
|
||||
} catch (err) {
|
||||
console.warn('[master] failed to load favorites for', username, err);
|
||||
state.tabFavorites = [];
|
||||
} finally {
|
||||
state.tabLoading = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** Can the viewer write into the currently-shown tab? */
|
||||
function canEditCurrentTab() {
|
||||
if (!state.tabUser) return false;
|
||||
if (state.tabUser === state.user.username) return true;
|
||||
// Admins can curate the shared/main tab.
|
||||
return state.user.role === 'admin'
|
||||
&& state.mainUser && state.tabUser === state.mainUser.username;
|
||||
}
|
||||
|
||||
// ---------- User switching (avatar picker) ----------
|
||||
|
||||
async function switchUser(username) {
|
||||
if (!username || username === state.user.username) {
|
||||
state.showAvatars = false;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/api/auth/switch', { username });
|
||||
} catch (err) {
|
||||
console.warn('[master] switch failed', err);
|
||||
alert(err.message || 'Could not switch user');
|
||||
return;
|
||||
}
|
||||
// Hard reload: re-bootstrap so all per-user caches (rooms, favorites, WS) are reset.
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// ---------- Follow main ----------
|
||||
|
||||
async function followMain() {
|
||||
if (!state.mainUser) return;
|
||||
const personalSlug = `u-${state.mainUser.id}`;
|
||||
state.roomSlug = personalSlug;
|
||||
history.replaceState(null, '', `?room=${encodeURIComponent(personalSlug)}`);
|
||||
// Make sure it appears in the room dropdown by refetching.
|
||||
try { state.rooms = await api.get('/api/rooms'); } catch { }
|
||||
openWs();
|
||||
render();
|
||||
}
|
||||
|
||||
// ---------- Render ----------
|
||||
|
||||
function render() {
|
||||
// Bail while the user is actively dragging a range slider — see
|
||||
// `_dragLockCount` for the rationale. The next pointerup will schedule
|
||||
// a catch-up render.
|
||||
if (_dragLockCount > 0) return;
|
||||
// Preserve scroll position of the favorites grid across re-renders so that
|
||||
// tapping a tile (which triggers a full re-render) does not jump back to top.
|
||||
const prevFavScroll = app.querySelector('.favs-grid')?.scrollLeft ?? 0;
|
||||
@@ -337,15 +683,19 @@ function render() {
|
||||
el('option', { value: r.slug, selected: r.slug === state.roomSlug }, r.name)))
|
||||
),
|
||||
el('div', { class: 'pill peers' }, `${state.peers.length} peer${state.peers.length === 1 ? '' : 's'}`),
|
||||
el('div', { class: 'pill role-pill', title: 'This master is the authoritative audio source for the room.' },
|
||||
'◉ Broadcasting'),
|
||||
np.error ? el('div', { class: 'err-banner' }, np.error) : null,
|
||||
el('div', { class: 'grow' }),
|
||||
el('button', {
|
||||
// Output picker: Electron only. In a browser tab the picker would
|
||||
// be useless (no real device enumeration / sink switching).
|
||||
native?.isElectron ? el('button', {
|
||||
class: 'pill out-btn' + (state.showOutputs ? ' active' : ''),
|
||||
title: 'Audio output',
|
||||
onClick: () => { state.showOutputs = !state.showOutputs; render(); }
|
||||
}, '🔊 ', currentDeviceLabel()),
|
||||
el('div', { class: 'pill' }, native ? 'native' : 'browser'),
|
||||
el('div', { class: 'pill' }, state.user.username),
|
||||
}, '🔊 ', currentDeviceLabel()) : null,
|
||||
renderFollowMainPill(),
|
||||
renderUserPill(),
|
||||
),
|
||||
|
||||
// Stage: now-playing block (with transport + volume embedded)
|
||||
@@ -376,6 +726,9 @@ function render() {
|
||||
}, fav ? '★' : '☆') : null
|
||||
),
|
||||
el('div', { class: 'genres' }, ...(st?.genres || []).slice(0, 6).map((g) => el('span', { class: 'tag' }, g))),
|
||||
// Live spectrum analyser (Electron only). The canvas is
|
||||
// hooked up after the DOM is appended (see below).
|
||||
native?.isElectron ? el('canvas', { class: 'np-spectrum', 'data-spectrum': '1' }) : null,
|
||||
state.voteStats ? el('div', { class: 'stats' },
|
||||
el('span', {}, '▲ ', el('b', {}, String(state.voteStats.up || 0))),
|
||||
el('span', {}, '▼ ', el('b', {}, String(state.voteStats.down || 0))),
|
||||
@@ -388,13 +741,14 @@ function render() {
|
||||
class: 'ctrl primary',
|
||||
title: 'Play / pause',
|
||||
disabled: !st,
|
||||
onClick: () => player.togglePause()
|
||||
onClick: () => { markGesture(); player.togglePause(); }
|
||||
}, np.playing ? '❚❚' : '▶'),
|
||||
el('button', {
|
||||
class: 'ctrl',
|
||||
title: 'Stop',
|
||||
disabled: !st,
|
||||
onClick: () => {
|
||||
markGesture();
|
||||
player.stop();
|
||||
endCurrentSession();
|
||||
state.np.playing = false;
|
||||
@@ -406,7 +760,15 @@ function render() {
|
||||
el('span', { class: 'vol-icon' }, '🔊'),
|
||||
el('input', {
|
||||
type: 'range', min: 0, max: 1, step: 0.01, value: np.volume,
|
||||
onInput: (e) => player.setVolume(Number(e.target.value))
|
||||
onInput: (e) => {
|
||||
const v = Number(e.target.value);
|
||||
player.setVolume(v);
|
||||
// Update the % label inline so it tracks the
|
||||
// slider while the drag-lock is suppressing
|
||||
// full renders.
|
||||
const valEl = e.target.parentNode?.querySelector('.val');
|
||||
if (valEl) valEl.textContent = Math.round(v * 100) + '%';
|
||||
}
|
||||
}),
|
||||
el('span', { class: 'val' }, Math.round(np.volume * 100) + '%')
|
||||
)
|
||||
@@ -419,7 +781,8 @@ function render() {
|
||||
el('span', {}, p.user?.username || '?')
|
||||
))
|
||||
: [el('span', { class: 'peer' }, 'Just you.')])
|
||||
)
|
||||
),
|
||||
renderZonesPanel()
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -430,7 +793,8 @@ function render() {
|
||||
),
|
||||
|
||||
// Output picker popover (hidden by default; toggled by topbar button).
|
||||
state.showOutputs ? renderOutputPopover() : null
|
||||
native?.isElectron && state.showOutputs ? renderOutputPopover() : null,
|
||||
state.showAvatars ? renderAvatarPopover() : null
|
||||
);
|
||||
app.appendChild(shell);
|
||||
// Restore favorites grid scroll position after the DOM swap.
|
||||
@@ -439,6 +803,11 @@ function render() {
|
||||
if (prevFavScroll) favGrid.scrollLeft = prevFavScroll;
|
||||
attachDragScroll(favGrid);
|
||||
}
|
||||
// Hook up the spectrum visualiser to the freshly-rendered canvas. The
|
||||
// visualiser module guards against running outside Electron and against
|
||||
// double-mounting on the same <audio> element.
|
||||
const canvas = app.querySelector('canvas.np-spectrum');
|
||||
if (canvas && player) mountVisualizer(canvas, player);
|
||||
}
|
||||
|
||||
// Pointer-drag horizontal scrolling for the favorites strip. Mouse users can
|
||||
@@ -500,12 +869,65 @@ function scrollFavs(direction) {
|
||||
grid.scrollBy({ left: direction * delta, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Per-zone volume panel: one slider per non-display peer. Master can dim a
|
||||
// specific kiosk's local audio without touching the room's master volume.
|
||||
// Sends `command:peerVolume` which the server forwards only to that user.
|
||||
const _zoneVolumes = new Map(); // userId -> last value (UI memory only)
|
||||
function renderZonesPanel() {
|
||||
if (!ws) return null;
|
||||
const zones = (state.peers || []).filter((p) => p.kind !== 'display');
|
||||
if (!zones.length) return null;
|
||||
return el('div', { class: 'zones-panel', title: 'Per-zone (local) volume for each connected client.' },
|
||||
el('div', { class: 'zones-label' }, 'Zones'),
|
||||
...zones.map((p) => {
|
||||
const uid = p.user?.id;
|
||||
const username = p.user?.username || '?';
|
||||
const cur = _zoneVolumes.get(uid) ?? 0.7;
|
||||
return el('div', { class: 'zone-row' },
|
||||
el('span', { class: 'zone-name', title: `${username} (${p.kind})` }, username),
|
||||
el('input', {
|
||||
type: 'range', min: 0, max: 1, step: 0.05, value: cur,
|
||||
'aria-label': `Volume for ${username}`,
|
||||
onInput: (e) => {
|
||||
const v = Number(e.target.value);
|
||||
_zoneVolumes.set(uid, v);
|
||||
try { ws.send({ type: 'command', action: 'peerVolume', userId: uid, value: v }); } catch { /* ignore */ }
|
||||
const valEl = e.target.parentNode?.querySelector('.zone-val');
|
||||
if (valEl) valEl.textContent = Math.round(v * 100) + '%';
|
||||
}
|
||||
}),
|
||||
el('span', { class: 'zone-val' }, Math.round(cur * 100) + '%')
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function renderFavoritesCard() {
|
||||
const tabs = favoriteTabs();
|
||||
const editable = canEditCurrentTab();
|
||||
const genres = favoriteGenres();
|
||||
const favs = filteredFavorites();
|
||||
const tabName = state.tabUser === state.user.username
|
||||
? 'My favorites'
|
||||
: (state.mainUser && state.tabUser === state.mainUser.username)
|
||||
? `${state.tabUser} (main)`
|
||||
: state.tabUser;
|
||||
return el('div', { class: 'card favs-card' },
|
||||
el('div', { class: 'fav-tabs' }, ...tabs.map((t) => el('button', {
|
||||
class: 'fav-tab' + (t.username === state.tabUser ? ' active' : '') + (t.main ? ' main' : '') + (t.self ? ' self' : ''),
|
||||
title: t.username + (t.main ? ' (main / shared)' : '') + (t.self ? ' (you)' : ''),
|
||||
onClick: () => switchTab(t.username)
|
||||
},
|
||||
el('span', { class: 'fav-tab-glyph' }, t.main ? '★' : (t.avatar_emoji || '●')),
|
||||
el('span', { class: 'fav-tab-name' }, t.username),
|
||||
))),
|
||||
el('div', { class: 'favs-header' },
|
||||
el('h3', {}, `Favorites (${favs.length}${state.favGenre ? `/${state.favorites.length}` : ''})`),
|
||||
el('h3', {},
|
||||
tabName,
|
||||
' ',
|
||||
el('span', { class: 'fav-count' }, `(${favs.length}${state.favGenre ? `/${state.tabFavorites.length}` : ''})`),
|
||||
!editable ? el('span', { class: 'fav-readonly', title: 'Read-only — switch to your own tab to edit' }, ' · read-only') : null
|
||||
),
|
||||
genres.length ? el('select', {
|
||||
class: 'genre-filter',
|
||||
title: 'Filter by genre',
|
||||
@@ -526,22 +948,26 @@ function renderFavoritesCard() {
|
||||
onClick: () => scrollFavs(1)
|
||||
}, '›')
|
||||
),
|
||||
el('div', { class: 'favs-grid' }, ...(favs.length ? favs.map((s) => {
|
||||
const art = s.image_display_url || s.image_url;
|
||||
const active = state.np.stationId === s.id;
|
||||
return el('button', {
|
||||
class: 'fav-tile' + (active ? ' active' : ''),
|
||||
title: s.name,
|
||||
onClick: () => playStation(s)
|
||||
},
|
||||
el('div', {
|
||||
class: 'fav-art' + (art ? '' : ' empty'),
|
||||
style: art ? { backgroundImage: `url("${art}")` } : {}
|
||||
}),
|
||||
el('div', { class: 'fav-name' }, s.name)
|
||||
);
|
||||
}) : [el('div', { class: 'favs-empty' },
|
||||
state.favGenre ? 'No favorites in this genre.' : 'No favorites yet. Star a station to add it.')]))
|
||||
el('div', { class: 'favs-grid' }, ...(state.tabLoading
|
||||
? [el('div', { class: 'favs-empty' }, 'Loading…')]
|
||||
: favs.length ? favs.map((s) => {
|
||||
const art = s.image_display_url || s.image_url;
|
||||
const active = state.np.stationId === s.id;
|
||||
return el('button', {
|
||||
class: 'fav-tile' + (active ? ' active' : ''),
|
||||
title: s.name,
|
||||
onClick: () => playStation(s)
|
||||
},
|
||||
el('div', {
|
||||
class: 'fav-art' + (art ? '' : ' empty'),
|
||||
style: art ? { backgroundImage: `url("${art}")` } : {}
|
||||
}),
|
||||
el('div', { class: 'fav-name' }, s.name)
|
||||
);
|
||||
}) : [el('div', { class: 'favs-empty' },
|
||||
state.favGenre ? 'No favorites in this genre.'
|
||||
: editable ? 'No favorites yet. Star a station to add it.'
|
||||
: `${state.tabUser} has no favorites yet.`)]))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -576,6 +1002,77 @@ function currentDeviceLabel() {
|
||||
return d ? d.label : '—';
|
||||
}
|
||||
|
||||
// ---------- User pill + avatar popover + follow-main ----------
|
||||
|
||||
function renderUserPill() {
|
||||
const trusted = !!state.device.trusted;
|
||||
const others = (state.device.users || []).filter((u) => u.username !== state.user.username);
|
||||
const canSwitch = trusted && others.length > 0;
|
||||
return el('button', {
|
||||
class: 'pill user-pill' + (state.showAvatars ? ' active' : ''),
|
||||
title: canSwitch ? 'Switch user' : (trusted ? 'No other users on this device' : 'This device is not trusted for fast switching'),
|
||||
onClick: () => {
|
||||
if (!canSwitch) return;
|
||||
state.showAvatars = !state.showAvatars;
|
||||
render();
|
||||
}
|
||||
},
|
||||
el('span', {
|
||||
class: 'avatar',
|
||||
style: state.user.avatar_color ? { background: state.user.avatar_color } : {}
|
||||
}, state.user.avatar_emoji || state.user.username.slice(0, 1).toUpperCase()),
|
||||
el('span', {}, state.user.username),
|
||||
canSwitch ? el('span', { class: 'caret' }, '▾') : null
|
||||
);
|
||||
}
|
||||
|
||||
function renderFollowMainPill() {
|
||||
if (!state.mainUser) return null;
|
||||
const mainSlug = `u-${state.mainUser.id}`;
|
||||
const following = state.roomSlug === mainSlug;
|
||||
const isMainHerself = state.user.id === state.mainUser.id;
|
||||
if (isMainHerself && following) return null; // pointless when main controls main
|
||||
return el('button', {
|
||||
class: 'pill follow-pill' + (following ? ' active' : ''),
|
||||
title: following ? `Following ${state.mainUser.username}'s group` : `Join ${state.mainUser.username}'s group (the house default)`,
|
||||
onClick: () => { followMain(); }
|
||||
}, following ? `◉ Following ${state.mainUser.username}` : `↗ Follow ${state.mainUser.username}`);
|
||||
}
|
||||
|
||||
function renderAvatarPopover() {
|
||||
const users = state.device.users || [];
|
||||
return el('div', {
|
||||
class: 'avatar-popover-wrap',
|
||||
onClick: (e) => { if (e.target === e.currentTarget) { state.showAvatars = false; render(); } }
|
||||
},
|
||||
el('div', { class: 'avatar-popover card' },
|
||||
el('div', { class: 'avatar-popover-head' },
|
||||
el('h3', {}, 'Switch user'),
|
||||
el('button', {
|
||||
class: 'close', title: 'Close',
|
||||
onClick: () => { state.showAvatars = false; render(); }
|
||||
}, '×')
|
||||
),
|
||||
el('div', { class: 'avatar-list' }, ...users.map((u) => el('button', {
|
||||
class: 'avatar-row' + (u.username === state.user.username ? ' active' : ''),
|
||||
onClick: () => switchUser(u.username)
|
||||
},
|
||||
el('span', {
|
||||
class: 'avatar lg',
|
||||
style: u.avatar_color ? { background: u.avatar_color } : {}
|
||||
}, u.avatar_emoji || u.username.slice(0, 1).toUpperCase()),
|
||||
el('span', { class: 'avatar-name' },
|
||||
u.username,
|
||||
u.is_main ? el('span', { class: 'avatar-tag' }, ' ★ main') : null,
|
||||
u.username === state.user.username ? el('span', { class: 'avatar-tag dim' }, ' (signed in)') : null
|
||||
)
|
||||
))),
|
||||
el('div', { class: 'avatar-hint' },
|
||||
'Add users via the admin panel → Trust device.')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
clear(app);
|
||||
app.appendChild(el('div', { class: 'login' },
|
||||
|
||||
@@ -267,6 +267,27 @@ body {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Live spectrum analyser canvas (Electron-only feature). Sits between the
|
||||
genre tags and the stats line. Height is fixed; internal width is set by
|
||||
the visualiser module based on devicePixelRatio. */
|
||||
.master .np .meta .np-spectrum {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0.25));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* "Broadcasting" role indicator in the topbar \u2014 makes it explicit that
|
||||
this window IS the room's audio source. */
|
||||
.master .topbar .role-pill {
|
||||
background: rgba(80, 220, 255, 0.10);
|
||||
border-color: rgba(80, 220, 255, 0.35);
|
||||
color: rgb(180, 235, 255);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ---------- Transport (embedded in now-playing) ---------- */
|
||||
.master .np .transport {
|
||||
display: flex;
|
||||
@@ -721,3 +742,271 @@ body {
|
||||
padding: 12px 4px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- Favorite tabs (users-as-folders) ---------- */
|
||||
.master .fav-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px 12px 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.master .fav-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.master .fav-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-2);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
border-bottom: none;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.master .fav-tab:hover {
|
||||
color: var(--fg);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
|
||||
.master .fav-tab.active {
|
||||
background: var(--bg-1);
|
||||
color: var(--fg);
|
||||
border-color: var(--accent);
|
||||
border-bottom-color: var(--bg-1);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.master .fav-tab.main .fav-tab-glyph {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.master .fav-tab.self {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.master .fav-tab-glyph {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.master .fav-count {
|
||||
color: var(--muted-2);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.master .fav-readonly {
|
||||
color: var(--muted-2);
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- User pill + avatar ---------- */
|
||||
.master .topbar .user-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.master .topbar .user-pill .avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--accent);
|
||||
color: #1a0a00;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.master .topbar .user-pill .caret {
|
||||
color: var(--muted-2);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.master .topbar .user-pill.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.master .topbar .follow-pill {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.master .topbar .follow-pill.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
/* ---------- Avatar / user-switch popover ---------- */
|
||||
.avatar-popover-wrap {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.avatar-popover {
|
||||
min-width: 340px;
|
||||
max-width: 480px;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.avatar-popover-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.avatar-popover-head h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.avatar-popover .close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.avatar-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.avatar-row:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.avatar-row.active {
|
||||
background: var(--bg-3);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.avatar.lg {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--accent);
|
||||
color: #1a0a00;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.avatar-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar-tag {
|
||||
color: var(--accent-2);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.avatar-tag.dim {
|
||||
color: var(--muted-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.avatar-hint {
|
||||
padding: 8px 16px 14px;
|
||||
color: var(--muted-2);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
/* === Click-to-start gate modal === */
|
||||
.play-gate-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
.play-gate-card {
|
||||
background: #161a22; border: 1px solid #262b36;
|
||||
border-radius: 20px; padding: 28px 32px;
|
||||
min-width: 320px; max-width: 480px;
|
||||
box-shadow: 0 18px 40px rgba(0,0,0,0.55);
|
||||
text-align: center; color: #e9ecf2;
|
||||
}
|
||||
.play-gate-title { margin: 0 0 8px; font-size: 20px; }
|
||||
.play-gate-station { color: #ff7a3d; font-weight: 600; margin-bottom: 4px; }
|
||||
.play-gate-sub { color: #8a90a0; font-size: 13px; margin-bottom: 16px; }
|
||||
.play-gate-row { display: flex; gap: 12px; justify-content: center; margin-top: 18px; }
|
||||
.play-gate-start, .play-gate-cancel {
|
||||
padding: 10px 22px; border-radius: 14px;
|
||||
border: 1px solid #262b36; background: #1f242e; color: #e9ecf2;
|
||||
font-size: 15px; cursor: pointer;
|
||||
}
|
||||
.play-gate-start { background: #ff7a3d; border-color: #ff7a3d; color: #07080b; font-weight: 600; }
|
||||
.play-gate-start:hover { filter: brightness(1.1); }
|
||||
.play-gate-cancel:hover { background: #0e1116; }
|
||||
.play-gate-dismiss {
|
||||
display: flex; gap: 8px; align-items: center; justify-content: center;
|
||||
margin-top: 14px; color: #8a90a0; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
|
||||
/* === Zones panel === */
|
||||
.zones-panel {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #0e1116;
|
||||
border: 1px solid #262b36;
|
||||
border-radius: 12px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.zones-label {
|
||||
font-size: 11px; color: #8a90a0; text-transform: uppercase; letter-spacing: 0.1em;
|
||||
}
|
||||
.zone-row {
|
||||
display: grid; grid-template-columns: 120px 1fr 44px; align-items: center; gap: 10px;
|
||||
}
|
||||
.zone-name { font-size: 13px; color: #e9ecf2; overflow: hidden; text-overflow: ellipsis; }
|
||||
.zone-row input[type=range] { width: 100%; }
|
||||
.zone-val { font-size: 12px; color: #8a90a0; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
108
web/master/visualizer.js
Normal file
108
web/master/visualizer.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// Live spectrum analyser for the Master view.
|
||||
//
|
||||
// The audio graph (AudioContext + MediaElementSource + AnalyserNode + DelayNode)
|
||||
// is owned by `web/player.js`. We only ask the player for the analyser node
|
||||
// and draw it. This way `createMediaElementSource` is called exactly once per
|
||||
// <audio> element regardless of who needs WebAudio.
|
||||
//
|
||||
// Whether the visualiser actually animates depends on the audio source being
|
||||
// CORS-clean:
|
||||
// - In Electron, the main process injects `Access-Control-Allow-Origin: *`
|
||||
// on media responses, so direct Icecast streams work.
|
||||
// - In a plain browser, the player routes through the same-origin
|
||||
// `/api/stations/:id/proxy` endpoint whenever room sync is active, which
|
||||
// also makes the analyser readable. Solo-mode playback in a plain browser
|
||||
// uses the direct stream and the analyser will read silence-of-data —
|
||||
// that's a fundamental browser CORS constraint, not a bug here.
|
||||
|
||||
let rafId = null;
|
||||
let analyser = null;
|
||||
let dataArr = null;
|
||||
let currentCanvas = null;
|
||||
let activePlayer = null;
|
||||
|
||||
function resizeCanvas(canvas) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = canvas.clientWidth || canvas.width || 1;
|
||||
const cssH = canvas.clientHeight || canvas.height || 1;
|
||||
const w = Math.max(1, Math.round(cssW * dpr));
|
||||
const h = Math.max(1, Math.round(cssH * dpr));
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
if (canvas.height !== h) canvas.height = h;
|
||||
}
|
||||
|
||||
function draw(canvas) {
|
||||
// Late binding: the player builds its graph lazily on first 'playing'
|
||||
// event, so the analyser may only appear a moment after we mount.
|
||||
if (!analyser && activePlayer) {
|
||||
analyser = activePlayer.getAnalyser();
|
||||
if (analyser) dataArr = new Uint8Array(analyser.frequencyBinCount);
|
||||
}
|
||||
if (!analyser || !dataArr) return;
|
||||
resizeCanvas(canvas);
|
||||
const ctx2d = canvas.getContext('2d');
|
||||
if (!ctx2d) return;
|
||||
analyser.getByteFrequencyData(dataArr);
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx2d.clearRect(0, 0, w, h);
|
||||
|
||||
// Aggregate the ~512 raw bins into ~64 visual bars for legibility on the
|
||||
// master stage. We weight lower frequencies (musically more interesting)
|
||||
// by sampling logarithmically.
|
||||
const bars = 64;
|
||||
const binCount = dataArr.length;
|
||||
const gap = Math.max(1, Math.floor(w / bars / 6));
|
||||
const barW = (w - gap * (bars - 1)) / bars;
|
||||
|
||||
const grad = ctx2d.createLinearGradient(0, h, 0, 0);
|
||||
grad.addColorStop(0, 'rgba(80, 220, 255, 0.85)');
|
||||
grad.addColorStop(0.6, 'rgba(140, 120, 255, 0.85)');
|
||||
grad.addColorStop(1, 'rgba(255, 100, 200, 0.95)');
|
||||
ctx2d.fillStyle = grad;
|
||||
|
||||
for (let i = 0; i < bars; i++) {
|
||||
const t0 = i / bars;
|
||||
const t1 = (i + 1) / bars;
|
||||
const lo = Math.floor(Math.pow(t0, 2) * binCount);
|
||||
const hi = Math.max(lo + 1, Math.floor(Math.pow(t1, 2) * binCount));
|
||||
let sum = 0;
|
||||
for (let j = lo; j < hi && j < binCount; j++) sum += dataArr[j];
|
||||
const avg = sum / (hi - lo); // 0..255
|
||||
const norm = Math.pow(avg / 255, 0.7);
|
||||
const barH = Math.max(2, Math.round(norm * h));
|
||||
const x = Math.round(i * (barW + gap));
|
||||
ctx2d.fillRect(x, h - barH, Math.max(1, Math.round(barW)), barH);
|
||||
}
|
||||
}
|
||||
|
||||
function loop() {
|
||||
if (!currentCanvas) { rafId = null; return; }
|
||||
draw(currentCanvas);
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the analyser tap to `player.audio` (via the player's shared graph)
|
||||
* and start drawing into `canvas`. Idempotent.
|
||||
*/
|
||||
export function mountVisualizer(canvas, player) {
|
||||
if (!canvas || !player?.audio) return;
|
||||
activePlayer = player;
|
||||
// Tickle the graph so the analyser is wired up before audio starts.
|
||||
if (typeof player._ensureAudioGraph === 'function') {
|
||||
try { player._ensureAudioGraph(); } catch { /* CORS — see file header */ }
|
||||
}
|
||||
analyser = player.getAnalyser();
|
||||
if (analyser) dataArr = new Uint8Array(analyser.frequencyBinCount);
|
||||
currentCanvas = canvas;
|
||||
if (rafId == null) rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
export function unmountVisualizer() {
|
||||
currentCanvas = null;
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
644
web/player.js
644
web/player.js
@@ -1,17 +1,151 @@
|
||||
import Hls from 'hls.js';
|
||||
import { api } from './shared/api.js';
|
||||
import { api, isAbort } from './shared/api.js';
|
||||
|
||||
// Shared symbols so the visualiser and the player attach to the same
|
||||
// AudioContext / MediaElementSource for a given <audio> element.
|
||||
// `createMediaElementSource` may only be called once per element.
|
||||
const CTX_KEY = Symbol.for('oradio.audio.ctx');
|
||||
const SRC_KEY = Symbol.for('oradio.audio.src');
|
||||
const ANALYSER_KEY = Symbol.for('oradio.audio.analyser');
|
||||
const DELAY_KEY = Symbol.for('oradio.audio.delay');
|
||||
const GAIN_KEY = Symbol.for('oradio.audio.gain');
|
||||
|
||||
// How much audio every client is willing to hold in its DelayNode buffer.
|
||||
// 30 s is the safe ceiling for most browsers' DelayNode maxDelayTime.
|
||||
const MAX_DELAY_SEC = 30;
|
||||
// 3s is responsive on a well-connected device and feels close to live.
|
||||
// Slow phones (iPhone cold-start ~4s) will report 'lagging' on the sync
|
||||
// chip until the user bumps the buffer via the room-pill slider — that's
|
||||
// the right UX escape hatch rather than forcing everyone to pay a 12s
|
||||
// startup lag.
|
||||
const DEFAULT_BUFFER_MS = 3000;
|
||||
const MIN_BUFFER_MS = 1500;
|
||||
const MAX_BUFFER_MS = 25000;
|
||||
|
||||
// Light, opt-in tracing for the sync pipeline. Enable in the browser via:
|
||||
// localStorage.setItem('oradio.debugSync', '1')
|
||||
function syncDebugOn() {
|
||||
try { return localStorage.getItem('oradio.debugSync') === '1'; }
|
||||
catch { return false; }
|
||||
}
|
||||
export function logSync(tag, payload) {
|
||||
if (!syncDebugOn()) return;
|
||||
try { console.log('[player:sync]', tag, payload || ''); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function readBufferPref() {
|
||||
try {
|
||||
const raw = Number(localStorage.getItem('oradio.syncBufferMs'));
|
||||
if (Number.isFinite(raw) && raw >= MIN_BUFFER_MS && raw <= MAX_BUFFER_MS) return raw;
|
||||
} catch { /* private mode */ }
|
||||
return DEFAULT_BUFFER_MS;
|
||||
}
|
||||
|
||||
export class Player {
|
||||
constructor({ onState }) {
|
||||
this.audio = new Audio();
|
||||
this.audio.preload = 'none';
|
||||
// Note: do NOT set crossOrigin — most Icecast/SHOUTcast servers don't send
|
||||
// CORS headers and the browser will then refuse to play the stream.
|
||||
// iOS Safari refuses to play inline without this; without it the
|
||||
// OS may also pause/route audio unpredictably which makes sync
|
||||
// measurement jump.
|
||||
this.audio.playsInline = true;
|
||||
this.audio.setAttribute('playsinline', '');
|
||||
this.audio.setAttribute('webkit-playsinline', '');
|
||||
this.hls = null;
|
||||
this.station = null;
|
||||
this.streamId = null;
|
||||
this.usingProxy = false;
|
||||
// Volume tracked separately from `audio.volume` because once the
|
||||
// WebAudio graph is built we pin the element to 1 and drive the
|
||||
// GainNode at the END of the chain (post-DelayNode) instead.
|
||||
this._logicalVolume = this.audio.volume;
|
||||
// Monotonically increasing token incremented on every play()/stop().
|
||||
// Any async continuation that captured an older token bails out so
|
||||
// a slow /resolve from station A can't clobber the audio src after
|
||||
// the user has already moved on to station B.
|
||||
this._playGen = 0;
|
||||
// AbortController for the currently in-flight /resolve. Aborted on
|
||||
// stop() or whenever a new play() begins.
|
||||
this._playAbort = null;
|
||||
this.onState = onState || (() => { });
|
||||
this.audio.addEventListener('playing', () => this.emit({ playing: true, loading: false, error: null }));
|
||||
this.audio.addEventListener('pause', () => this.emit({ playing: false, loading: false }));
|
||||
// Cross-client sync state.
|
||||
//
|
||||
// Three anchoring sources, tried in order of accuracy each tick:
|
||||
// 1. HLS PROGRAM-DATE-TIME — for HLS streams that publish PDT,
|
||||
// every client locks to absolute stream time, so they converge
|
||||
// regardless of join order or network history.
|
||||
// 2. Master `sync-pos` — for non-HLS streams the master display
|
||||
// announces (atServerNow, masterCT) every ~2 s; peers compute
|
||||
// their drift versus master's projected position.
|
||||
// 3. Local first decoded sample — fallback before either of the
|
||||
// above is available.
|
||||
//
|
||||
// DelayNode holds `bufferMs` on every client so each speaker emits
|
||||
// its (corrected) audio at the same wall-clock instant. A small PI
|
||||
// controller nudges `audio.playbackRate` (±0.5 % max) to keep the
|
||||
// local decoder's rate locked to the shared timeline.
|
||||
this.sync = {
|
||||
enabled: false,
|
||||
clock: null, // RoomClock instance
|
||||
startedAt: null, // server epoch ms when room playback began
|
||||
t0Wall: null, // clock.now() at this client's first decoded sample
|
||||
t0Audio: null, // audio.currentTime at that same moment
|
||||
// Master sync-pos snapshot (peers only; master ignores its own).
|
||||
masterStationId: null,
|
||||
masterCT: null, // master's audio.currentTime at atServerNow
|
||||
masterAt: null, // server epoch ms when masterCT was sampled
|
||||
// Latest HLS PDT observation (informational; PDT is read live each tick).
|
||||
pdtMs: null,
|
||||
// PI controller state.
|
||||
integral: 0,
|
||||
// Most-recent active anchor source — 'hls-pdt' | 'master' | 'local'.
|
||||
anchorSource: null,
|
||||
bufferMs: readBufferPref(),
|
||||
currentDelay: 0,
|
||||
targetDelay: 0,
|
||||
driftMs: 0, // measured audio-vs-clock drift (ms; positive = audio ahead)
|
||||
rate: 1.0, // last applied audio.playbackRate
|
||||
error: 0,
|
||||
status: 'off', // 'off' | 'no-anchor' | 'measuring' | 'in-sync' | 'lagging' | 'no-buffer'
|
||||
timer: null
|
||||
};
|
||||
this.onSyncChange = null; // optional sync status listener
|
||||
this.audio.addEventListener('playing', () => {
|
||||
// First decoded sample after a fresh load — anchor the drift
|
||||
// reference. From now on we expect audio.currentTime to track
|
||||
// (clock.now() - t0Wall) / 1000 + t0Audio. Any divergence is
|
||||
// drift to correct via playbackRate.
|
||||
if (this.sync.enabled && this.sync.t0Wall == null && this.sync.clock) {
|
||||
this.sync.t0Wall = this.sync.clock.now();
|
||||
this.sync.t0Audio = this.audio.currentTime;
|
||||
logSync('anchor t0', { t0Wall: this.sync.t0Wall, t0Audio: this.sync.t0Audio });
|
||||
// Graph was (ideally) pre-built in play() so the DelayNode is
|
||||
// already in the path; this just guarantees it for the case
|
||||
// where sync was toggled on after playback started.
|
||||
this._ensureAudioGraph();
|
||||
this._syncTick();
|
||||
}
|
||||
// Notify external listeners (master uses this to re-anchor
|
||||
// `started_at` to its actual first decoded sample).
|
||||
if (this.onPlayingOnce && !this._announcedPlayingForStation) {
|
||||
this._announcedPlayingForStation = this.station?.id ?? true;
|
||||
try { this.onPlayingOnce(this.station); } catch (err) { console.warn('[player] onPlayingOnce', err); }
|
||||
}
|
||||
this.emit({ playing: true, loading: false, error: null });
|
||||
});
|
||||
this.audio.addEventListener('pause', () => {
|
||||
// play() sets _silentStop before its internal stop() so the
|
||||
// resulting (asynchronous) pause event doesn't emit a transient
|
||||
// {stationId: old, playing: false} to onState — the consumer
|
||||
// would otherwise re-broadcast it to the room and every kiosk
|
||||
// would briefly see the wrong state. User-initiated stop() /
|
||||
// pause() leave the flag clear and emit normally.
|
||||
if (this._silentStop) {
|
||||
this._silentStop = false;
|
||||
return;
|
||||
}
|
||||
this.emit({ playing: false, loading: false });
|
||||
});
|
||||
this.audio.addEventListener('waiting', () => this.emit({ loading: true }));
|
||||
this.audio.addEventListener('error', () => {
|
||||
const code = this.audio.error?.code;
|
||||
@@ -27,21 +161,74 @@ export class Player {
|
||||
stationId: this.station?.id ?? null,
|
||||
stationName: this.station?.name ?? null,
|
||||
genres: this.station?.genres || [],
|
||||
volume: this.audio.volume,
|
||||
volume: this._logicalVolume ?? this.audio.volume,
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
setVolume(v) {
|
||||
this.audio.volume = Math.max(0, Math.min(1, v));
|
||||
const clamped = Math.max(0, Math.min(1, Number(v) || 0));
|
||||
this._logicalVolume = clamped;
|
||||
const gain = this.audio[GAIN_KEY];
|
||||
const ctx = this.audio[CTX_KEY];
|
||||
if (gain && ctx) {
|
||||
// Route via the post-DelayNode GainNode so the change is audible
|
||||
// immediately, not queued behind `bufferMs` of buffered audio.
|
||||
try {
|
||||
const t = ctx.currentTime;
|
||||
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
|
||||
// is applied pre-delay and would compound with the GainNode.
|
||||
if (this.audio.volume !== 1) this.audio.volume = 1;
|
||||
} else {
|
||||
this.audio.volume = clamped;
|
||||
}
|
||||
this.emit({});
|
||||
}
|
||||
|
||||
/** Alias kept for clarity: local <audio>.volume only; never broadcasts. */
|
||||
setLocalVolume(v) { this.setVolume(v); }
|
||||
|
||||
setMuted(m) {
|
||||
this.audio.muted = !!m;
|
||||
this.emit({});
|
||||
}
|
||||
getMuted() { return !!this.audio.muted; }
|
||||
|
||||
/**
|
||||
* Route output to a specific audio sink. Must call BOTH the audio element
|
||||
* setSinkId() AND the AudioContext setSinkId() — once the WebAudio graph
|
||||
* is built, audio exits through `AudioContext.destination` and the audio
|
||||
* element's sink is bypassed entirely.
|
||||
*/
|
||||
async setSinkId(deviceId) {
|
||||
const id = String(deviceId || '');
|
||||
this._sinkId = id;
|
||||
if (this.audio.setSinkId) {
|
||||
try { await this.audio.setSinkId(id); }
|
||||
catch (err) { console.warn('[player] audio.setSinkId failed', err); }
|
||||
}
|
||||
const ctx = this.audio[CTX_KEY];
|
||||
if (ctx && typeof ctx.setSinkId === 'function') {
|
||||
try { await ctx.setSinkId(id); }
|
||||
catch (err) { console.warn('[player] AudioContext.setSinkId failed', err); }
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
// Invalidate any in-flight play() continuation immediately.
|
||||
this._playGen++;
|
||||
if (this._playAbort) { try { this._playAbort.abort(); } catch { /* ignore */ } this._playAbort = null; }
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
if (this.hls) { this.hls.destroy(); this.hls = null; }
|
||||
this._resetSyncAnchor();
|
||||
}
|
||||
|
||||
togglePause() {
|
||||
@@ -51,18 +238,71 @@ export class Player {
|
||||
}
|
||||
|
||||
async play(station) {
|
||||
// Mute the pause event that this internal stop() will produce so
|
||||
// the consumer doesn't see a transient {playing:false, stationId:
|
||||
// old} mid-switch. The pause listener consumes the flag and resets.
|
||||
if (!this.audio.paused) this._silentStop = true;
|
||||
this.stop();
|
||||
const gen = ++this._playGen;
|
||||
this.station = station;
|
||||
this.emit({ playing: false, loading: true });
|
||||
let resolved;
|
||||
// Reset the first-playing latch so onPlayingOnce fires once per new
|
||||
// station (master uses this to re-anchor started_at).
|
||||
this._announcedPlayingForStation = null;
|
||||
logSync('play()', { stationId: station?.id, syncEnabled: this.sync.enabled, gen });
|
||||
// Emit BEFORE the network call so the UI flips to the new
|
||||
// station name immediately. Otherwise on slow phones the old
|
||||
// name stays visible for the duration of /resolve + connect.
|
||||
this.emit({ playing: false, loading: true, error: null });
|
||||
// Pre-warm AudioContext while we still have the user-gesture
|
||||
// credit. iOS will keep it running for the rest of the session.
|
||||
if (this.sync.enabled) {
|
||||
const Ctor = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext);
|
||||
if (Ctor) {
|
||||
let ctx = this.audio[CTX_KEY];
|
||||
if (!ctx) { try { ctx = new Ctor(); this.audio[CTX_KEY] = ctx; } catch { /* ignore */ } }
|
||||
if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => { });
|
||||
}
|
||||
}
|
||||
let resolved, streamMeta;
|
||||
const ac = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||
this._playAbort = ac;
|
||||
try {
|
||||
const r = await api.post(`/api/stations/${station.id}/resolve`);
|
||||
const r = await api.post(`/api/stations/${station.id}/resolve`, null, ac ? { signal: ac.signal } : undefined);
|
||||
// The user may have picked another station while we were waiting.
|
||||
if (gen !== this._playGen) { logSync('play() stale-resolve discarded', { gen }); return; }
|
||||
resolved = r.resolved;
|
||||
streamMeta = r.stream || null;
|
||||
} catch (err) {
|
||||
if (isAbort(err) || gen !== this._playGen) return;
|
||||
this.emit({ playing: false, loading: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
const url = resolved.url;
|
||||
this.streamId = streamMeta?.id ?? null;
|
||||
|
||||
// When room sync is active and the stream isn't HLS, route through the
|
||||
// same-origin proxy. That guarantees a CORS-clean MediaElementSource
|
||||
// on every platform (iOS Safari, Android, Firefox, plain Chrome) so
|
||||
// the DelayNode-based sync graph can actually be built. Otherwise
|
||||
// some browsers refuse to let WebAudio touch the cross-origin stream.
|
||||
const wantProxy = this.sync.enabled && resolved.format !== 'hls';
|
||||
this.usingProxy = wantProxy;
|
||||
let url = resolved.url;
|
||||
if (wantProxy && this.streamId != null) {
|
||||
url = `/api/stations/${station.id}/proxy?streamId=${this.streamId}`;
|
||||
}
|
||||
|
||||
// crossOrigin: not needed for the same-origin proxy. For direct
|
||||
// streams we only set it when running inside Electron (main process
|
||||
// rewrites CORS headers); regular browsers MUST leave it unset or
|
||||
// playback fails on Icecast/SHOUTcast servers.
|
||||
if (wantProxy) {
|
||||
this.audio.removeAttribute('crossorigin');
|
||||
} else if (typeof window !== 'undefined' && window.oradioNative?.isElectron) {
|
||||
this.audio.crossOrigin = 'anonymous';
|
||||
} else {
|
||||
this.audio.removeAttribute('crossorigin');
|
||||
}
|
||||
|
||||
if (resolved.format === 'hls') {
|
||||
if (Hls.isSupported()) {
|
||||
this.hls = new Hls({ enableWorker: true });
|
||||
@@ -77,7 +317,389 @@ export class Player {
|
||||
}
|
||||
} else {
|
||||
this.audio.src = url;
|
||||
// Pre-build the WebAudio graph BEFORE play resolves so the
|
||||
// DelayNode sits in the audio path from the very first decoded
|
||||
// sample. On iOS this matters: otherwise a few seconds of audio
|
||||
// play straight to the speakers before the graph is attached
|
||||
// and the client appears "seconds behind" the rest of the room.
|
||||
if (this.sync.enabled) this._ensureAudioGraph();
|
||||
this.audio.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Cross-client stream sync --------
|
||||
|
||||
/**
|
||||
* Enable buffer-based output alignment. Every client routes audio
|
||||
* through a DelayNode set to `bufferMs` and uses the shared RoomClock
|
||||
* to keep the local decoder's pace locked to the server timeline via
|
||||
* micro-adjustments to `audio.playbackRate`. Net effect: every
|
||||
* speaker emits the live broadcast at the same wall-clock instant,
|
||||
* within network-jitter.
|
||||
*/
|
||||
enableSync({ clock, startedAt }) {
|
||||
const wasEnabled = this.sync.enabled;
|
||||
this.sync.enabled = true;
|
||||
this.sync.clock = clock;
|
||||
this.sync.startedAt = startedAt || null;
|
||||
this.sync.status = startedAt ? 'measuring' : 'no-anchor';
|
||||
if (!wasEnabled) this._resetSyncAnchor();
|
||||
if (!this.sync.timer) {
|
||||
this.sync.timer = setInterval(() => this._syncTick(), 500);
|
||||
}
|
||||
// If audio is already playing without a drift anchor (e.g. user
|
||||
// toggled sync on mid-stream), capture it now.
|
||||
if (!this.audio.paused && this.audio.readyState >= 2 && this.sync.t0Wall == null && this.sync.clock) {
|
||||
this.sync.t0Wall = this.sync.clock.now();
|
||||
this.sync.t0Audio = this.audio.currentTime;
|
||||
}
|
||||
this._ensureAudioGraph();
|
||||
this._syncTick();
|
||||
this._emitSync();
|
||||
}
|
||||
|
||||
updateSyncTarget(startedAt) {
|
||||
if (!this.sync.enabled) return;
|
||||
if (startedAt === this.sync.startedAt) return;
|
||||
this.sync.startedAt = startedAt || null;
|
||||
// A change in the room's started_at means a new playback session — drop
|
||||
// our anchor so we re-measure once audio resumes.
|
||||
this._resetSyncAnchor();
|
||||
// Reflect the truth: we're no longer aligned to anything until the
|
||||
// new stream actually starts. Without this the chip stays green
|
||||
// while the old audio is still playing.
|
||||
this.sync.status = startedAt ? 'measuring' : 'no-anchor';
|
||||
this._emitSync();
|
||||
this._syncTick();
|
||||
}
|
||||
|
||||
disableSync() {
|
||||
this.sync.enabled = false;
|
||||
this.sync.startedAt = null;
|
||||
this.sync.status = 'off';
|
||||
if (this.sync.timer) { clearInterval(this.sync.timer); this.sync.timer = null; }
|
||||
try { this.audio.playbackRate = 1.0; } catch { /* ignore */ }
|
||||
// Drain the DelayNode so we don't keep holding several seconds of
|
||||
// audio when the user switches back to solo mode.
|
||||
const delay = this.audio[DELAY_KEY];
|
||||
const ctx = this.audio[CTX_KEY];
|
||||
if (delay && ctx) {
|
||||
try {
|
||||
delay.delayTime.cancelScheduledValues(ctx.currentTime);
|
||||
delay.delayTime.linearRampToValueAtTime(0, ctx.currentTime + 0.2);
|
||||
} catch { /* not started yet */ }
|
||||
}
|
||||
this.sync.currentDelay = 0;
|
||||
this.sync.targetDelay = 0;
|
||||
this._resetSyncAnchor();
|
||||
this._emitSync();
|
||||
}
|
||||
|
||||
/** User-tunable buffer in ms. Larger = easier sync, longer to-start lag. */
|
||||
setSyncBufferMs(ms) {
|
||||
const clamped = Math.max(MIN_BUFFER_MS, Math.min(MAX_BUFFER_MS, Math.round(Number(ms) || 0)));
|
||||
if (clamped === this.sync.bufferMs) return clamped;
|
||||
this.sync.bufferMs = clamped;
|
||||
try { localStorage.setItem('oradio.syncBufferMs', String(clamped)); } catch { /* private mode */ }
|
||||
this._syncTick();
|
||||
this._emitSync();
|
||||
return clamped;
|
||||
}
|
||||
|
||||
getSyncBufferMs() { return this.sync.bufferMs; }
|
||||
|
||||
/** Web Audio handles for the visualiser to tap into. */
|
||||
getAudioContext() { return this.audio[CTX_KEY] || null; }
|
||||
getAnalyser() { return this.audio[ANALYSER_KEY] || null; }
|
||||
|
||||
_resetSyncAnchor() {
|
||||
this.sync.t0Wall = null;
|
||||
this.sync.t0Audio = null;
|
||||
this.sync.masterCT = null;
|
||||
this.sync.masterAt = null;
|
||||
this.sync.masterStationId = null;
|
||||
this.sync.pdtMs = null;
|
||||
this.sync.integral = 0;
|
||||
this.sync.anchorSource = null;
|
||||
this.sync.driftMs = 0;
|
||||
this.sync.rate = 1.0;
|
||||
try { this.audio.playbackRate = 1.0; } catch { /* ignore */ }
|
||||
this.sync.error = 0;
|
||||
this.sync.currentDelay = 0;
|
||||
this.sync.targetDelay = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a `sync-pos` snapshot from the room's master display. Used as
|
||||
* the anchor for non-HLS streams (HLS prefers PROGRAM-DATE-TIME). Master
|
||||
* ignores its own broadcasts — the server forwards sync-pos with except=
|
||||
* sender so this only fires on peers.
|
||||
*/
|
||||
acceptMasterPos({ stationId, masterCT, atServerNow, bufferMs }) {
|
||||
if (!this.sync.enabled) return;
|
||||
if (!Number.isFinite(masterCT) || !Number.isFinite(atServerNow)) return;
|
||||
// Only trust positions for the station we believe we're playing —
|
||||
// otherwise a stale snapshot for the previous station would mis-anchor
|
||||
// immediately after a switch.
|
||||
const myId = this.station?.id ?? null;
|
||||
if (stationId != null && myId != null && stationId !== myId) return;
|
||||
this.sync.masterStationId = stationId ?? myId;
|
||||
this.sync.masterCT = masterCT;
|
||||
this.sync.masterAt = atServerNow;
|
||||
// Switching anchor source — drop integral so it doesn't blow up.
|
||||
this.sync.integral = 0;
|
||||
// Buffer must be the same on every client — otherwise the DelayNode
|
||||
// delays differ and speakers play the same content at different
|
||||
// wall-clock times. Master is the source of truth; peers adopt.
|
||||
if (Number.isFinite(bufferMs) && bufferMs !== this.sync.bufferMs) {
|
||||
this.sync.bufferMs = Math.max(MIN_BUFFER_MS, Math.min(MAX_BUFFER_MS, Math.round(bufferMs)));
|
||||
// Don't write to localStorage here — that pref is the user's
|
||||
// *requested* buffer and we want it back when they leave sync.
|
||||
this._emitSync();
|
||||
}
|
||||
logSync('master-pos', { stationId, masterCT, atServerNow, bufferMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build (once per <audio>) the Web Audio chain:
|
||||
* MediaElementSource -> DelayNode -> AnalyserNode -> destination
|
||||
* The analyser is placed AFTER the delay so the spectrum it produces
|
||||
* matches the audio the user actually hears (the speakers emit the
|
||||
* delayed signal). Consequence: the visualiser is silent for `bufferMs`
|
||||
* 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`
|
||||
* throws and we silently bail — sync degrades to 'no-buffer' in that
|
||||
* environment but plain playback continues.
|
||||
*/
|
||||
_ensureAudioGraph() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
const a = this.audio;
|
||||
let ctx = a[CTX_KEY];
|
||||
if (!ctx) {
|
||||
try { ctx = new Ctor(); } catch { return null; }
|
||||
a[CTX_KEY] = ctx;
|
||||
// Apply any sink chosen before the context existed.
|
||||
if (this._sinkId && typeof ctx.setSinkId === 'function') {
|
||||
ctx.setSinkId(this._sinkId).catch((err) =>
|
||||
console.warn('[player] AudioContext.setSinkId on create failed', err));
|
||||
}
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume().catch(() => { /* needs user gesture; the play click counts */ });
|
||||
}
|
||||
let src = a[SRC_KEY];
|
||||
if (!src) {
|
||||
try {
|
||||
src = ctx.createMediaElementSource(a);
|
||||
a[SRC_KEY] = src;
|
||||
} catch (err) {
|
||||
// CORS-tainted media — common in plain browsers on direct
|
||||
// Icecast streams. Sync via DelayNode is unavailable here.
|
||||
console.warn('[player] WebAudio unavailable (CORS):', err?.message || err);
|
||||
return null;
|
||||
}
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
const delay = ctx.createDelay(MAX_DELAY_SEC);
|
||||
delay.delayTime.value = 0;
|
||||
const gain = ctx.createGain();
|
||||
// Adopt the audio element's current volume, then pin the element
|
||||
// to 1 so volume control happens post-delay (immediate) instead
|
||||
// of pre-delay (queued behind `bufferMs` of audio).
|
||||
gain.gain.value = (this._logicalVolume ?? a.volume);
|
||||
a.volume = 1;
|
||||
src.connect(delay);
|
||||
delay.connect(analyser);
|
||||
analyser.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
a[ANALYSER_KEY] = analyser;
|
||||
a[DELAY_KEY] = delay;
|
||||
a[GAIN_KEY] = gain;
|
||||
}
|
||||
return { ctx, src, analyser: a[ANALYSER_KEY], delay: a[DELAY_KEY], gain: a[GAIN_KEY] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best available anchor for this tick and compute drift.
|
||||
*
|
||||
* Priority:
|
||||
* 1. HLS PROGRAM-DATE-TIME (absolute stream time) — converges any
|
||||
* number of clients to the same stream moment.
|
||||
* 2. Master `sync-pos` — projects master's audio.currentTime forward
|
||||
* using the shared room clock.
|
||||
* 3. Local first-frame anchor.
|
||||
*
|
||||
* Returns { source, driftSec, expectedAudio, pdtMs? } or null.
|
||||
*/
|
||||
_measureDrift() {
|
||||
const s = this.sync;
|
||||
const audio = this.audio;
|
||||
if (!s.clock || !s.clock.synced) return null;
|
||||
const clockNow = s.clock.now();
|
||||
const ct = audio.currentTime;
|
||||
|
||||
// 1) HLS PROGRAM-DATE-TIME. hls.js exposes `playingDate` once it has
|
||||
// parsed a fragment carrying PDT. Sync rule: at wall-clock T,
|
||||
// every client plays the stream moment PDT == (T - bufferMs).
|
||||
const pd = this.hls?.playingDate;
|
||||
if (pd && typeof pd.getTime === 'function') {
|
||||
const pdtMs = pd.getTime();
|
||||
if (Number.isFinite(pdtMs) && pdtMs > 0) {
|
||||
const expectedPdtMs = clockNow - s.bufferMs;
|
||||
const driftSec = (pdtMs - expectedPdtMs) / 1000;
|
||||
return {
|
||||
source: 'hls-pdt',
|
||||
driftSec,
|
||||
expectedAudio: ct - driftSec,
|
||||
pdtMs
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Master sync-pos. Project master's audio CT forward by elapsed
|
||||
// server time since the snapshot.
|
||||
const myId = this.station?.id ?? null;
|
||||
if (s.masterCT != null && s.masterAt != null
|
||||
&& (s.masterStationId == null || myId == null || s.masterStationId === myId)) {
|
||||
const expectedAudio = s.masterCT + (clockNow - s.masterAt) / 1000;
|
||||
const driftSec = ct - expectedAudio;
|
||||
return { source: 'master', driftSec, expectedAudio };
|
||||
}
|
||||
|
||||
// 3) Local first-frame anchor.
|
||||
if (s.t0Wall != null && s.t0Audio != null) {
|
||||
const expectedAudio = s.t0Audio + (clockNow - s.t0Wall) / 1000;
|
||||
const driftSec = ct - expectedAudio;
|
||||
return { source: 'local', driftSec, expectedAudio };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_syncTick() {
|
||||
const s = this.sync;
|
||||
if (!s.enabled || !s.clock) return;
|
||||
if (!s.startedAt) {
|
||||
if (s.status !== 'no-anchor') { s.status = 'no-anchor'; this._emitSync(); }
|
||||
return;
|
||||
}
|
||||
if (this.audio.paused || this.audio.readyState < 2) return;
|
||||
// Hold 'measuring' until the clock has at least one good sample —
|
||||
// before then the offset is 0 and drift estimates would be junk.
|
||||
if (!s.clock.synced) {
|
||||
if (s.status !== 'measuring') { s.status = 'measuring'; this._emitSync(); }
|
||||
return;
|
||||
}
|
||||
|
||||
const graph = this._ensureAudioGraph();
|
||||
|
||||
// DelayNode hold = bufferMs for every client. Combined with the
|
||||
// rate-trim controller below, this aligns every speaker's output to
|
||||
// the same wall-clock instant on the shared timeline.
|
||||
const desiredSec = s.bufferMs / 1000;
|
||||
const clamped = Math.max(0, Math.min(MAX_DELAY_SEC, desiredSec));
|
||||
s.targetDelay = clamped;
|
||||
s.error = 0;
|
||||
|
||||
if (graph?.delay) {
|
||||
const cur = graph.delay.delayTime.value;
|
||||
if (Math.abs(cur - clamped) > 0.01) {
|
||||
try {
|
||||
const t = graph.ctx.currentTime;
|
||||
graph.delay.delayTime.cancelScheduledValues(t);
|
||||
graph.delay.delayTime.setValueAtTime(cur, t);
|
||||
graph.delay.delayTime.linearRampToValueAtTime(clamped, t + 0.25);
|
||||
} catch {
|
||||
graph.delay.delayTime.value = clamped;
|
||||
}
|
||||
s.currentDelay = clamped;
|
||||
}
|
||||
} else {
|
||||
s.currentDelay = 0;
|
||||
}
|
||||
|
||||
const measurement = this._measureDrift();
|
||||
if (!measurement) {
|
||||
// No usable anchor yet (e.g. local mode without a t0). Hold the
|
||||
// rate at 1.0 and wait for either HLS PDT, a master sync-pos, or
|
||||
// the audio's first decoded sample.
|
||||
return;
|
||||
}
|
||||
const driftSec = measurement.driftSec;
|
||||
s.driftMs = driftSec * 1000;
|
||||
if (measurement.source !== s.anchorSource) {
|
||||
// Anchor source changed (e.g. PDT became available) — integrator
|
||||
// is suspect across the transition; drop it.
|
||||
s.integral = 0;
|
||||
s.anchorSource = measurement.source;
|
||||
logSync('anchor source', { source: measurement.source });
|
||||
}
|
||||
if (measurement.pdtMs != null) s.pdtMs = measurement.pdtMs;
|
||||
|
||||
// Re-anchor escape hatch for catastrophic drift (suspended tab,
|
||||
// decoder stall): drop integral, reset local anchor, leave rate at
|
||||
// 1.0 for one tick. Only meaningful for the local-anchor path; PDT
|
||||
// and master-pos re-measure live so they self-correct.
|
||||
if (Math.abs(driftSec) > 1.0) {
|
||||
logSync('drift re-anchor', { source: measurement.source, driftSec });
|
||||
s.t0Wall = s.clock.now();
|
||||
s.t0Audio = this.audio.currentTime;
|
||||
s.integral = 0;
|
||||
try { this.audio.playbackRate = 1.0; s.rate = 1.0; } catch { /* ignore */ }
|
||||
} else {
|
||||
// PI controller. 10ms deadband (below that the measurement is
|
||||
// dominated by clock jitter). dt = tick interval = 0.5s.
|
||||
// - positive drift = audio ahead of where it should be → rate < 1
|
||||
// - negative drift = audio behind → rate > 1
|
||||
// ±1% rate cap: still inaudible for music, but a 200ms gap closes
|
||||
// in ~20s instead of 40s. Larger caps start to colour speech.
|
||||
const Kp = 0.05;
|
||||
const Ki = 0.003;
|
||||
const dt = 0.5;
|
||||
const RATE_CAP = 0.010;
|
||||
const usedErr = Math.abs(driftSec) < 0.010 ? 0 : driftSec;
|
||||
s.integral = Math.max(-1.0, Math.min(1.0, s.integral + usedErr * dt));
|
||||
let adj = -(Kp * usedErr + Ki * s.integral);
|
||||
adj = Math.max(-RATE_CAP, Math.min(RATE_CAP, adj));
|
||||
const rate = 1.0 + adj;
|
||||
if (Math.abs(rate - s.rate) > 0.0001) {
|
||||
try { this.audio.playbackRate = rate; s.rate = rate; }
|
||||
catch { /* some browsers refuse rate changes on streamed media */ }
|
||||
}
|
||||
}
|
||||
|
||||
let status;
|
||||
if (!graph?.delay) status = 'no-buffer';
|
||||
else if (Math.abs(driftSec) > 0.05) status = 'lagging';
|
||||
else status = 'in-sync';
|
||||
if (status !== s.status) {
|
||||
s.status = status;
|
||||
logSync('status', { status, source: measurement.source, driftMs: s.driftMs, delay: clamped, rate: s.rate });
|
||||
this._emitSync();
|
||||
}
|
||||
}
|
||||
|
||||
_emitSync() {
|
||||
if (this.onSyncChange) {
|
||||
this.onSyncChange({
|
||||
status: this.sync.status,
|
||||
error: this.sync.error,
|
||||
startedAt: this.sync.startedAt,
|
||||
bufferMs: this.sync.bufferMs,
|
||||
delay: this.sync.currentDelay,
|
||||
driftMs: this.sync.driftMs,
|
||||
rate: this.sync.rate,
|
||||
anchorSource: this.sync.anchorSource,
|
||||
pdtMs: this.sync.pdtMs,
|
||||
clockOffset: this.sync.clock?.offset ?? 0,
|
||||
clockRtt: this.sync.clock?.rtt ?? null,
|
||||
clockStd: this.sync.clock?.offsetStd ?? null,
|
||||
currentTime: this.audio.currentTime,
|
||||
paused: this.audio.paused
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
async function http(method, path, body) {
|
||||
async function http(method, path, body, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: opts.signal
|
||||
});
|
||||
if (res.status === 204) return null;
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
@@ -12,10 +13,16 @@ async function http(method, path, body) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Returns true for AbortError thrown by `fetch(... { signal })` so callers can
|
||||
// silently ignore cancelled requests instead of surfacing them as errors.
|
||||
export function isAbort(err) {
|
||||
return err && (err.name === 'AbortError' || err.code === 20);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (p) => http('GET', p),
|
||||
post: (p, b) => http('POST', p, b),
|
||||
put: (p, b) => http('PUT', p, b),
|
||||
patch: (p, b) => http('PATCH', p, b),
|
||||
del: (p) => http('DELETE', p)
|
||||
get: (p, opts) => http('GET', p, null, opts),
|
||||
post: (p, b, opts) => http('POST', p, b, opts),
|
||||
put: (p, b, opts) => http('PUT', p, b, opts),
|
||||
patch: (p, b, opts) => http('PATCH', p, b, opts),
|
||||
del: (p, opts) => http('DELETE', p, null, opts)
|
||||
};
|
||||
|
||||
146
web/shared/clock.js
Normal file
146
web/shared/clock.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// NTP-lite room clock. Exchanges clock-ping/clock-pong pairs over the existing
|
||||
// WebSocket and keeps a server/local offset so all clients can agree on a
|
||||
// wall-clock for stream-sync purposes.
|
||||
//
|
||||
// Filter: drop samples whose RTT > 2× rolling median, then take the median
|
||||
// offset of the remainder. Lowest-RTT alone is too vulnerable to one lucky
|
||||
// packet that happens to skew the offset.
|
||||
//
|
||||
// Adaptive ping rate: 1 s while still converging (offsetStd > 5 ms or < 8
|
||||
// accepted samples), 5 s after stabilising. The original flat 15 s heartbeat
|
||||
// was too slow to recover from a WiFi RTT spike.
|
||||
//
|
||||
// Usage:
|
||||
// const clock = new RoomClock();
|
||||
// clock.attachWs(ws); // start handshake
|
||||
// clock.now(); // returns estimated server epoch ms
|
||||
// clock.onUpdate((info) => ...) // get notified when offset moves
|
||||
|
||||
const FAST_PING_MS = 1000;
|
||||
const SLOW_PING_MS = 5000;
|
||||
const STABLE_STD_MS = 5;
|
||||
const STABLE_MIN_SAMPLES = 8;
|
||||
const SAMPLE_WINDOW = 16;
|
||||
|
||||
function median(nums) {
|
||||
if (!nums.length) return 0;
|
||||
const s = nums.slice().sort((a, b) => a - b);
|
||||
const mid = s.length >> 1;
|
||||
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||||
}
|
||||
|
||||
export class RoomClock {
|
||||
constructor() {
|
||||
this.offset = 0; // ms to add to Date.now() to get server time
|
||||
this.rtt = Infinity; // RTT of the latest accepted sample (ms)
|
||||
this.offsetStd = Infinity; // std-dev (ms) of accepted offsets in window
|
||||
this.samples = []; // recent { offset, rtt } pairs
|
||||
this.synced = false;
|
||||
this._pending = new Map(); // t1 -> sent timestamp
|
||||
this._listeners = new Set();
|
||||
this._timeoutId = null;
|
||||
this._ws = null;
|
||||
}
|
||||
|
||||
attachWs(wsClient) {
|
||||
this._ws = wsClient;
|
||||
this.reset();
|
||||
// Burst of 5 pings ~150ms apart, then adaptive heartbeat.
|
||||
let n = 0;
|
||||
const burst = () => {
|
||||
if (n++ >= 5) {
|
||||
this._scheduleNext();
|
||||
return;
|
||||
}
|
||||
this._sendPing();
|
||||
setTimeout(burst, 150);
|
||||
};
|
||||
burst();
|
||||
}
|
||||
|
||||
detach() {
|
||||
if (this._timeoutId) clearTimeout(this._timeoutId);
|
||||
this._timeoutId = null;
|
||||
this._pending.clear();
|
||||
this._ws = null;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.samples = [];
|
||||
this.synced = false;
|
||||
this.offsetStd = Infinity;
|
||||
this._pending.clear();
|
||||
if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; }
|
||||
}
|
||||
|
||||
/** Server epoch ms estimate. */
|
||||
now() { return Date.now() + this.offset; }
|
||||
|
||||
/** True once the clock has stabilised: enough samples and low jitter. */
|
||||
isStable() {
|
||||
return this.synced
|
||||
&& this.samples.length >= STABLE_MIN_SAMPLES
|
||||
&& this.offsetStd <= STABLE_STD_MS;
|
||||
}
|
||||
|
||||
onUpdate(fn) { this._listeners.add(fn); return () => this._listeners.delete(fn); }
|
||||
|
||||
/** Called by the WS dispatcher when a `clock-pong` arrives. */
|
||||
handlePong(msg) {
|
||||
const sent = this._pending.get(msg.t1);
|
||||
if (sent == null) return;
|
||||
this._pending.delete(msg.t1);
|
||||
const t4 = Date.now();
|
||||
const rtt = t4 - msg.t1;
|
||||
// Symmetric one-way latency assumption: server-clock at midpoint == t2,
|
||||
// local-clock at midpoint == (t1+t4)/2, so offset = t2 - (t1+t4)/2.
|
||||
const offset = msg.t2 - (msg.t1 + t4) / 2;
|
||||
this.samples.push({ offset, rtt });
|
||||
if (this.samples.length > SAMPLE_WINDOW) this.samples.shift();
|
||||
|
||||
// Drop samples whose RTT is > 2× rolling median RTT — those are
|
||||
// bufferbloat / WiFi-burst outliers and tend to carry a skewed offset.
|
||||
const rttMed = median(this.samples.map((s) => s.rtt));
|
||||
const cutoff = Math.max(rttMed * 2, rttMed + 10);
|
||||
const good = this.samples.filter((s) => s.rtt <= cutoff);
|
||||
const offsets = good.length ? good.map((s) => s.offset) : this.samples.map((s) => s.offset);
|
||||
const medOffset = median(offsets);
|
||||
// Std-dev of the accepted offsets — clock-quality metric.
|
||||
const mean = offsets.reduce((a, b) => a + b, 0) / offsets.length;
|
||||
const variance = offsets.reduce((a, b) => a + (b - mean) ** 2, 0) / offsets.length;
|
||||
this.offsetStd = Math.sqrt(variance);
|
||||
this.offset = medOffset;
|
||||
this.rtt = rtt;
|
||||
this.synced = true;
|
||||
for (const fn of this._listeners) {
|
||||
fn({
|
||||
offset: this.offset,
|
||||
rtt: this.rtt,
|
||||
offsetStd: this.offsetStd,
|
||||
samples: this.samples.length,
|
||||
accepted: good.length,
|
||||
stable: this.isStable()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_sendPing() {
|
||||
if (!this._ws) return;
|
||||
const t1 = Date.now();
|
||||
this._pending.set(t1, t1);
|
||||
// Drop very old pending entries to avoid leaking memory if pongs are lost.
|
||||
for (const k of this._pending.keys()) {
|
||||
if (t1 - k > 5000) this._pending.delete(k);
|
||||
}
|
||||
this._ws.send({ type: 'clock-ping', t1 });
|
||||
}
|
||||
|
||||
_scheduleNext() {
|
||||
if (this._timeoutId) clearTimeout(this._timeoutId);
|
||||
const delay = this.isStable() ? SLOW_PING_MS : FAST_PING_MS;
|
||||
this._timeoutId = setTimeout(() => {
|
||||
this._sendPing();
|
||||
this._scheduleNext();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
356
web/shared/debug.js
Normal file
356
web/shared/debug.js
Normal file
@@ -0,0 +1,356 @@
|
||||
// Sync-status debug overlay.
|
||||
//
|
||||
// Gated by `?log=debug` in the URL. When active:
|
||||
// - Sets `localStorage.oradio.debugSync = '1'` so `logSync()` in
|
||||
// player.js starts emitting `[player:sync]` console traces.
|
||||
// - Persists the flag in localStorage so reloads keep debug on for the
|
||||
// session (clear it via `?log=off`).
|
||||
// - Mounts a fixed-position overlay at bottom-right showing clock, audio,
|
||||
// stream, and room state plus a tail of recent log lines.
|
||||
//
|
||||
// Skin is a few classes in web/style.css (.oradio-debug-*). No frameworks,
|
||||
// no external state — the pane reads live values from the player, clock,
|
||||
// and a tapped `console.log` each refresh.
|
||||
|
||||
const LS_KEY = 'oradio.log';
|
||||
const TAIL_SIZE = 20;
|
||||
const REFRESH_MS = 250;
|
||||
|
||||
// Injected once; each entry point (kiosk / master / admin) ships its own CSS
|
||||
// bundle so it's cleaner to carry the overlay's styling here than to fork it
|
||||
// into three stylesheets.
|
||||
const STYLE_TEXT = `
|
||||
.oradio-debug {
|
||||
position: fixed; bottom: 12px; right: 12px; z-index: 99999;
|
||||
width: 280px; max-height: 70vh; overflow: auto;
|
||||
background: rgba(12, 14, 20, 0.92); color: #e2e8f0;
|
||||
font: 11px/1.35 ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
border: 1px solid #2a3240; border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.oradio-debug-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 10px; background: #1a2030;
|
||||
border-bottom: 1px solid #2a3240; border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.oradio-debug-title {
|
||||
font-weight: 600; color: #93c5fd;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.oradio-debug-collapse {
|
||||
background: transparent; color: #cbd5e1;
|
||||
border: 1px solid #334155; border-radius: 3px;
|
||||
width: 20px; height: 20px; line-height: 18px;
|
||||
cursor: pointer; font: inherit; padding: 0;
|
||||
}
|
||||
.oradio-debug-body { padding: 6px 10px 10px; }
|
||||
.oradio-debug-section { margin-top: 8px; }
|
||||
.oradio-debug-section:first-child { margin-top: 0; }
|
||||
.oradio-debug-section-h {
|
||||
color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
font-size: 10px; padding: 4px 0 3px;
|
||||
border-bottom: 1px solid #1f2937; margin-bottom: 4px;
|
||||
}
|
||||
.oradio-debug-grid {
|
||||
display: grid; grid-template-columns: 90px 1fr; gap: 1px 8px;
|
||||
}
|
||||
.oradio-debug-k { color: #94a3b8; }
|
||||
.oradio-debug-v {
|
||||
color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.oradio-debug-v[data-status="in-sync"] { color: #4ade80; }
|
||||
.oradio-debug-v[data-status="lagging"] { color: #fbbf24; }
|
||||
.oradio-debug-v[data-status="no-buffer"],
|
||||
.oradio-debug-v[data-status="no-anchor"] { color: #f87171; }
|
||||
.oradio-debug-v[data-status="measuring"] { color: #60a5fa; }
|
||||
.oradio-debug-tail {
|
||||
max-height: 160px; overflow: auto;
|
||||
background: #0c1018; border: 1px solid #1f2937;
|
||||
border-radius: 3px; padding: 4px;
|
||||
}
|
||||
.oradio-debug-tail-row {
|
||||
color: #cbd5e1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
font-size: 10px; padding: 1px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
let _styleInjected = false;
|
||||
function injectStyleOnce() {
|
||||
if (_styleInjected || typeof document === 'undefined') return;
|
||||
_styleInjected = true;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'oradio-debug-style';
|
||||
style.textContent = STYLE_TEXT;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/** Returns true if the debug overlay should be active. */
|
||||
export function isDebugEnabled() {
|
||||
try {
|
||||
const sp = new URLSearchParams(location.search);
|
||||
const q = sp.get('log');
|
||||
if (q === 'debug') {
|
||||
localStorage.setItem(LS_KEY, 'debug');
|
||||
localStorage.setItem('oradio.debugSync', '1');
|
||||
return true;
|
||||
}
|
||||
if (q === 'off' || q === '0') {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
localStorage.removeItem('oradio.debugSync');
|
||||
return false;
|
||||
}
|
||||
if (localStorage.getItem(LS_KEY) === 'debug') {
|
||||
// Keep logSync wired up across reloads too.
|
||||
localStorage.setItem('oradio.debugSync', '1');
|
||||
return true;
|
||||
}
|
||||
} catch { /* private mode, no-op */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
let _tail = [];
|
||||
let _tailListeners = new Set();
|
||||
let _consoleHooked = false;
|
||||
|
||||
function pushTail(line) {
|
||||
_tail.push({ t: Date.now(), line });
|
||||
if (_tail.length > TAIL_SIZE) _tail.shift();
|
||||
for (const fn of _tailListeners) { try { fn(); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
function hookConsole() {
|
||||
if (_consoleHooked || typeof console === 'undefined') return;
|
||||
_consoleHooked = true;
|
||||
const orig = console.log.bind(console);
|
||||
console.log = (...args) => {
|
||||
try {
|
||||
const first = args[0];
|
||||
if (typeof first === 'string'
|
||||
&& (first.startsWith('[player:sync]') || first.startsWith('[clock]') || first.startsWith('[ws]'))) {
|
||||
pushTail(args.map(formatArg).join(' '));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
orig(...args);
|
||||
};
|
||||
}
|
||||
|
||||
function formatArg(a) {
|
||||
if (a == null) return String(a);
|
||||
if (typeof a === 'string') return a;
|
||||
if (typeof a === 'number') return Number.isInteger(a) ? String(a) : a.toFixed(3);
|
||||
try { return JSON.stringify(a); } catch { return String(a); }
|
||||
}
|
||||
|
||||
function fmtMs(v) {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
const abs = Math.abs(v);
|
||||
if (abs < 1) return v.toFixed(2) + 'ms';
|
||||
return Math.round(v) + 'ms';
|
||||
}
|
||||
function fmtSec(v) {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return v.toFixed(3) + 's';
|
||||
}
|
||||
function fmtRate(v) {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return v.toFixed(5) + '×';
|
||||
}
|
||||
function fmtTime(t) {
|
||||
if (!t) return '—';
|
||||
const d = new Date(t);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function section(title) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'oradio-debug-section';
|
||||
const h = document.createElement('div');
|
||||
h.className = 'oradio-debug-section-h';
|
||||
h.textContent = title;
|
||||
wrap.appendChild(h);
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'oradio-debug-grid';
|
||||
wrap.appendChild(grid);
|
||||
return { wrap, grid };
|
||||
}
|
||||
function row(grid, label) {
|
||||
const k = document.createElement('div');
|
||||
k.className = 'oradio-debug-k';
|
||||
k.textContent = label;
|
||||
const v = document.createElement('div');
|
||||
v.className = 'oradio-debug-v';
|
||||
v.textContent = '—';
|
||||
grid.appendChild(k);
|
||||
grid.appendChild(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the debug overlay. Safe to call without `player` (admin pages):
|
||||
* pass `null` for the player and the Audio/Stream sections collapse to "no
|
||||
* player on this page". Returns an `unmount()` function.
|
||||
*/
|
||||
export function mountDebugPane({ player, clock, ws, getWs, role }) {
|
||||
if (!isDebugEnabled() || typeof document === 'undefined') return () => { };
|
||||
hookConsole();
|
||||
injectStyleOnce();
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'oradio-debug';
|
||||
root.setAttribute('role', 'complementary');
|
||||
root.setAttribute('aria-label', 'sync debug');
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'oradio-debug-head';
|
||||
const title = document.createElement('span');
|
||||
title.className = 'oradio-debug-title';
|
||||
title.textContent = `sync · ${role || '?'}`;
|
||||
head.appendChild(title);
|
||||
const collapseBtn = document.createElement('button');
|
||||
collapseBtn.className = 'oradio-debug-collapse';
|
||||
collapseBtn.textContent = '–';
|
||||
collapseBtn.title = 'Collapse';
|
||||
head.appendChild(collapseBtn);
|
||||
root.appendChild(head);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'oradio-debug-body';
|
||||
root.appendChild(body);
|
||||
|
||||
let collapsed = false;
|
||||
collapseBtn.addEventListener('click', () => {
|
||||
collapsed = !collapsed;
|
||||
body.style.display = collapsed ? 'none' : '';
|
||||
collapseBtn.textContent = collapsed ? '+' : '–';
|
||||
});
|
||||
|
||||
// ----- Clock -----
|
||||
const clk = section('Clock');
|
||||
const clk_offset = row(clk.grid, 'offset');
|
||||
const clk_rtt = row(clk.grid, 'rtt');
|
||||
const clk_std = row(clk.grid, 'std');
|
||||
const clk_samples = row(clk.grid, 'samples');
|
||||
const clk_stable = row(clk.grid, 'stable');
|
||||
body.appendChild(clk.wrap);
|
||||
|
||||
// ----- Audio -----
|
||||
const aud = section('Audio');
|
||||
const aud_status = row(aud.grid, 'status');
|
||||
const aud_source = row(aud.grid, 'anchor');
|
||||
const aud_drift = row(aud.grid, 'drift');
|
||||
const aud_rate = row(aud.grid, 'rate');
|
||||
const aud_ct = row(aud.grid, 'currentTime');
|
||||
const aud_delay = row(aud.grid, 'delay');
|
||||
const aud_buffer = row(aud.grid, 'bufferMs');
|
||||
const aud_paused = row(aud.grid, 'paused');
|
||||
body.appendChild(aud.wrap);
|
||||
|
||||
// ----- Stream -----
|
||||
const str = section('Stream');
|
||||
const str_kind = row(str.grid, 'kind');
|
||||
const str_station = row(str.grid, 'station');
|
||||
const str_pdt = row(str.grid, 'PDT');
|
||||
body.appendChild(str.wrap);
|
||||
|
||||
// ----- Room -----
|
||||
const rm = section('Room');
|
||||
const rm_role = row(rm.grid, 'role');
|
||||
const rm_ws = row(rm.grid, 'ws');
|
||||
const rm_started = row(rm.grid, 'started_at');
|
||||
const rm_master = row(rm.grid, 'last master-pos');
|
||||
body.appendChild(rm.wrap);
|
||||
rm_role.textContent = role || '—';
|
||||
|
||||
// ----- Tail -----
|
||||
const tail = document.createElement('div');
|
||||
tail.className = 'oradio-debug-section';
|
||||
const tailH = document.createElement('div');
|
||||
tailH.className = 'oradio-debug-section-h';
|
||||
tailH.textContent = 'Log';
|
||||
tail.appendChild(tailH);
|
||||
const tailList = document.createElement('div');
|
||||
tailList.className = 'oradio-debug-tail';
|
||||
tail.appendChild(tailList);
|
||||
body.appendChild(tail);
|
||||
|
||||
function renderTail() {
|
||||
tailList.textContent = '';
|
||||
for (const e of _tail.slice().reverse()) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'oradio-debug-tail-row';
|
||||
div.textContent = `${fmtTime(e.t)} ${e.line}`;
|
||||
tailList.appendChild(div);
|
||||
}
|
||||
}
|
||||
_tailListeners.add(renderTail);
|
||||
|
||||
function refresh() {
|
||||
// Clock
|
||||
if (clock) {
|
||||
clk_offset.textContent = fmtMs(clock.offset);
|
||||
clk_rtt.textContent = Number.isFinite(clock.rtt) ? fmtMs(clock.rtt) : '—';
|
||||
clk_std.textContent = Number.isFinite(clock.offsetStd) ? fmtMs(clock.offsetStd) : '—';
|
||||
clk_samples.textContent = String(clock.samples?.length ?? 0);
|
||||
clk_stable.textContent = clock.isStable?.() ? 'yes' : 'no';
|
||||
}
|
||||
// Audio
|
||||
if (player) {
|
||||
const s = player.sync;
|
||||
aud_status.textContent = s.status || '—';
|
||||
aud_status.dataset.status = s.status || '';
|
||||
aud_source.textContent = s.anchorSource || '—';
|
||||
aud_drift.textContent = fmtMs(s.driftMs);
|
||||
aud_rate.textContent = fmtRate(s.rate);
|
||||
aud_ct.textContent = fmtSec(player.audio?.currentTime);
|
||||
aud_delay.textContent = fmtSec(s.currentDelay);
|
||||
aud_buffer.textContent = fmtMs(s.bufferMs);
|
||||
aud_paused.textContent = player.audio?.paused ? 'yes' : 'no';
|
||||
const st = player.station;
|
||||
str_station.textContent = st ? `${st.id} ${st.name || ''}` : '—';
|
||||
str_kind.textContent = player.hls ? 'hls' : (st ? 'direct' : '—');
|
||||
str_pdt.textContent = s.pdtMs ? fmtTime(s.pdtMs) : '—';
|
||||
rm_started.textContent = s.startedAt ? fmtTime(s.startedAt) : '—';
|
||||
// masterAt is in server time. Age == server-now − masterAt.
|
||||
if (s.masterAt && clock) {
|
||||
const ageMs = (Date.now() + clock.offset) - s.masterAt;
|
||||
rm_master.textContent = ageMs >= 0
|
||||
? (ageMs < 1000 ? ageMs + 'ms ago' : (ageMs / 1000).toFixed(1) + 's ago')
|
||||
: '—';
|
||||
} else {
|
||||
rm_master.textContent = '—';
|
||||
}
|
||||
} else {
|
||||
aud_status.textContent = 'no-player';
|
||||
aud_source.textContent = '—';
|
||||
aud_drift.textContent = '—';
|
||||
aud_rate.textContent = '—';
|
||||
aud_ct.textContent = '—';
|
||||
aud_delay.textContent = '—';
|
||||
aud_buffer.textContent = '—';
|
||||
aud_paused.textContent = '—';
|
||||
str_kind.textContent = '—';
|
||||
str_station.textContent = '—';
|
||||
str_pdt.textContent = '—';
|
||||
rm_started.textContent = '—';
|
||||
rm_master.textContent = '—';
|
||||
}
|
||||
// WS — accept a static handle or a getter so re-openings stay visible.
|
||||
const wsNow = typeof getWs === 'function' ? getWs() : ws;
|
||||
const rs = wsNow?.readyState;
|
||||
rm_ws.textContent = rs === 1 ? 'open' : rs === 0 ? 'connecting' : rs === 2 ? 'closing' : rs === 3 ? 'closed' : '—';
|
||||
}
|
||||
|
||||
document.body.appendChild(root);
|
||||
refresh();
|
||||
renderTail();
|
||||
const refreshId = setInterval(refresh, REFRESH_MS);
|
||||
|
||||
return function unmount() {
|
||||
clearInterval(refreshId);
|
||||
_tailListeners.delete(renderTail);
|
||||
try { root.remove(); } catch { /* ignore */ }
|
||||
};
|
||||
}
|
||||
143
web/shared/playGate.js
Normal file
143
web/shared/playGate.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// Click-to-start gate.
|
||||
//
|
||||
// Browsers reject `audio.play()` without a user gesture. After a hard reload
|
||||
// we want to resume playback automatically when possible and fall back to a
|
||||
// "tap to start" modal otherwise. Inside the Electron shell autoplay is
|
||||
// permitted, so a one-time "Don't show again" pref suppresses the modal.
|
||||
//
|
||||
// Usage:
|
||||
// const ok = await tryAutoplay(audioEl); // best-effort attempt
|
||||
// if (!ok) {
|
||||
// await showStartModal({ stationName, onStart, onCancel });
|
||||
// }
|
||||
|
||||
const DISMISSED_KEY = 'oradio.autoplayDismissed';
|
||||
|
||||
function isElectron() {
|
||||
try { return !!(typeof window !== 'undefined' && window.oradioNative?.isElectron); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
export function autoplayDismissed() {
|
||||
// Only honour the dismissal in Electron — plain browsers can't bypass
|
||||
// their autoplay policy regardless of any flag we set.
|
||||
if (!isElectron()) return false;
|
||||
try { return localStorage.getItem(DISMISSED_KEY) === '1'; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/** Try to start an <audio> element. Resolves true on success, false on rejection. */
|
||||
export async function tryAutoplay(audio) {
|
||||
if (!audio) return false;
|
||||
try {
|
||||
const p = audio.play();
|
||||
if (p && typeof p.then === 'function') await p;
|
||||
return !audio.paused;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let _open = null;
|
||||
|
||||
/**
|
||||
* Show a blocking modal that captures a user gesture. Resolves when the user
|
||||
* taps Start (which fulfils browser autoplay rules for the subsequent
|
||||
* `audio.play()` the caller will perform). Rejects on Cancel.
|
||||
*
|
||||
* Returns a Promise. While the modal is open subsequent calls return the
|
||||
* same promise (idempotent).
|
||||
*/
|
||||
export function showStartModal({ stationName = 'Radio', subtitle = '', onStart, onCancel } = {}) {
|
||||
if (_open) return _open.promise;
|
||||
|
||||
let resolve, reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'play-gate-backdrop';
|
||||
backdrop.setAttribute('role', 'dialog');
|
||||
backdrop.setAttribute('aria-modal', 'true');
|
||||
backdrop.setAttribute('aria-label', 'Tap to start audio');
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'play-gate-card';
|
||||
|
||||
const h = document.createElement('h2');
|
||||
h.className = 'play-gate-title';
|
||||
h.textContent = 'Tap to start audio';
|
||||
card.appendChild(h);
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'play-gate-station';
|
||||
name.textContent = stationName;
|
||||
card.appendChild(name);
|
||||
|
||||
if (subtitle) {
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'play-gate-sub';
|
||||
sub.textContent = subtitle;
|
||||
card.appendChild(sub);
|
||||
}
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'play-gate-row';
|
||||
const startBtn = document.createElement('button');
|
||||
startBtn.className = 'play-gate-start';
|
||||
startBtn.textContent = '▶ Start';
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'play-gate-cancel';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
row.appendChild(startBtn);
|
||||
row.appendChild(cancelBtn);
|
||||
card.appendChild(row);
|
||||
|
||||
let dismissCheckbox = null;
|
||||
if (isElectron()) {
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 'play-gate-dismiss';
|
||||
dismissCheckbox = document.createElement('input');
|
||||
dismissCheckbox.type = 'checkbox';
|
||||
dismissCheckbox.id = 'play-gate-dismiss-cb';
|
||||
lbl.appendChild(dismissCheckbox);
|
||||
const txt = document.createElement('span');
|
||||
txt.textContent = " Don't show again on this device";
|
||||
lbl.appendChild(txt);
|
||||
card.appendChild(lbl);
|
||||
}
|
||||
|
||||
backdrop.appendChild(card);
|
||||
document.body.appendChild(backdrop);
|
||||
queueMicrotask(() => startBtn.focus());
|
||||
|
||||
function close() {
|
||||
if (_open?.backdrop?.parentNode) _open.backdrop.parentNode.removeChild(_open.backdrop);
|
||||
_open = null;
|
||||
}
|
||||
|
||||
function persistDismissal() {
|
||||
if (dismissCheckbox && dismissCheckbox.checked) {
|
||||
try { localStorage.setItem(DISMISSED_KEY, '1'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', () => {
|
||||
persistDismissal();
|
||||
close();
|
||||
try { onStart && onStart(); } catch { /* ignore */ }
|
||||
resolve();
|
||||
});
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
close();
|
||||
try { onCancel && onCancel(); } catch { /* ignore */ }
|
||||
reject(new Error('autoplay-cancelled'));
|
||||
});
|
||||
backdrop.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') cancelBtn.click();
|
||||
});
|
||||
|
||||
_open = { backdrop, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function isStartModalOpen() { return !!_open; }
|
||||
347
web/style.css
347
web/style.css
@@ -213,6 +213,24 @@ textarea {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* "Join group audio" affordance — pulses gently when a tap will silently
|
||||
join the room's current stream without broadcasting a new command. */
|
||||
.btn-play.cta-join {
|
||||
animation: cta-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cta-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 4px 22px var(--accent-glow), 0 0 0 6px rgba(255, 122, 61, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
background: var(--bg-2);
|
||||
color: var(--muted);
|
||||
@@ -313,47 +331,210 @@ textarea {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Room sync pill: room picker + peer count + here/follow mode toggle. */
|
||||
/* Group + listening-mode pill. Left half = group picker; right half = a
|
||||
segmented Solo / Linked / Remote selector with a sliding indicator. */
|
||||
.room-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
padding: 4px;
|
||||
height: 36px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.room-pill .room-icon { opacity: .7; }
|
||||
.room-pill .room-select {
|
||||
|
||||
.room-pill .rp-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px 0 8px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.room-pill .rp-group-icon {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.room-pill .rp-group-select {
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 12px;
|
||||
max-width: 140px;
|
||||
}
|
||||
.room-pill .room-peers {
|
||||
color: var(--muted-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.room-pill .room-mode {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
color: var(--fg);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.room-pill .room-mode.follow-room {
|
||||
background: linear-gradient(180deg, rgba(255, 122, 61, 0.18), rgba(255, 122, 61, 0.08));
|
||||
|
||||
.room-pill .rp-peers {
|
||||
color: var(--muted-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
background: var(--bg-1);
|
||||
border-radius: 999px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.mode-pill {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
background: var(--bg-1);
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.mode-pill .mode-indicator {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
width: calc((100% - 4px) / 2);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(255, 122, 61, 0.28), rgba(255, 122, 61, 0.12));
|
||||
border: 1px solid rgba(255, 122, 61, 0.35);
|
||||
transition: transform 180ms cubic-bezier(.4, 0, .2, 1);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.mode-pill .mode-indicator.pos-solo {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
|
||||
.mode-pill .mode-indicator.pos-synced {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.mode-pill .mode-seg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 10px;
|
||||
height: 26px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-2);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
transition: color 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode-pill .mode-seg:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.mode-pill .mode-seg.on {
|
||||
color: var(--accent-2);
|
||||
border-color: rgba(255, 122, 61, 0.30);
|
||||
}
|
||||
|
||||
.mode-pill .mode-seg-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mode-pill .mode-seg-label {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.mode-pill .mode-seg-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mode-pill .mode-seg {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cross-client sync indicator (shown only in Linked mode). */
|
||||
.sync-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 10px;
|
||||
height: 26px;
|
||||
margin-left: 2px;
|
||||
background: var(--bg-1);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted-2);
|
||||
transition: color 200ms ease, background 200ms ease;
|
||||
}
|
||||
|
||||
.sync-chip .sync-dot {
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sync-chip.sync-in-sync {
|
||||
color: #67e08a;
|
||||
background: rgba(103, 224, 138, 0.08);
|
||||
}
|
||||
|
||||
.sync-chip.sync-catching-up,
|
||||
.sync-chip.sync-lagging,
|
||||
.sync-chip.sync-measuring {
|
||||
color: #f0c460;
|
||||
background: rgba(240, 196, 96, 0.08);
|
||||
}
|
||||
|
||||
.sync-chip.sync-out-of-range,
|
||||
.sync-chip.sync-no-anchor,
|
||||
.sync-chip.sync-no-buffer {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.sync-chip .sync-buffer {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 70px;
|
||||
height: 4px;
|
||||
margin-left: 6px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sync-chip .sync-buffer::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sync-chip .sync-buffer::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sync-chip .sync-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sync-chip {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.sync-chip .sync-buffer {
|
||||
width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
@@ -1501,4 +1682,124 @@ dialog.add-station select:focus {
|
||||
|
||||
.card.playing .score-badge {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* === Click-to-start gate modal === */
|
||||
.play-gate-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.play-gate-card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.play-gate-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.play-gate-station {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.play-gate-sub {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.play-gate-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.play-gate-start,
|
||||
.play-gate-cancel {
|
||||
padding: 10px 22px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-3);
|
||||
color: var(--fg);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.play-gate-start {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #07080b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.play-gate-start:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.play-gate-cancel:hover {
|
||||
background: var(--bg-1);
|
||||
}
|
||||
|
||||
.play-gate-dismiss {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* === Synced audio sub-toggle === */
|
||||
.synced-audio-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.synced-audio-toggle.on {
|
||||
color: var(--fg);
|
||||
border-color: rgba(255, 122, 61, 0.35);
|
||||
}
|
||||
|
||||
.synced-audio-toggle input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.synced-audio-toggle .sa-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vol.vol-master .vol-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.vol.vol-local {
|
||||
opacity: 0.92;
|
||||
}
|
||||
Reference in New Issue
Block a user