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:
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
|
||||
});
|
||||
Reference in New Issue
Block a user