Files
radio-explorer/web/master/main.js
Marco Mooren 29423288ca 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.
2026-05-13 13:53:12 +02:00

1100 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<void>,
// getCurrent(): Promise<string>,
// 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=<slug> 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 <audio> reports its first decoded
// sample for a (new) station, re-broadcast started_at so every peer
// aligns on a real wall-clock instant instead of the server's
// command-time estimate.
player.onPlayingOnce = (station) => {
if (!ws || !station) return;
const startedAt = clock.now ? clock.now() : Date.now();
try {
ws.send({
type: 'state',
stationId: station.id,
playing: true,
volume: state.np.volume,
started_at: startedAt
});
} catch { /* ignore */ }
// Local sync engine should re-target too so the master's own output
// honours the shared deadline.
if (player.sync.enabled) player.updateSyncTarget(startedAt);
else applyMasterSync(startedAt);
};
// Load favorites so the touch browser + heart indicator work.
try { state.favorites = await api.get('/api/me/favorites'); }
catch { state.favorites = []; }
// Multi-user surface: list of all users (tab strip), the main/shared
// identity (pinned tab + "Follow main" target), and this device's
// trusted-user whitelist (for fast switching).
try { state.users = await api.get('/api/auth/users/public'); }
catch { state.users = []; }
try { state.mainUser = await api.get('/api/auth/main'); }
catch { state.mainUser = null; }
try { state.device = await api.get('/api/auth/devices/me'); }
catch { state.device = { trusted: false, users: [] }; }
// Default tab is yourself; the strip pins main first regardless.
state.tabUser = state.user.username;
state.tabFavorites = state.favorites;
openWs();
startSyncPosBroadcast();
mountDebugPane({ player, clock, getWs: () => ws, role: 'master' });
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 { } }
clock.detach();
ws = connectWs(handleWs, {
room: state.roomSlug,
kind: 'display',
onOpen: () => advertiseDevices()
});
clock.attachWs(ws);
}
// Every 2 s while audio is actually playing, broadcast a sync-pos snapshot.
// Peers on non-HLS streams use this as the timeline anchor: at server-clock
// `atServerNow` the master's <audio>.currentTime was `masterCT`, so each
// peer projects its own expected position forward from that point. HLS
// peers prefer PROGRAM-DATE-TIME when available and ignore this; the
// snapshot is still useful as a fallback if PDT is missing or skewed.
let _syncPosTimer = null;
function startSyncPosBroadcast() {
if (_syncPosTimer) return;
_syncPosTimer = setInterval(() => {
if (!ws || !player) return;
if (player.audio.paused || player.audio.readyState < 2) return;
const station = state.np.stationId;
if (!Number.isFinite(station)) return;
try {
ws.send({
type: 'sync-pos',
stationId: station,
masterCT: player.audio.currentTime,
atServerNow: clock.now ? clock.now() : Date.now(),
pdtMs: player.hls?.playingDate?.getTime?.() ?? null,
bufferMs: player.sync?.bufferMs ?? null
});
} catch { /* ignore transient send errors */ }
}, 2000);
}
function stopSyncPosBroadcast() {
if (_syncPosTimer) { clearInterval(_syncPosTimer); _syncPosTimer = null; }
}
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', stopSyncPosBroadcast);
}
// The master is the room's authoritative audio source, but it still routes
// its own output through a DelayNode so the shared room deadline
// (startedAt + bufferMs) gives every late-joining listener room to align.
function applyMasterSync(startedAt) {
if (!player) return;
if (startedAt) player.enableSync({ clock, startedAt });
else player.disableSync();
}
function handleWs(msg) {
if (!msg || !msg.type) return;
if (msg.type === 'clock-pong') { clock.handlePong(msg); 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);
}
applyMasterSync(rs?.started_at || null);
render();
return;
}
case 'state': {
// Server echoes our own state plus its canonical started_at; the
// master uses that as the sync anchor too.
if (msg.started_at) {
if (player?.sync?.enabled) player.updateSyncTarget(msg.started_at);
else applyMasterSync(msg.started_at);
} else {
applyMasterSync(null);
}
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;
// Idempotency: ignore a play for the station already current OR
// already pending. Without these guards a flood of commands (e.g.
// multiple kiosks racing, or repeated clicks) would call
// playStation() in a loop, each one doing stop()+play() and
// producing audible start/stop thrashing.
if (id === state.np.stationId) return;
if (id === _pendingStationId) return;
_pendingStationId = id;
const gen = ++_cmdGen;
api.get(`/api/stations/${id}`).then((st) => {
// Bail if a newer command superseded this one while the
// station metadata fetch was in flight.
if (gen !== _cmdGen) return;
_pendingStationId = null;
playStation(st);
}).catch(() => {
if (gen === _cmdGen) _pendingStationId = null;
});
return;
}
case 'pause':
if (state.np.playing) player.togglePause();
return;
case 'stop':
if (!state.np.stationId) return;
player.stop();
endCurrentSession();
state.np.playing = false;
state.np.stationId = null;
sendState();
render();
return;
case 'volume':
if (typeof msg.value === 'number' && Math.abs(msg.value - state.np.volume) > 0.001) {
player.setVolume(msg.value);
}
return;
case 'setSink':
setSink(String(msg.deviceId || ''));
return;
case 'setSyncBuffer':
if (Number.isFinite(msg.value) && player) {
player.setSyncBufferMs(msg.value);
// The next sync-pos broadcast (within 2s) carries the new
// bufferMs so late joiners and reconnecting peers pick it up
// from `hello.last_sync_pos`.
}
return;
default:
return;
}
}
async function playStation(station, { silent } = {}) {
if (!station) return;
// User-initiated playStation calls (station-card click) ARE the
// gesture. Silent/hello-resume paths leave gestureUnlocked false so
// ensureGesture below surfaces the Start modal.
if (!silent) markGesture();
// Any in-flight command for a different station is now stale.
_cmdGen++;
_pendingStationId = null;
// 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();
// Mandatory gesture in plain browsers — silent/hello-resume paths
// would otherwise call audio.play() without one and iOS/Chromium
// would refuse. ensureGesture short-circuits once unlocked.
const ok = await ensureGesture(station.name, silent ? 'Tap Start to resume the group audio.' : 'Tap Start to play.');
if (!ok) return;
await player.play(station);
if (player.audio.paused) {
// Gesture confirmed but browser hasn't started yet — try once more.
player.audio.play().catch(() => { });
}
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() {
ws?.send({
type: 'state',
stationId: state.np.stationId,
playing: !!state.np.playing,
volume: state.np.volume
});
}
// Volume changes from the slider can fire at 60+ Hz. Coalesce the broadcast
// onto a short trailing timer so dragging produces ~12 messages/sec instead
// of hundreds. Cleared when a station/playing change forces an immediate send.
let _volumeBroadcastT = null;
function broadcastVolumeSoon() {
if (_volumeBroadcastT) return;
_volumeBroadcastT = setTimeout(() => {
_volumeBroadcastT = null;
sendState();
}, 80);
}
// Single rAF gate around render() so a burst of state updates within the same
// frame collapses to one DOM rebuild. Without this, dragging the volume
// slider triggered a render per pointer event, tearing the slider out from
// under the user's pointer.
let _renderScheduled = false;
function scheduleRender() {
if (_renderScheduled) return;
_renderScheduled = true;
requestAnimationFrame(() => {
_renderScheduled = false;
render();
});
}
// `render()` does a full clear+rebuild of the DOM. If we re-render while the
// user is dragging a range slider, the slider element they're holding gets
// destroyed mid-drag and the browser drops the drag. Track active slider
// drags at the document level and have render() bail until the pointer is
// released. After release we trigger one catch-up render.
let _dragLockCount = 0;
function isRangeInput(t) {
return t instanceof HTMLInputElement && t.type === 'range';
}
if (typeof window !== 'undefined') {
document.addEventListener('pointerdown', (e) => {
if (isRangeInput(e.target)) _dragLockCount++;
}, true);
const release = (e) => {
if (isRangeInput(e.target) && _dragLockCount > 0) {
_dragLockCount--;
scheduleRender();
}
};
document.addEventListener('pointerup', release, true);
document.addEventListener('pointercancel', release, true);
}
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;
// Route both the audio element AND the AudioContext. Once the WebAudio
// graph is built (always, for the master) audio exits via the context's
// destination — setSinkId on the element alone is silently ignored.
if (native?.isElectron) {
try { await player.setSinkId(deviceId); }
catch (err) { console.warn('[master] setSinkId failed', err); }
}
advertiseDevices();
state.showOutputs = false;
render();
}
function countDisplays(peers) {
return (peers || []).filter((p) => p.kind === 'display').length;
}
// ---------- Favorites ----------
function isFavorite(stationId) {
// The heart always reflects the *viewer's* library, not the active tab.
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');
// If you're currently viewing your own tab, refresh that view too.
if (state.tabUser === state.user.username) state.tabFavorites = state.favorites;
render();
} catch (err) {
console.warn('[master] toggleFavorite failed', err);
}
}
function favoriteGenres() {
const counts = new Map();
for (const s of state.tabFavorites) {
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.tabFavorites;
return state.tabFavorites.filter((s) => (s.genres || []).includes(state.favGenre));
}
// ---------- Favorite tabs (users-as-folders) ----------
/** Ordered list of tabs: main first (★), then self if not main, then others. */
function favoriteTabs() {
const tabs = [];
const seen = new Set();
if (state.mainUser) {
tabs.push({ ...state.mainUser, main: true });
seen.add(state.mainUser.username);
}
if (state.user && !seen.has(state.user.username)) {
tabs.push({ ...state.user, self: true });
seen.add(state.user.username);
}
for (const u of state.users) {
if (!seen.has(u.username)) {
tabs.push(u);
seen.add(u.username);
}
}
// Annotate "self" on whichever tab matches the current viewer.
for (const t of tabs) if (t.username === state.user.username) t.self = true;
return tabs;
}
async function switchTab(username) {
if (!username || username === state.tabUser) return;
state.tabUser = username;
state.favGenre = '';
if (username === state.user.username) {
state.tabFavorites = state.favorites;
render();
return;
}
state.tabLoading = true;
render();
try {
state.tabFavorites = await api.get(`/api/users/${encodeURIComponent(username)}/favorites`);
} catch (err) {
console.warn('[master] failed to load favorites for', username, err);
state.tabFavorites = [];
} finally {
state.tabLoading = false;
render();
}
}
/** Can the viewer write into the currently-shown tab? */
function canEditCurrentTab() {
if (!state.tabUser) return false;
if (state.tabUser === state.user.username) return true;
// Admins can curate the shared/main tab.
return state.user.role === 'admin'
&& state.mainUser && state.tabUser === state.mainUser.username;
}
// ---------- User switching (avatar picker) ----------
async function switchUser(username) {
if (!username || username === state.user.username) {
state.showAvatars = false;
render();
return;
}
try {
await api.post('/api/auth/switch', { username });
} catch (err) {
console.warn('[master] switch failed', err);
alert(err.message || 'Could not switch user');
return;
}
// Hard reload: re-bootstrap so all per-user caches (rooms, favorites, WS) are reset.
location.reload();
}
// ---------- Follow main ----------
async function followMain() {
if (!state.mainUser) return;
const personalSlug = `u-${state.mainUser.id}`;
state.roomSlug = personalSlug;
history.replaceState(null, '', `?room=${encodeURIComponent(personalSlug)}`);
// Make sure it appears in the room dropdown by refetching.
try { state.rooms = await api.get('/api/rooms'); } catch { }
openWs();
render();
}
// ---------- Render ----------
function render() {
// Bail while the user is actively dragging a range slider — see
// `_dragLockCount` for the rationale. The next pointerup will schedule
// a catch-up render.
if (_dragLockCount > 0) return;
// 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'}`),
el('div', { class: 'pill role-pill', title: 'This master is the authoritative audio source for the room.' },
'◉ Broadcasting'),
np.error ? el('div', { class: 'err-banner' }, np.error) : null,
el('div', { class: 'grow' }),
// Output picker: Electron only. In a browser tab the picker would
// be useless (no real device enumeration / sink switching).
native?.isElectron ? el('button', {
class: 'pill out-btn' + (state.showOutputs ? ' active' : ''),
title: 'Audio output',
onClick: () => { state.showOutputs = !state.showOutputs; render(); }
}, '🔊 ', currentDeviceLabel()) : null,
renderFollowMainPill(),
renderUserPill(),
),
// 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))),
// Live spectrum analyser (Electron only). The canvas is
// hooked up after the DOM is appended (see below).
native?.isElectron ? el('canvas', { class: 'np-spectrum', 'data-spectrum': '1' }) : null,
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: () => { markGesture(); player.togglePause(); }
}, np.playing ? '❚❚' : '▶'),
el('button', {
class: 'ctrl',
title: 'Stop',
disabled: !st,
onClick: () => {
markGesture();
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) => {
const v = Number(e.target.value);
player.setVolume(v);
// Update the % label inline so it tracks the
// slider while the drag-lock is suppressing
// full renders.
const valEl = e.target.parentNode?.querySelector('.val');
if (valEl) valEl.textContent = Math.round(v * 100) + '%';
}
}),
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.')])
),
renderZonesPanel()
)
)
),
// Bottom: stations grid (2 rows of the viewport)
el('section', { class: 'stations-bar' },
renderFavoritesCard()
),
// Output picker popover (hidden by default; toggled by topbar button).
native?.isElectron && state.showOutputs ? renderOutputPopover() : null,
state.showAvatars ? renderAvatarPopover() : 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);
}
// Hook up the spectrum visualiser to the freshly-rendered canvas. The
// visualiser module guards against running outside Electron and against
// double-mounting on the same <audio> element.
const canvas = app.querySelector('canvas.np-spectrum');
if (canvas && player) mountVisualizer(canvas, player);
}
// 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' });
}
// Per-zone volume panel: one slider per non-display peer. Master can dim a
// specific kiosk's local audio without touching the room's master volume.
// Sends `command:peerVolume` which the server forwards only to that user.
const _zoneVolumes = new Map(); // userId -> last value (UI memory only)
function renderZonesPanel() {
if (!ws) return null;
const zones = (state.peers || []).filter((p) => p.kind !== 'display');
if (!zones.length) return null;
return el('div', { class: 'zones-panel', title: 'Per-zone (local) volume for each connected client.' },
el('div', { class: 'zones-label' }, 'Zones'),
...zones.map((p) => {
const uid = p.user?.id;
const username = p.user?.username || '?';
const cur = _zoneVolumes.get(uid) ?? 0.7;
return el('div', { class: 'zone-row' },
el('span', { class: 'zone-name', title: `${username} (${p.kind})` }, username),
el('input', {
type: 'range', min: 0, max: 1, step: 0.05, value: cur,
'aria-label': `Volume for ${username}`,
onInput: (e) => {
const v = Number(e.target.value);
_zoneVolumes.set(uid, v);
try { ws.send({ type: 'command', action: 'peerVolume', userId: uid, value: v }); } catch { /* ignore */ }
const valEl = e.target.parentNode?.querySelector('.zone-val');
if (valEl) valEl.textContent = Math.round(v * 100) + '%';
}
}),
el('span', { class: 'zone-val' }, Math.round(cur * 100) + '%')
);
})
);
}
function renderFavoritesCard() {
const tabs = favoriteTabs();
const editable = canEditCurrentTab();
const genres = favoriteGenres();
const favs = filteredFavorites();
const tabName = state.tabUser === state.user.username
? 'My favorites'
: (state.mainUser && state.tabUser === state.mainUser.username)
? `${state.tabUser} (main)`
: state.tabUser;
return el('div', { class: 'card favs-card' },
el('div', { class: 'fav-tabs' }, ...tabs.map((t) => el('button', {
class: 'fav-tab' + (t.username === state.tabUser ? ' active' : '') + (t.main ? ' main' : '') + (t.self ? ' self' : ''),
title: t.username + (t.main ? ' (main / shared)' : '') + (t.self ? ' (you)' : ''),
onClick: () => switchTab(t.username)
},
el('span', { class: 'fav-tab-glyph' }, t.main ? '★' : (t.avatar_emoji || '●')),
el('span', { class: 'fav-tab-name' }, t.username),
))),
el('div', { class: 'favs-header' },
el('h3', {},
tabName,
' ',
el('span', { class: 'fav-count' }, `(${favs.length}${state.favGenre ? `/${state.tabFavorites.length}` : ''})`),
!editable ? el('span', { class: 'fav-readonly', title: 'Read-only — switch to your own tab to edit' }, ' · read-only') : null
),
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' }, ...(state.tabLoading
? [el('div', { class: 'favs-empty' }, 'Loading…')]
: 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.'
: editable ? 'No favorites yet. Star a station to add it.'
: `${state.tabUser} has no favorites yet.`)]))
);
}
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 : '—';
}
// ---------- User pill + avatar popover + follow-main ----------
function renderUserPill() {
const trusted = !!state.device.trusted;
const others = (state.device.users || []).filter((u) => u.username !== state.user.username);
const canSwitch = trusted && others.length > 0;
return el('button', {
class: 'pill user-pill' + (state.showAvatars ? ' active' : ''),
title: canSwitch ? 'Switch user' : (trusted ? 'No other users on this device' : 'This device is not trusted for fast switching'),
onClick: () => {
if (!canSwitch) return;
state.showAvatars = !state.showAvatars;
render();
}
},
el('span', {
class: 'avatar',
style: state.user.avatar_color ? { background: state.user.avatar_color } : {}
}, state.user.avatar_emoji || state.user.username.slice(0, 1).toUpperCase()),
el('span', {}, state.user.username),
canSwitch ? el('span', { class: 'caret' }, '▾') : null
);
}
function renderFollowMainPill() {
if (!state.mainUser) return null;
const mainSlug = `u-${state.mainUser.id}`;
const following = state.roomSlug === mainSlug;
const isMainHerself = state.user.id === state.mainUser.id;
if (isMainHerself && following) return null; // pointless when main controls main
return el('button', {
class: 'pill follow-pill' + (following ? ' active' : ''),
title: following ? `Following ${state.mainUser.username}'s group` : `Join ${state.mainUser.username}'s group (the house default)`,
onClick: () => { followMain(); }
}, following ? `◉ Following ${state.mainUser.username}` : `↗ Follow ${state.mainUser.username}`);
}
function renderAvatarPopover() {
const users = state.device.users || [];
return el('div', {
class: 'avatar-popover-wrap',
onClick: (e) => { if (e.target === e.currentTarget) { state.showAvatars = false; render(); } }
},
el('div', { class: 'avatar-popover card' },
el('div', { class: 'avatar-popover-head' },
el('h3', {}, 'Switch user'),
el('button', {
class: 'close', title: 'Close',
onClick: () => { state.showAvatars = false; render(); }
}, '×')
),
el('div', { class: 'avatar-list' }, ...users.map((u) => el('button', {
class: 'avatar-row' + (u.username === state.user.username ? ' active' : ''),
onClick: () => switchUser(u.username)
},
el('span', {
class: 'avatar lg',
style: u.avatar_color ? { background: u.avatar_color } : {}
}, u.avatar_emoji || u.username.slice(0, 1).toUpperCase()),
el('span', { class: 'avatar-name' },
u.username,
u.is_main ? el('span', { class: 'avatar-tag' }, ' ★ main') : null,
u.username === state.user.username ? el('span', { class: 'avatar-tag dim' }, ' (signed in)') : null
)
))),
el('div', { class: 'avatar-hint' },
'Add users via the admin panel → Trust device.')
)
);
}
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();