// Master display: owns the audio output for a room. Connects to the WS as // kind='display', advertises a (fake) device list, plays the active station // locally, and emits authoritative `state` events so other panels mirror. // // In production this same page is loaded inside an Electron window. The // `window.oradioNative` bridge — when present — replaces the fake device // enumerator below with the real OS one. The bridge contract is: // // window.oradioNative = { // listOutputs(): Promise<{id, label, kind}[]>, // setOutput(id): Promise, // getCurrent(): Promise, // onCurrentChanged(cb): unsubscribe // }; 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'; // 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, peers: [], devices: { list: [], current: 'default' }, np: { stationId: null, station: null, playing: false, loading: false, volume: 0.7, error: null }, voteStats: null, 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'); } catch { return showLogin(); } // Pick the room: ?room= wins, else first server-side room, else personal. const params = new URLSearchParams(location.search); const wanted = params.get('room'); try { state.rooms = await api.get('/api/rooms'); } catch { state.rooms = []; } state.roomSlug = wanted || (state.rooms[0] && state.rooms[0].slug) || `u-${state.user.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 = []; 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 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