// 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 //