// 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'; // 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' } ]; const app = document.getElementById('app'); const state = { user: null, 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: [], favGenre: '', // active genre filter for favorites browser showOutputs: false, // output picker is hidden behind a button session: null // { id, stationId, startedAt } for the open play_history row }; const native = window.oradioNative || null; let ws = null; let player = null; 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. if (native?.listOutputs) { state.devices.list = await native.listOutputs(); state.devices.current = (await native.getCurrent()) || state.devices.list[0]?.id; native.onCurrentChanged?.((id) => { state.devices.current = id; advertiseDevices(); render(); }); } else { state.devices.list = FAKE_DEVICES; state.devices.current = 'default'; } player = new Player({ onState: (s) => { Object.assign(state.np, s); // Push display truth out to the room. sendState(); render(); } }); // Load favorites so the touch browser + heart indicator work. try { state.favorites = await api.get('/api/me/favorites'); } catch { state.favorites = []; } openWs(); render(); } // Best-effort session flush on tab close so total_play_ms stays honest. if (typeof window !== 'undefined') { window.addEventListener('pagehide', () => endCurrentSession({ beacon: true })); window.addEventListener('beforeunload', () => endCurrentSession({ beacon: true })); } function openWs() { if (ws) { try { ws.close(); } catch { } } ws = connectWs(handleWs, { room: state.roomSlug, kind: 'display', onOpen: () => advertiseDevices() }); } function handleWs(msg) { if (!msg || !msg.type) return; switch (msg.type) { case 'hello': { state.room = msg.room; state.peers = msg.peers || []; // If the server thinks another display already owns this room we // were demoted to 'panel' — surface that. if (msg.you?.kind && msg.you.kind !== 'display') { state.np.error = `This room already has a display (${countDisplays(msg.peers)} active). You were joined as ${msg.you.kind}.`; } // Resume room state when (re-)connecting: play whatever the room // thinks is current, unless we're already on it. const rs = msg.state; if (rs?.station_id && rs.station && rs.station_id !== state.np.stationId) { playStation(rs.station, { silent: true }); } if (typeof rs?.volume === 'number') { player.setVolume(rs.volume); } render(); return; } case 'presence': state.peers = msg.peers || []; render(); return; case 'command': handleCommand(msg); return; case 'vote': case 'plays': if (msg.stationId === state.np.stationId) { state.voteStats = { ...(state.voteStats || {}), ...(msg.stats || {}) }; if (msg.type === 'plays') state.voteStats.plays = msg.plays; render(); } return; default: return; } } function handleCommand(msg) { switch (msg.action) { case 'play': { const id = Number(msg.stationId); if (!Number.isFinite(id)) return; api.get(`/api/stations/${id}`).then((st) => playStation(st)).catch(() => { }); return; } case 'pause': player.togglePause(); return; case 'stop': player.stop(); endCurrentSession(); state.np.playing = false; state.np.stationId = null; sendState(); render(); return; case 'volume': if (typeof msg.value === 'number') player.setVolume(msg.value); return; case 'setSink': setSink(String(msg.deviceId || '')); return; default: return; } } async function playStation(station, { silent } = {}) { if (!station) return; // Close any previous session before swapping. We compute the duration // locally so resumes-after-suspend don't get charged the whole gap. endCurrentSession(); state.np.station = station; state.np.stationId = station.id; state.voteStats = { up: station.up || 0, down: station.down || 0, plays: station.plays || 0, score: station.score || 0 }; render(); await player.play(station); if (!silent) { try { const stats = await api.post(`/api/stations/${station.id}/play`); // The same station may have been swapped out while the POST was in // flight — only retain the session id when it's still current. if (state.np.stationId === station.id) { state.session = { id: stats.sessionId, stationId: station.id, startedAt: Date.now() }; state.voteStats = { ...state.voteStats, ...stats }; } else if (stats.sessionId) { // We've already moved on; close the just-opened session immediately. api.post(`/api/stations/${station.id}/play/end`, { sessionId: stats.sessionId, duration_ms: 0 }).catch(() => { }); } } catch { /* network blip — best-effort counter */ } } } // Close whichever session is currently open. Idempotent. function endCurrentSession({ beacon = false } = {}) { const s = state.session; if (!s || !s.id) return; state.session = null; const body = { sessionId: s.id, duration_ms: Math.max(0, Date.now() - s.startedAt) }; const url = `/api/stations/${s.stationId}/play/end`; if (beacon && typeof navigator !== 'undefined' && navigator.sendBeacon) { try { navigator.sendBeacon(url, new Blob([JSON.stringify(body)], { type: 'application/json' })); return; } catch { /* fall through */ } } api.post(url, body).catch(() => { }); } 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({ type: 'state', stationId: state.np.stationId, playing: !!state.np.playing, volume: state.np.volume }); } function advertiseDevices() { ws?.send({ type: 'devices', list: state.devices.list, current: state.devices.current }); } async function setSink(deviceId) { if (!deviceId) return; if (native?.setOutput) { try { await native.setOutput(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 { } } advertiseDevices(); state.showOutputs = false; render(); } function countDisplays(peers) { return (peers || []).filter((p) => p.kind === 'display').length; } // ---------- Favorites ---------- function isFavorite(stationId) { return !!stationId && state.favorites.some((f) => f.id === stationId); } async function toggleFavorite(stationId) { if (!stationId) return; const has = isFavorite(stationId); try { 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'); render(); } catch (err) { console.warn('[master] toggleFavorite failed', err); } } function favoriteGenres() { const counts = new Map(); for (const s of state.favorites) { for (const g of (s.genres || [])) counts.set(g, (counts.get(g) || 0) + 1); } return [...counts.entries()] .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .map(([g, n]) => ({ genre: g, count: n })); } function filteredFavorites() { if (!state.favGenre) return state.favorites; return state.favorites.filter((s) => (s.genres || []).includes(state.favGenre)); } // ---------- Render ---------- function render() { // 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; clear(app); const np = state.np; const st = np.station; const artUrl = st?.image_display_url || st?.image_url || null; const fav = isFavorite(st?.id); const shell = el('div', { class: 'master' }, // Topbar el('header', { class: 'topbar' }, el('h1', {}, '◉ MASTER'), el('div', { class: 'pill' }, el('span', {}, 'Room:'), el('select', { onChange: (e) => { state.roomSlug = e.target.value; history.replaceState(null, '', `?room=${encodeURIComponent(state.roomSlug)}`); openWs(); } }, ...state.rooms.map((r) => 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'}`), np.error ? el('div', { class: 'err-banner' }, np.error) : null, el('div', { class: 'grow' }), 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), ), // Stage: now-playing block (with transport + volume embedded) el('section', { class: 'stage' }, el('div', { class: 'np' }, el('div', { class: 'art' + (artUrl ? '' : ' empty') }, artUrl ? el('img', { class: 'art-img', src: artUrl, alt: '', referrerpolicy: 'no-referrer', onError: (e) => { // Fall back to the empty glyph if the image fails to load. const parent = e.target.parentNode; e.target.remove(); if (parent) parent.classList.add('empty'); } }) : null ), el('div', { class: 'meta' }, el('div', { class: 'tiny' }, np.loading ? 'Loading…' : np.playing ? 'Now playing' : st ? 'Paused' : 'Idle'), el('div', { class: 'title-row' }, el('h2', {}, st?.name || '—'), st ? el('button', { class: 'fav-toggle' + (fav ? ' on' : ''), title: fav ? 'Remove favorite' : 'Add favorite', onClick: () => toggleFavorite(st.id) }, fav ? '★' : '☆') : null ), el('div', { class: 'genres' }, ...(st?.genres || []).slice(0, 6).map((g) => el('span', { class: 'tag' }, g))), state.voteStats ? el('div', { class: 'stats' }, el('span', {}, '▲ ', el('b', {}, String(state.voteStats.up || 0))), el('span', {}, '▼ ', el('b', {}, String(state.voteStats.down || 0))), el('span', {}, '▶ ', el('b', {}, String(state.voteStats.plays || 0))) ) : null, st?.country ? el('div', { class: 'stats' }, el('span', {}, st.country)) : null, // Transport + volume embedded inside the now-playing block el('div', { class: 'transport' }, el('button', { class: 'ctrl primary', title: 'Play / pause', disabled: !st, onClick: () => player.togglePause() }, np.playing ? '❚❚' : '▶'), el('button', { class: 'ctrl', title: 'Stop', disabled: !st, onClick: () => { player.stop(); endCurrentSession(); state.np.playing = false; sendState(); render(); } }, '■'), el('div', { class: 'vol' }, 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)) }), el('span', { class: 'val' }, Math.round(np.volume * 100) + '%') ) ), el('div', { class: 'peer-line' }, el('span', { class: 'peer-line-label' }, 'In room:'), ...(state.peers.length ? state.peers.map((p) => el('span', { class: 'peer role-' + p.kind }, el('span', { class: 'role-tag' }, p.kind), el('span', {}, p.user?.username || '?') )) : [el('span', { class: 'peer' }, 'Just you.')]) ) ) ) ), // Bottom: stations grid (2 rows of the viewport) el('section', { class: 'stations-bar' }, renderFavoritesCard() ), // Output picker popover (hidden by default; toggled by topbar button). state.showOutputs ? renderOutputPopover() : null ); app.appendChild(shell); // Restore favorites grid scroll position after the DOM swap. const favGrid = app.querySelector('.favs-grid'); if (favGrid) { if (prevFavScroll) favGrid.scrollLeft = prevFavScroll; attachDragScroll(favGrid); } } // Pointer-drag horizontal scrolling for the favorites strip. Mouse users can // click and drag like a touch surface; we suppress the click on the tile that // was the drag origin so a drag doesn't fire a station change. function attachDragScroll(el) { if (el.dataset.dragBound === '1') return; el.dataset.dragBound = '1'; let down = false; let moved = false; let startX = 0; let startScroll = 0; let pointerId = -1; el.addEventListener('pointerdown', (e) => { // Only left-button mouse / touch / pen; ignore wheel buttons. if (e.pointerType === 'mouse' && e.button !== 0) return; down = true; moved = false; startX = e.clientX; startScroll = el.scrollLeft; pointerId = e.pointerId; }); el.addEventListener('pointermove', (e) => { if (!down) return; const dx = e.clientX - startX; if (!moved && Math.abs(dx) > 5) { moved = true; try { el.setPointerCapture(pointerId); } catch { } el.classList.add('dragging'); } if (moved) { el.scrollLeft = startScroll - dx; e.preventDefault(); } }); const endDrag = () => { down = false; if (moved) { // Swallow the click that follows the drag-up so tiles aren't activated. const swallow = (ev) => { ev.stopPropagation(); ev.preventDefault(); }; el.addEventListener('click', swallow, { capture: true, once: true }); setTimeout(() => el.removeEventListener('click', swallow, true), 0); } moved = false; el.classList.remove('dragging'); try { el.releasePointerCapture(pointerId); } catch { } }; el.addEventListener('pointerup', endDrag); el.addEventListener('pointercancel', endDrag); el.addEventListener('pointerleave', () => { if (down && !moved) down = false; }); } function scrollFavs(direction) { const grid = app.querySelector('.favs-grid'); if (!grid) return; // Page by ~80% of the visible width, snapping feels natural with scroll-snap. const delta = Math.max(160, Math.round(grid.clientWidth * 0.8)); grid.scrollBy({ left: direction * delta, behavior: 'smooth' }); } function renderFavoritesCard() { const genres = favoriteGenres(); const favs = filteredFavorites(); return el('div', { class: 'card favs-card' }, el('div', { class: 'favs-header' }, el('h3', {}, `Favorites (${favs.length}${state.favGenre ? `/${state.favorites.length}` : ''})`), genres.length ? el('select', { class: 'genre-filter', title: 'Filter by genre', onChange: (e) => { state.favGenre = e.target.value; render(); } }, el('option', { value: '' }, 'All genres'), ...genres.map(({ genre, count }) => el('option', { value: genre, selected: state.favGenre === genre }, `${genre} (${count})`)) ) : null, el('button', { class: 'favs-nav', title: 'Scroll left', onClick: () => scrollFavs(-1) }, '‹'), el('button', { class: 'favs-nav', title: 'Scroll right', 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.')])) ); } function renderOutputPopover() { return el('div', { class: 'out-popover-wrap', onClick: (e) => { if (e.target === e.currentTarget) { state.showOutputs = false; render(); } } }, el('div', { class: 'out-popover card' }, el('div', { class: 'out-popover-head' }, el('h3', {}, 'Audio output'), el('button', { class: 'close', title: 'Close', onClick: () => { state.showOutputs = false; render(); } }, '×') ), el('div', { class: 'device-list' }, ...state.devices.list.map((d) => el('button', { class: 'device' + (d.id === state.devices.current ? ' active' : ''), onClick: () => { setSink(d.id); } }, el('span', { class: 'dot' }), el('span', { class: 'name' }, d.label), el('span', { class: 'kind' }, d.kind) ))) ) ); } function currentDeviceLabel() { const d = state.devices.list.find((d) => d.id === state.devices.current); return d ? d.label : '—'; } function showLogin() { clear(app); app.appendChild(el('div', { class: 'login' }, el('form', { onSubmit: async (e) => { e.preventDefault(); const fd = new FormData(e.target); try { state.user = await api.post('/api/auth/login', { username: fd.get('username'), password: fd.get('password') }); await bootstrap(); } catch (err) { e.target.querySelector('.err').textContent = err.message; } } }, el('h1', {}, 'Master sign in'), el('input', { name: 'username', placeholder: 'Username', required: true }), el('input', { name: 'password', type: 'password', placeholder: 'Password', required: true }), el('div', { class: 'err' }), el('button', { type: 'submit' }, 'Sign in') ))); } bootstrap();