From 29423288caa27ed316d8da2c7451a479adba81c2 Mon Sep 17 00:00:00 2001 From: Marco Mooren Date: Wed, 13 May 2026 13:53:12 +0200 Subject: [PATCH] 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. --- electron/main.js | 44 +- electron/preload.cjs | 107 +++ package.json | 2 +- server/auth.js | 119 ++- server/db/index.js | 20 + server/db/schema.sql | 23 + server/public/admin/index.html | 4 +- server/public/assets/admin-BnGhtAku.js | 1 + server/public/assets/admin-GqZPhz-K.js | 1 - server/public/assets/debug-DBzSAgZo.js | 93 +++ server/public/assets/kiosk-CzWLja7k.js | 1 - server/public/assets/kiosk-DIx-PLJP.js | 1 + server/public/assets/kiosk-DuoYH-tL.css | 1 + server/public/assets/kiosk-PzkUrLf6.css | 1 - server/public/assets/master-B8Vyo4--.css | 1 + server/public/assets/master-BGIwPPRC.js | 1 + server/public/assets/master-CpJfsvtJ.css | 1 - server/public/assets/master-kSyrThjc.js | 1 - server/public/assets/playGate-C1e0nYli.js | 1 + server/public/assets/player-BBOsFRH-.js | 40 - server/public/assets/ws-BM1PmMVd.js | 1 - server/public/index.html | 8 +- server/public/master/index.html | 8 +- server/rooms.js | 10 +- server/routes/auth.js | 117 ++- server/routes/rooms.js | 36 + server/routes/users.js | 70 ++ server/scripts/check-multiuser.js | 24 + server/start.js | 5 +- server/ws.js | 133 +++- web/admin/main.js | 96 ++- web/main.js | 852 +++++++++++++++++++--- web/master/main.js | 627 ++++++++++++++-- web/master/style.css | 289 ++++++++ web/master/visualizer.js | 108 +++ web/player.js | 644 +++++++++++++++- web/shared/api.js | 21 +- web/shared/clock.js | 146 ++++ web/shared/debug.js | 356 +++++++++ web/shared/playGate.js | 143 ++++ web/style.css | 347 ++++++++- 41 files changed, 4229 insertions(+), 275 deletions(-) create mode 100644 electron/preload.cjs create mode 100644 server/public/assets/admin-BnGhtAku.js delete mode 100644 server/public/assets/admin-GqZPhz-K.js create mode 100644 server/public/assets/debug-DBzSAgZo.js delete mode 100644 server/public/assets/kiosk-CzWLja7k.js create mode 100644 server/public/assets/kiosk-DIx-PLJP.js create mode 100644 server/public/assets/kiosk-DuoYH-tL.css delete mode 100644 server/public/assets/kiosk-PzkUrLf6.css create mode 100644 server/public/assets/master-B8Vyo4--.css create mode 100644 server/public/assets/master-BGIwPPRC.js delete mode 100644 server/public/assets/master-CpJfsvtJ.css delete mode 100644 server/public/assets/master-kSyrThjc.js create mode 100644 server/public/assets/playGate-C1e0nYli.js delete mode 100644 server/public/assets/player-BBOsFRH-.js delete mode 100644 server/public/assets/ws-BM1PmMVd.js create mode 100644 server/routes/users.js create mode 100644 server/scripts/check-multiuser.js create mode 100644 web/master/visualizer.js create mode 100644 web/shared/clock.js create mode 100644 web/shared/debug.js create mode 100644 web/shared/playGate.js diff --git a/electron/main.js b/electron/main.js index d9ebeec..1cbf6db 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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); diff --git a/electron/preload.cjs b/electron/preload.cjs new file mode 100644 index 0000000..f32baf8 --- /dev/null +++ b/electron/preload.cjs @@ -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 + //