Files
radio-explorer/web/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

1486 lines
66 KiB
JavaScript

import { api, isAbort } from './shared/api.js';
import { connectWs } from './shared/ws.js';
import { el, clear } from './shared/dom.js';
import { Player, logSync } from './player.js';
import { RoomClock } from './shared/clock.js';
import { tryAutoplay, showStartModal, autoplayDismissed } from './shared/playGate.js';
import { mountDebugPane } from './shared/debug.js';
const app = document.getElementById('app');
// Mode model (refactored): two modes only.
// solo = local <audio> only, commands not broadcast, ignores room state.
// synced = UI mirrors the master's now-playing; commands target the group.
// Sub-toggle `syncedAudio` controls whether THIS device also
// plays the stream locally (with DelayNode-based alignment) or
// just observes the group's audio output silently.
// Legacy values ('play-here', 'follow-room', 'linked', 'remote') are migrated.
function readModePref() {
const raw = localStorage.getItem('oradio.mode');
if (raw === 'solo' || raw === 'synced') return raw;
if (raw === 'linked') { localStorage.setItem('oradio.mode', 'synced'); localStorage.setItem('oradio.syncedAudio', '1'); return 'synced'; }
if (raw === 'remote' || raw === 'follow-room') { localStorage.setItem('oradio.mode', 'synced'); localStorage.setItem('oradio.syncedAudio', '0'); return 'synced'; }
if (raw === 'play-here') { localStorage.setItem('oradio.mode', 'solo'); return 'solo'; }
return 'solo';
}
function readSyncedAudio() {
const raw = localStorage.getItem('oradio.syncedAudio');
if (raw === '0') return false;
if (raw === '1') return true;
return true; // default: when entering synced mode, local audio is on
}
function readLocalVolume() {
const raw = Number(localStorage.getItem('oradio.localVolume'));
if (Number.isFinite(raw) && raw >= 0 && raw <= 1) return raw;
return 0.7;
}
function readLastStation() {
try {
const raw = localStorage.getItem('oradio.lastStation');
if (!raw) return null;
const v = JSON.parse(raw);
if (v && Number.isFinite(v.id)) return v;
} catch { /* ignore */ }
return null;
}
function writeLastStation(station) {
if (!station || !Number.isFinite(station.id)) return;
try {
localStorage.setItem('oradio.lastStation', JSON.stringify({
id: station.id, name: station.name, genres: station.genres || []
}));
} catch { /* private mode */ }
}
const state = {
user: null,
tab: 'favorites', // favorites | browse | recent
stations: [],
categories: [],
selectedCategory: null,
favorites: [],
history: [],
query: '',
sort: 'hot', // hot | top | plays | name | controversial — applied in Browse
randomMode: localStorage.getItem('oradio.randomMode') === 'favorites' ? 'favorites' : 'all',
// Room sync. Two modes:
// solo = local <audio> only, controls do NOT broadcast, room state ignored
// synced = UI mirrors group; controls send commands to group
// syncedAudio sub-toggle: local stream playback ON (audible here,
// aligned via DelayNode) or OFF (silent observer / "remote")
rooms: [],
roomSlug: localStorage.getItem('oradio.room') || null,
mode: readModePref(),
syncedAudio: readSyncedAudio(),
roomState: null, // { station, station_id, playing, volume, started_at }
roomPeers: [], // [{ user, kind }]
roomDevices: { list: [], current: null },
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: readLocalVolume(), votes: null },
sync: { status: 'off', error: 0, rtt: null, bufferMs: 8000, delay: 0 }, // cross-client stream-sync indicator
// Last user click on a transport button in synced mode. Drives the
// optimistic flip of the play/stop buttons until the server's next
// `state` echo confirms (or replaces) it. Cleared on any inbound
// `state` and after a 2s safety timeout, so the UI auto-corrects when
// a command is dropped or the room rejects it.
pendingCommand: null,
session: null // { id, stationId, startedAt } for the currently-open play_history row
};
let _pendingCommandTimer = null;
function setPendingCommand(pc) {
state.pendingCommand = pc;
if (_pendingCommandTimer) { clearTimeout(_pendingCommandTimer); _pendingCommandTimer = null; }
if (pc) {
_pendingCommandTimer = setTimeout(() => {
state.pendingCommand = null;
_pendingCommandTimer = null;
render();
}, 2000);
}
}
function clearPendingCommand() {
if (state.pendingCommand == null && _pendingCommandTimer == null) return;
state.pendingCommand = null;
if (_pendingCommandTimer) { clearTimeout(_pendingCommandTimer); _pendingCommandTimer = null; }
}
const clock = new RoomClock();
const player = new Player({
onState: (s) => {
state.player = { ...state.player, ...s };
// Persist local volume changes so they survive reload.
if (typeof s.volume === 'number') {
try { localStorage.setItem('oradio.localVolume', String(s.volume)); } catch { /* ignore */ }
}
scheduleRender();
}
});
// rAF gate + drag lock — see the matching block in web/master/main.js for the
// rationale. Volume sliders fire input events at ~60 Hz; without this the
// resulting per-tick re-render tore the slider element out of the user's
// pointer drag.
let _renderScheduled = false;
function scheduleRender() {
if (_renderScheduled) return;
_renderScheduled = true;
requestAnimationFrame(() => {
_renderScheduled = false;
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 releaseDrag = (e) => {
if (isRangeInput(e.target) && _dragLockCount > 0) {
_dragLockCount--;
scheduleRender();
}
};
document.addEventListener('pointerup', releaseDrag, true);
document.addEventListener('pointercancel', releaseDrag, true);
}
// Coalesce master-volume drag ticks into a trailing WS send. Without this a
// drag at 60Hz produces dozens of room-wide rebroadcasts.
let _volumeCmdT = null;
let _pendingVolumeCmd = null;
function sendVolumeCommandSoon(v) {
_pendingVolumeCmd = v;
if (_volumeCmdT) return;
_volumeCmdT = setTimeout(() => {
_volumeCmdT = null;
const value = _pendingVolumeCmd;
_pendingVolumeCmd = null;
ws?.send({ type: 'command', action: 'volume', value });
}, 80);
}
// Same coalescing strategy for the room-wide sync buffer slider.
let _bufferCmdT = null;
let _pendingBufferCmd = null;
function sendBufferCommandSoon(ms) {
_pendingBufferCmd = ms;
if (_bufferCmdT) return;
_bufferCmdT = setTimeout(() => {
_bufferCmdT = null;
const value = _pendingBufferCmd;
_pendingBufferCmd = null;
ws?.send({ type: 'command', action: 'setSyncBuffer', value });
}, 150);
}
// Apply restored local volume immediately so the very first stream honours it.
player.setLocalVolume(readLocalVolume());
player.onSyncChange = (info) => {
state.sync = {
status: info.status,
error: info.error,
rtt: info.clockRtt,
bufferMs: info.bufferMs,
delay: info.delay
};
render();
};
let ws;
// Session-scoped flag: has the user produced a gesture (click/tap) yet?
// Plain browsers (especially iOS Safari) refuse audio playback without
// one. We never call player.audio.play() from auto-resume paths until
// this is true OR autoplayDismissed() is set (Electron opt-out). The
// flag becomes true the first time the user taps a transport button, a
// station card, or the start-modal Start button.
let gestureUnlocked = false;
// Set when the user explicitly cancels the gesture modal — suppresses
// further auto-prompts from incoming room-state broadcasts. A user
// click (transport buttons, station card) clears it.
let gestureRefused = false;
function markGesture() { gestureUnlocked = true; gestureRefused = false; }
// Show the click-to-start modal if we don't have a gesture yet.
// Returns true if we now have a gesture (modal was confirmed or
// already had one), false if the user cancelled or has already refused.
async function ensureGesture(stationName, subtitle) {
if (gestureUnlocked) return true;
if (autoplayDismissed()) { gestureUnlocked = true; return true; }
if (gestureRefused) return false;
try {
await showStartModal({
stationName: stationName || 'Radio',
subtitle: subtitle || 'Tap Start to enable audio.',
onStart: () => { gestureUnlocked = true; }
});
return gestureUnlocked;
} catch {
gestureRefused = true;
return false;
}
}
// AbortController for per-station side-effects (votes GET, play POST). Aborted
// whenever the user picks another station so stale responses can't overwrite
// the now-playing card with the previous station's stats.
let stationAbort = null;
function newStationAbort() {
if (stationAbort) { try { stationAbort.abort(); } catch { /* ignore */ } }
stationAbort = (typeof AbortController !== 'undefined') ? new AbortController() : null;
return stationAbort;
}
async function bootstrap() {
try {
state.user = await api.get('/api/auth/me');
} catch {
showLogin();
return;
}
await refreshAll();
// Load rooms; pick the user's personal room if none selected.
try {
state.rooms = await api.get('/api/rooms');
if (!state.roomSlug || !state.rooms.find((r) => r.slug === state.roomSlug)) {
state.roomSlug = (state.rooms[0] && state.rooms[0].slug) || `u-${state.user.id}`;
localStorage.setItem('oradio.room', state.roomSlug);
}
} catch { /* falls back to personal room slug */
state.roomSlug = state.roomSlug || `u-${state.user.id}`;
}
openWs();
mountDebugPane({ player, clock, getWs: () => ws, role: 'kiosk' });
render();
requestWakeLock();
// Solo restore: try to resume the last station. Use the autoplay gate so
// a blocked browser shows the click-to-start modal.
if (state.mode === 'solo') {
const last = readLastStation();
if (last && Number.isFinite(last.id)) {
try {
const full = state.favorites.find((s) => s.id === last.id)
|| state.stations.find((s) => s.id === last.id)
|| await api.get(`/api/stations/${last.id}`).catch(() => null);
if (full) await soloResumeStation(full);
} catch { /* ignore */ }
}
}
}
// Resume playback in Solo mode after a reload. Always gates on a user
// gesture in plain browsers (Electron with autoplayDismissed bypasses).
// Does NOT broadcast.
async function soloResumeStation(station) {
const ac = newStationAbort();
state.player = {
...state.player,
stationId: station.id,
stationName: station.name,
genres: station.genres || [],
playing: false,
loading: true,
error: null
};
render();
const ok = await ensureGesture(station.name, 'Tap Start to resume your last station.');
if (!ok) {
state.player.loading = false;
state.player.playing = false;
render();
return;
}
await player.play(station);
if (player.audio.paused) {
// The gesture was confirmed but the browser still hasn't started
// — likely an immediate-after-modal race. Try once more.
player.audio.play().catch(() => { });
}
// Record the play once it's actually rolling.
recordHistory(station.id);
try {
const stats = await api.post(`/api/stations/${station.id}/play`, null, ac ? { signal: ac.signal } : undefined);
if (state.player.stationId === station.id) {
state.player.votes = stats;
if (stats.sessionId) state.session = { id: stats.sessionId, stationId: station.id, startedAt: Date.now() };
mergeStats(station.id, stats);
render();
}
} catch (err) { if (!isAbort(err)) { /* ignore */ } }
}
function openWs() {
if (ws) { try { ws.close(); } catch { } }
clock.detach();
// 'panel' = no local audio, mirror display. 'controller' = browser may play locally.
const kind = (state.mode === 'synced' && !state.syncedAudio) ? 'panel' : 'controller';
logSync('openWs', { mode: state.mode, syncedAudio: state.syncedAudio, kind, room: state.roomSlug });
ws = connectWs(handleWs, { room: state.roomSlug, kind });
clock.attachWs(ws);
}
// True when this client's controls should also broadcast commands to the group.
function broadcasts() { return state.mode === 'synced'; }
function setMode(mode) {
if (mode !== 'solo' && mode !== 'synced') return;
if (state.mode === mode) return;
const prev = state.mode;
state.mode = mode;
localStorage.setItem('oradio.mode', mode);
// Switching to Solo: stop mirroring; user's local audio (if any) keeps playing.
// Switching to Synced + audio OFF: stop local audio so we observe silently.
if (mode === 'synced' && !state.syncedAudio && state.player.stationId) {
player.stop();
endCurrentSession();
}
applySyncMode();
// Re-open WS only if the connection kind changed (controller vs panel).
const prevKind = (prev === 'synced' && !state.syncedAudio) ? 'panel' : 'controller';
const nextKind = (mode === 'synced' && !state.syncedAudio) ? 'panel' : 'controller';
if (prevKind !== nextKind) openWs();
if (mode === 'synced') autoJoinRoomPlayback();
render();
}
function setSyncedAudio(on) {
on = !!on;
if (state.syncedAudio === on) return;
const wasKind = (state.mode === 'synced' && !state.syncedAudio) ? 'panel' : 'controller';
state.syncedAudio = on;
try { localStorage.setItem('oradio.syncedAudio', on ? '1' : '0'); } catch { /* ignore */ }
// In Synced: turning audio OFF stops local stream; turning ON will join.
if (state.mode === 'synced') {
if (!on && state.player.stationId) {
player.stop();
endCurrentSession();
}
applySyncMode();
const nextKind = (!on) ? 'panel' : 'controller';
if (wasKind !== nextKind) openWs();
if (on) autoJoinRoomPlayback();
}
render();
}
// When in Synced mode and the room has a station playing, get the local view
// (and local audio, when syncedAudio is on) onto that station without
// requiring an extra click. Idempotent.
function autoJoinRoomPlayback() {
const rs = state.roomState;
if (!rs || !rs.station_id || !rs.playing) return;
if (state.mode !== 'synced') return;
if (!state.syncedAudio) {
// Pure UI mirror — no local audio.
applyRoomStateToUI();
render();
return;
}
// "Already on the same station" check needs to use the actual Player
// state, NOT state.player.stationId — the latter gets optimistically
// mirrored from the room state by applyRoomStateToUI before audio is
// loaded, which would otherwise fool us into thinking we're already
// playing and skip the gesture-gated join on a cold reload.
if (player.station?.id === rs.station_id && !player.audio.paused) return;
const station = state.stations.find((s) => s.id === rs.station_id)
|| state.favorites.find((s) => s.id === rs.station_id)
|| (rs.station && rs.station.id === rs.station_id ? rs.station : null);
if (!station) return;
joinStationSilently(station);
}
// Like playStation() but does NOT broadcast a command — used when we are
// joining an in-progress group playback. Always gates on a user gesture
// in plain browsers (Electron with autoplayDismissed bypasses).
async function joinStationSilently(station) {
// Concurrency guard: while the gesture modal is open (or play is
// in-flight) further state broadcasts will re-trigger autoJoin. Bail
// out if we're already loading this station.
if (state.player.loading && state.player.stationId === station.id) return;
const ac = newStationAbort();
state.player.votes = null;
endCurrentSession();
writeLastStation(station);
// Optimistic UI: flip card to the new station immediately.
state.player.stationId = station.id;
state.player.stationName = station.name;
state.player.genres = station.genres || [];
state.player.loading = true;
render();
const ok = await ensureGesture(station.name, 'Tap Start to join the group audio.');
if (!ok) {
state.player.loading = false;
render();
return;
}
await player.play(station);
if (player.audio.paused) {
// Gesture confirmed but browser hasn't started yet — try once more.
player.audio.play().catch(() => { });
}
recordHistory(station.id);
try {
const stats = await api.post(`/api/stations/${station.id}/play`, null, ac ? { signal: ac.signal } : undefined);
if (state.player.stationId === station.id) {
state.player.votes = stats;
if (stats.sessionId) {
state.session = { id: stats.sessionId, stationId: station.id, startedAt: Date.now() };
}
mergeStats(station.id, stats);
render();
}
} catch (err) { if (!isAbort(err)) { /* ignore */ } }
}
// Enable Player drift correction whenever we're in synced mode with local
// audio. Disable in solo or in synced-without-audio.
function applySyncMode() {
const rs = state.roomState;
if (state.mode === 'synced' && state.syncedAudio) {
player.enableSync({ clock, startedAt: rs?.started_at || null });
} else {
player.disableSync();
}
}
function setRoom(slug) {
if (!slug || state.roomSlug === slug) return;
state.roomSlug = slug;
localStorage.setItem('oradio.room', slug);
state.roomPeers = [];
state.roomState = null;
openWs();
render();
}
async function refreshAll() {
const [stations, favs, history, categories] = await Promise.all([
api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`),
api.get('/api/me/favorites').catch(() => []),
api.get('/api/me/history').catch(() => []),
api.get('/api/v1/categories').catch(() => [])
]);
state.stations = stations;
state.favorites = favs;
state.history = history;
state.categories = categories;
}
async function refreshStations() {
state.stations = await api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`);
}
function handleWs(msg) {
if (!msg || !msg.type) return;
if (msg.type === 'clock-pong') { clock.handlePong(msg); return; }
switch (msg.type) {
case 'hello':
state.roomState = msg.state || null;
state.roomPeers = msg.peers || [];
logSync('hello', { mode: state.mode, syncedAudio: state.syncedAudio, roomState: state.roomState });
if (state.mode === 'synced') applyRoomStateToUI();
applySyncMode();
// Replay the last sync-pos the server cached so a reconnecting
// peer locks to master's timeline immediately instead of waiting
// for the next 2-second broadcast.
if (msg.last_sync_pos && state.mode === 'synced' && state.syncedAudio) {
try { player.acceptMasterPos(msg.last_sync_pos); } catch { /* ignore */ }
}
if (state.mode === 'synced') autoJoinRoomPlayback();
render();
return;
case 'sync-pos':
if (state.mode === 'synced' && state.syncedAudio) {
try { player.acceptMasterPos(msg); } catch { /* ignore */ }
}
return;
case 'presence':
state.roomPeers = msg.peers || [];
render();
return;
case 'devices':
state.roomDevices = { list: msg.list || [], current: msg.current || null };
render();
return;
case 'state':
state.roomState = { ...state.roomState, ...msg };
logSync('state', msg);
// A real state echo from the server is the convergence point —
// drop any optimistic pending command and let the canonical
// state drive the buttons.
clearPendingCommand();
if (state.mode === 'synced') applyRoomStateToUI();
// Update the sync anchor if the server is telling us one. A null
// `started_at` just means the master hasn't (re-)anchored yet
// for the upcoming station — leave the existing sync state alone
// so we don't ramp the DelayNode down/up on every transient
// broadcast during the brief loading window.
if (state.mode === 'synced' && state.syncedAudio) {
if (msg.started_at) {
player.updateSyncTarget(msg.started_at);
applySyncMode();
}
}
// Render NOW so the optimistic card flip is visible before any
// async fetches kick off in autoJoinRoomPlayback.
render();
if (state.mode === 'synced') autoJoinRoomPlayback();
// autoJoinRoomPlayback only handles the "should be playing X"
// case. Pause and stop reactions for local audio used to live in
// the `command` handler; mirror them off the canonical state
// instead so we have a single source of truth.
if (state.mode === 'synced' && state.syncedAudio) {
const rs = state.roomState;
if (rs && rs.playing === false && !player.audio.paused) {
player.togglePause();
}
// Resume case: room is playing the station we already have
// locally, but our <audio> is paused (e.g. another peer hit
// play). autoJoinRoomPlayback only handles station changes,
// so it won't catch this.
if (rs && rs.playing && rs.station_id && rs.station_id === state.player.stationId && player.audio.paused) {
player.togglePause();
}
if (rs && !rs.station_id && state.player.stationId) {
player.stop();
state.player = { ...state.player, stationId: null, stationName: null, genres: [], playing: false, loading: false };
render();
}
}
return;
case 'vote': {
// Live vote update from any client (including ourselves).
const id = msg.stationId;
const stats = msg.stats || {};
for (const arr of [state.stations, state.favorites]) {
const hit = arr.find((s) => s.id === id);
if (hit) {
if ('up' in stats) hit.up = stats.up;
if ('down' in stats) hit.down = stats.down;
if ('score' in stats) hit.score = stats.score;
}
}
if (state.player.votes && state.player.stationId === id) {
state.player.votes = { ...state.player.votes, ...stats };
}
render();
return;
}
case 'plays': {
const id = msg.stationId;
for (const arr of [state.stations, state.favorites]) {
const hit = arr.find((s) => s.id === id);
if (hit) hit.plays = msg.plays;
}
if (state.player.votes && state.player.stationId === id) {
state.player.votes = { ...state.player.votes, plays: msg.plays };
}
render();
return;
}
case 'peerVolume': {
// Master sent a targeted local-volume nudge for this user. Apply
// as LOCAL volume only — never broadcasts and never alters the
// master's room volume.
if (typeof msg.value === 'number') {
player.setLocalVolume(Math.max(0, Math.min(1, msg.value)));
render();
}
return;
}
case 'command': {
// setSyncBuffer is the one command we DO mirror locally: it has
// no `state` echo (server doesn't persist it in room_state), and
// every client must adopt the new bufferMs immediately so their
// DelayNode delays converge. Skipped on the originator since the
// server forwards with except=sender; the originator applied it
// optimistically when moving the slider.
if (msg.action === 'setSyncBuffer' && Number.isFinite(msg.value)) {
player.setSyncBufferMs(msg.value);
return;
}
// All other commands have a canonical `state` echo (see
// server/ws.js — broadcastToRoom command, then state). Reacting
// only to that state echo eliminates the double-play race that
// used to exist when both paths called player.play().
return;
}
default:
return;
}
}
// Mirror the authoritative room state into the local "player" view-model so
// the now-playing card renders the same on all panels. In Synced mode the
// card reflects the room; local <audio> may or may not play depending on
// the syncedAudio sub-toggle.
function applyRoomStateToUI() {
const rs = state.roomState;
if (!rs) return;
// When syncedAudio is ON, the local Player drives `state.player` directly.
// We still want the station NAME/genres/playing flag to track the room
// (e.g. while loading) so the card doesn't lag, but we must not
// overwrite the local <audio>'s volume.
if (state.mode === 'synced' && state.syncedAudio) {
if (state.player.stationId && rs.station_id === state.player.stationId) {
// Same station — refresh metadata (name/genres may have arrived
// late) but don't touch the playing/loading flags.
state.player = {
...state.player,
stationName: rs.station?.name || state.player.stationName,
genres: rs.station?.genres || state.player.genres || []
};
} else if (!state.player.stationId && rs.station_id) {
// We're idle locally — adopt the room's current station.
state.player = {
...state.player,
stationId: rs.station_id,
stationName: rs.station?.name || null,
genres: rs.station?.genres || []
};
}
// Otherwise (rs is for a different station than we're locally
// playing) we intentionally do NOT clobber state.player.stationId.
// The newer command/state for our actual station will arrive next
// and converge us; meanwhile UI keeps showing the right thing.
return;
}
// Synced + audio OFF: full mirror, no local audio.
state.player = {
...state.player,
stationId: rs.station_id ?? rs.station?.id ?? null,
stationName: rs.station?.name || null,
genres: rs.station?.genres || [],
playing: !!rs.playing,
loading: false,
// volume here is the displayed *master* volume in the UI.
volume: typeof rs.volume === 'number' ? rs.volume : state.player.volume,
error: null
};
}
function showLogin() {
clear(app);
const overlay = 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', {}, 'Sign in'),
el('input', { name: 'username', placeholder: 'Username', autocomplete: 'username', required: true }),
el('input', { name: 'password', type: 'password', placeholder: 'Password', autocomplete: 'current-password', required: true }),
el('div', { class: 'err' }),
el('button', { type: 'submit' }, 'Continue')
)
);
app.appendChild(overlay);
}
let savedGridScroll = 0;
function renderVolumeSliders(mode, syncedAudio, masterVol, localVol) {
const showMaster = mode === 'synced';
const showLocal = mode === 'solo' || (mode === 'synced' && syncedAudio);
const out = [];
if (showMaster) {
const title = 'Group (master) volume — broadcasts to the room';
out.push(el('div', { class: 'vol vol-master', title },
el('span', { class: 'vol-icon' }, '📡'),
el('input', {
type: 'range', min: 0, max: 1, step: 0.05, value: masterVol,
'aria-label': title, title,
onInput: (e) => {
const v = Number(e.target.value);
if (state.roomState) state.roomState.volume = v;
sendVolumeCommandSoon(v);
const valEl = e.target.parentNode?.querySelector('.val');
if (valEl) valEl.textContent = String(Math.round(v * 100));
}
}),
el('span', { class: 'val' }, Math.round(masterVol * 100))
));
}
if (showLocal) {
const title = mode === 'synced' ? 'Local volume — this device only' : 'Local volume';
out.push(el('div', { class: 'vol vol-local', title },
el('span', { class: 'vol-icon' }, localVol === 0 ? '🔇' : localVol < 0.5 ? '🔈' : '🔊'),
el('input', {
type: 'range', min: 0, max: 1, step: 0.05, value: localVol,
'aria-label': title, title,
onInput: (e) => {
const v = Number(e.target.value);
player.setLocalVolume(v);
const valEl = e.target.parentNode?.querySelector('.val');
if (valEl) valEl.textContent = String(Math.round(v * 100));
}
}),
el('span', { class: 'val' }, Math.round(localVol * 100))
));
}
return out;
}
function render() {
if (!state.user) return;
// Suspend the full DOM rebuild while the user is dragging a slider —
// pointerup will trigger a catch-up render.
if (_dragLockCount > 0) return;
// Capture scroll from the live grid before tearing down (in case it changed since last scroll event).
const prevGrid = app.querySelector('.grid');
if (prevGrid && prevGrid.scrollTop > 0) savedGridScroll = prevGrid.scrollTop;
closeContextMenu();
clear(app);
const p = state.player;
const favIds = new Set(state.favorites.map((f) => f.id));
const v = p.votes; // { up, down, plays, myVote, score } or null
// Mode-aware labels so it's obvious what each control will touch.
const M = state.mode; // 'solo' | 'synced'
const SA = state.syncedAudio; // bool — relevant only when M==='synced'
const rs = state.roomState;
const roomPlaying = !!(rs && rs.station_id && rs.playing);
// Canonical "what the buttons should show". In synced mode the room
// state is the source of truth so every client agrees regardless of
// local audio buffering. In solo mode the local player is the truth.
// A recent click sets state.pendingCommand to flip the view
// optimistically until the next `state` echo arrives (or the 2s
// safety timeout expires).
let view;
if (M === 'synced' && rs) {
view = {
stationId: rs.station_id ?? null,
stationName: rs.station?.name ?? p.stationName,
playing: !!rs.playing
};
} else {
view = { stationId: p.stationId, stationName: p.stationName, playing: p.playing };
}
if (state.pendingCommand) {
view = { ...view, playing: state.pendingCommand.playing, stationId: state.pendingCommand.stationId ?? view.stationId };
}
// In Synced+audio mode, when we're not yet aligned with the group's
// station, pressing play should JOIN silently (no outbound command).
const canJoinSilently =
M === 'synced' && SA && roomPlaying &&
(!p.stationId || p.stationId !== rs.station_id);
const playTitle = canJoinSilently
? 'Join group audio'
: (view.playing
? (M === 'synced' ? (SA ? 'Pause here & group' : 'Pause group') : 'Pause')
: (M === 'synced' ? (SA ? 'Play here & group' : 'Play on group') : 'Play'));
const stopTitle = M === 'synced' ? (SA ? 'Stop here & group' : 'Stop group') : 'Stop';
const groupStationName = roomPlaying ? (rs.station?.name || 'group audio') : '';
const idleSub = canJoinSilently
? `Tap ▶ to join “${groupStationName}`
: M === 'synced'
? (SA ? 'Idle — pick a station to play here and on the group' : 'Idle — pick a station to play it on the group')
: 'Idle';
// Sub-line for the now-playing card. Prefer the canonical room view in
// synced mode; surface a small "audio: …" hint when the local audio
// element disagrees (still loading / waiting for the buffer / silently
// observing) so the user understands why their speakers may be silent
// even though the room is playing.
let subLine;
if (p.loading) subLine = 'Connecting…';
else if (view.playing) {
if (M === 'synced' && SA && p.stationId === view.stationId && !p.playing) subLine = 'On air — local audio loading…';
else if (M === 'synced' && !SA) subLine = 'On air (silent observer)';
else subLine = 'On air';
} else if (p.error) subLine = p.error;
else if (view.stationId) subLine = 'Paused';
else subLine = idleSub;
// Master (room) volume vs Local (this device) volume.
// Solo: only local. Synced+audio: BOTH (master broadcasts, local affects only this audio).
// Synced+!audio: only master.
const masterVol = typeof rs?.volume === 'number' ? rs.volume : 0.7;
const localVol = p.volume;
const now = el('section', { class: 'now' },
el('div', { class: 'meta' },
el('div', { class: 'name' }, view.stationName || 'Select a station'),
el('div', { class: 'sub' }, subLine),
el('div', { class: 'tags' }, ...(p.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g)))
),
el('div', { class: 'controls' },
el('div', { class: 'vote-group', title: 'Vote on current station' },
el('button', {
class: `vote up ${v?.myVote === 1 ? 'on' : ''}`,
disabled: !view.stationId,
title: 'Upvote',
onClick: () => votePlayer(1)
}, el('span', { class: 'vote-icon' }, '▲'),
el('span', { class: 'vote-count' }, String(v?.up ?? 0))),
el('button', {
class: `vote down ${v?.myVote === -1 ? 'on' : ''}`,
disabled: !view.stationId,
title: 'Downvote',
onClick: () => votePlayer(-1)
}, el('span', { class: 'vote-icon' }, '▼'),
el('span', { class: 'vote-count' }, String(v?.down ?? 0)))
),
el('button', {
class: `btn-play ${p.loading ? 'loading' : ''} ${canJoinSilently ? 'cta-join' : ''}`,
title: playTitle,
'aria-label': playTitle,
onClick: () => {
markGesture();
if (state.mode === 'synced' && !state.syncedAudio) {
const target = view.stationId || state.favorites[0]?.id;
setPendingCommand({ stationId: target, playing: !view.playing });
ws?.send({ type: 'command', action: view.playing ? 'pause' : 'play', stationId: target });
render();
return;
}
// Synced + audio on, not on the room's current station => JOIN silently
if (canJoinSilently) {
const st = state.stations.find((s) => s.id === rs.station_id)
|| state.favorites.find((s) => s.id === rs.station_id);
if (st) {
setPendingCommand({ stationId: st.id, playing: true });
joinStationSilently(st);
return;
}
}
if (view.stationId) {
const wasPlaying = view.playing;
if (state.mode === 'synced' && state.syncedAudio) {
setPendingCommand({ stationId: view.stationId, playing: !wasPlaying });
}
player.togglePause();
if (broadcasts()) {
ws?.send({ type: 'command', action: wasPlaying ? 'pause' : 'play', stationId: view.stationId });
}
} else if (state.favorites[0]) {
playStation(state.favorites[0]);
}
}
}, view.playing ? '❚❚' : '▶'),
el('button', {
class: 'btn-stop',
title: stopTitle,
'aria-label': stopTitle,
disabled: !view.stationId,
onClick: () => {
markGesture();
if (state.mode === 'synced' && !state.syncedAudio) {
setPendingCommand({ stationId: null, playing: false });
ws?.send({ type: 'command', action: 'stop' });
render();
} else {
if (state.mode === 'synced' && state.syncedAudio) {
setPendingCommand({ stationId: null, playing: false });
}
player.stop();
endCurrentSession();
if (broadcasts()) ws?.send({ type: 'command', action: 'stop' });
}
}
}, '■'),
// Volume cluster: master (group) and local (this device).
// Solo: just one slider (local). Synced+audio: both. Synced+!audio: just master.
...renderVolumeSliders(M, SA, masterVol, localVol)
)
);
const isAdmin = state.user.role === 'admin';
const header = el('div', { class: 'header' },
el('div', { class: 'tabs' },
...['favorites', 'browse', 'recent'].map((t) =>
el('button', {
class: `tab ${state.tab === t ? 'active' : ''}`,
onClick: () => { state.tab = t; savedGridScroll = 0; render(); }
},
t === 'favorites' ? '★ Favorites' : t === 'browse' ? '🌐 Browse' : '⏱ Recent')
)
),
el('div', { class: 'header-tools' },
renderRoomPill(),
state.tab === 'browse'
? el('select', {
class: 'sort',
title: 'Sort browse list',
onChange: (e) => { state.sort = e.target.value; savedGridScroll = 0; refreshStations().then(render); }
},
el('option', { value: 'hot', selected: state.sort === 'hot' }, '🔥 Hot (smart)'),
el('option', { value: 'top', selected: state.sort === 'top' }, '▲ Top voted'),
el('option', { value: 'plays', selected: state.sort === 'plays' }, '▶ Most played'),
el('option', { value: 'controversial', selected: state.sort === 'controversial' }, '⚡ Controversial'),
el('option', { value: 'name', selected: state.sort === 'name' }, 'A → Z')
)
: null,
el('input', {
class: 'search', type: 'search', placeholder: 'Search…', value: state.query,
onInput: (e) => { state.query = e.target.value; renderGrid(); }
}),
el('button', {
class: 'btn-random',
title: `Play random station (mode: ${state.randomMode}). Right-click to switch mode.`,
onClick: playRandom,
onContextMenu: (e) => { e.preventDefault(); toggleRandomMode(); }
},
el('span', { class: 'rand-icon' }, '🎲'),
el('span', { class: 'rand-mode' }, state.randomMode === 'favorites' ? '★' : 'All')
),
el('a', {
class: 'btn-docs', href: '/docs/', target: '_blank', rel: 'noopener',
title: 'Open API reference'
}, 'API'),
isAdmin ? el('button', { class: 'btn-add', title: 'Add station', onClick: openAddStation }, '+') : null
)
);
const sec = el('section', { class: 'lib' }, header);
if (state.tab === 'browse' && state.categories.length) {
sec.appendChild(renderChips());
}
const grid = el('div', { class: 'grid' });
grid.id = 'grid';
grid.addEventListener('scroll', () => { savedGridScroll = grid.scrollTop; }, { passive: true });
sec.appendChild(grid);
app.appendChild(now);
app.appendChild(sec);
paintGrid(grid, favIds);
if (savedGridScroll) {
grid.scrollTop = savedGridScroll;
requestAnimationFrame(() => { if (savedGridScroll) grid.scrollTop = savedGridScroll; });
}
}
function renderRoomPill() {
const peers = state.roomPeers || [];
const hasDisplay = peers.some((p) => p.kind === 'display');
const roomName = (state.rooms.find((r) => r.slug === state.roomSlug)?.name) || 'My group';
const MODES = [
{ id: 'solo', icon: '🎧', label: 'Solo', title: 'Local audio only. Controls do not affect the group.' },
{ id: 'synced', icon: '🔗', label: 'Synced', title: `Mirror the ${roomName} group's UI. Controls target the group. Local audio is optional.` }
];
// Sync chip: green when in-sync, amber when still lagging, grey when off.
const sync = state.sync || { status: 'off', error: 0, rtt: null, bufferMs: 8000, delay: 0 };
const showSync = state.mode === 'synced' && state.syncedAudio && sync.status !== 'off';
const bufSec = ((sync.bufferMs ?? 8000) / 1000).toFixed(1);
const delaySec = (sync.delay ?? 0).toFixed(2);
const syncMeta = {
'no-anchor': { dot: '⚪', label: 'wait', title: 'Waiting for the room to start playback' },
'measuring': { dot: '🟡', label: '…', title: `Measuring clock (buffer ${bufSec}s)` },
'in-sync': { dot: '🟢', label: 'sync', title: `In sync — holding ${delaySec}s of ${bufSec}s buffer (RTT ${sync.rtt?.toFixed?.(0) ?? '?'} ms)` },
'lagging': { dot: '🟡', label: 'lag', title: `Joined too late by ${sync.error.toFixed(1)}s — increase the buffer to catch up` },
'no-buffer': { dot: '⚠️', label: 'cors', title: 'Stream is cross-origin without CORS — sync buffer disabled.' },
'off': { dot: '', label: '', title: '' }
}[sync.status] || { dot: '', label: '', title: '' };
return el('div', { class: `room-pill mode-${state.mode}`, title: 'Listening mode & group' },
el('div', { class: 'rp-group', title: hasDisplay ? 'Group display online' : 'Group' },
el('span', { class: 'rp-group-icon' }, hasDisplay ? '📻' : '🏠'),
el('select', {
class: 'rp-group-select',
onChange: (e) => setRoom(e.target.value),
'aria-label': 'Group'
}, ...(state.rooms.length ? state.rooms : [{ slug: state.roomSlug || '', name: 'My group' }])
.map((r) => el('option', { value: r.slug, selected: r.slug === state.roomSlug }, r.name))),
el('span', { class: 'rp-peers', title: `${peers.length} client(s) connected${hasDisplay ? ' • display online' : ''}` },
`${peers.length}`)
),
el('div', { class: 'mode-pill', role: 'radiogroup', 'aria-label': 'Listening mode' },
el('span', { class: `mode-indicator pos-${state.mode}`, 'aria-hidden': 'true' }),
...MODES.map((m) => el('button', {
class: `mode-seg ${state.mode === m.id ? 'on' : ''}`,
role: 'radio',
'aria-checked': state.mode === m.id ? 'true' : 'false',
title: m.title,
onClick: () => setMode(m.id)
}, el('span', { class: 'mode-seg-icon' }, m.icon),
el('span', { class: 'mode-seg-label' }, m.label)))
),
// Synced-only sub-toggle: should this device also play the stream locally?
state.mode === 'synced' ? el('label', {
class: `synced-audio-toggle ${state.syncedAudio ? 'on' : 'off'}`,
title: state.syncedAudio
? 'Local synced audio ON — this device plays the group stream (aligned to the room).'
: 'Local synced audio OFF — silent observer of the group.'
},
el('input', {
type: 'checkbox',
checked: state.syncedAudio,
onChange: (e) => setSyncedAudio(e.target.checked),
'aria-label': 'Local synced audio'
}),
el('span', { class: 'sa-icon' }, state.syncedAudio ? '🔊' : '🔇'),
el('span', { class: 'sa-label' }, state.syncedAudio ? 'Audio' : 'Silent')
) : null,
showSync ? el('span', { class: `sync-chip sync-${sync.status}`, title: syncMeta.title },
el('span', { class: 'sync-dot' }, syncMeta.dot),
el('span', { class: 'sync-label' }, syncMeta.label),
el('input', {
class: 'sync-buffer',
type: 'range',
min: '2',
max: '20',
step: '0.5',
value: String((sync.bufferMs ?? 8000) / 1000),
title: `Sync buffer: ${bufSec}s. Bigger = easier for slow devices to align (more startup lag).`,
'aria-label': 'Sync buffer seconds',
onInput: (e) => {
const secs = Number(e.target.value);
if (!Number.isFinite(secs)) return;
const ms = secs * 1000;
// Apply locally for immediate audio response, then
// (in synced mode) broadcast so every other client's
// DelayNode converges to the same delay.
player.setSyncBufferMs(ms);
if (state.mode === 'synced') sendBufferCommandSoon(ms);
// Tooltip update only; the chip's text label re-renders at drag-release.
e.target.title = `Sync buffer: ${secs.toFixed(1)}s. Bigger = easier for slow devices to align (more startup lag).`;
}
})
) : null
);
}
function renderChips() {
return el('div', { class: 'chips' },
el('button', {
class: `chip ${!state.selectedCategory ? 'active' : ''}`,
onClick: () => { state.selectedCategory = null; savedGridScroll = 0; render(); }
}, `All (${state.stations.length})`),
...state.categories.filter((c) => c.count > 0).map((c) => el('button', {
class: `chip ${state.selectedCategory === c.id ? 'active' : ''}`,
onClick: () => { state.selectedCategory = c.id; savedGridScroll = 0; render(); }
}, `${c.icon || ''} ${c.label} (${c.count})`.trim()))
);
}
function visibleItems() {
let items = [];
if (state.tab === 'favorites') items = state.favorites;
else if (state.tab === 'browse') {
items = state.stations;
if (state.selectedCategory) items = items.filter((s) => s.category === state.selectedCategory);
} else if (state.tab === 'recent') {
const seen = new Set();
items = state.history.filter((h) => !seen.has(h.station_id) && seen.add(h.station_id))
.map((h) => state.stations.find((s) => s.id === h.station_id)).filter(Boolean);
}
const q = state.query.trim().toLowerCase();
if (q) {
items = items.filter((s) =>
s.name.toLowerCase().includes(q) ||
(s.country || '').toLowerCase().includes(q) ||
(s.genres || []).some((g) => g.toLowerCase().includes(q))
);
}
return items;
}
function renderGrid() {
const grid = document.getElementById('grid');
if (!grid) return;
const favIds = new Set(state.favorites.map((f) => f.id));
paintGrid(grid, favIds);
}
function paintGrid(grid, favIds) {
clear(grid);
const items = visibleItems();
if (!items.length) {
grid.appendChild(el('div', { class: 'empty' },
state.tab === 'favorites' ? 'No favorites yet — long-press or tap ★ on a station.' :
state.query ? 'No matches.' : 'Nothing here yet.'));
return;
}
// In synced mode highlight the room's active card; in solo, the local
// player's. Matches what the play/stop buttons reflect.
const activeId = (state.mode === 'synced' && state.roomState)
? (state.roomState.station_id ?? null)
: state.player.stationId;
for (const s of items) {
const score = typeof s.score === 'number' ? s.score : 0;
const net = (s.up ?? 0) - (s.down ?? 0);
const badgeClass = net > 0 ? 'pos' : net < 0 ? 'neg' : 'neu';
const card = el('div', {
class: `card ${activeId === s.id ? 'playing' : ''}`,
role: 'button',
tabindex: 0,
onClick: () => playStation(s),
onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); }
},
el('div', { class: 'art' },
(s.image_display_url || s.image_url)
? el('img', {
class: 'art-img',
src: s.image_display_url || s.image_url,
alt: '',
loading: 'lazy',
referrerpolicy: 'no-referrer',
onError: (e) => {
const parent = e.target.parentNode;
e.target.remove();
if (parent) parent.appendChild(el('span', { class: 'art-glyph' }, '♪'));
}
})
: el('span', { class: 'art-glyph' }, '♪')),
el('div', { class: 'card-body' },
el('div', { class: 'n' }, s.name),
el('div', { class: 'g' },
(s.genres || []).slice(0, 3).join(' · ') || (s.country || '—'))
),
el('div', { class: `score-badge ${badgeClass}`, title: `${s.up ?? 0} · ▼${s.down ?? 0} · ▶${s.plays ?? 0} · score ${score.toFixed(2)}` },
net > 0 ? `+${net}` : String(net)
),
el('button', {
class: `fav ${favIds.has(s.id) ? 'on' : ''}`,
title: favIds.has(s.id) ? 'Remove favorite' : 'Add favorite',
onClick: (e) => { e.stopPropagation(); toggleFavorite(s); }
}, favIds.has(s.id) ? '★' : '☆'),
el('button', {
class: 'more',
title: 'API endpoints',
onClick: (e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
openContextMenu(r.right, r.bottom, s);
}
}, '⋯')
);
grid.appendChild(card);
}
}
async function toggleFavorite(station) {
const has = state.favorites.some((f) => f.id === station.id);
if (has) await api.del(`/api/me/favorites/${station.id}`);
else await api.put(`/api/me/favorites/${station.id}`, { position: state.favorites.length });
state.favorites = await api.get('/api/me/favorites');
render();
}
function toggleRandomMode() {
state.randomMode = state.randomMode === 'favorites' ? 'all' : 'favorites';
localStorage.setItem('oradio.randomMode', state.randomMode);
toast(`Random mode: ${state.randomMode === 'favorites' ? 'favorites only' : 'all stations'}`);
render();
}
// Pick a random station and play it. Uses the public /api/v1/stations/random
// for "all" mode, /api/me/favorites/random for "favorites" mode. Falls back
// to a local random pick if the network call fails.
async function playRandom() {
try {
const ep = state.randomMode === 'favorites'
? '/api/me/favorites/random'
: '/api/v1/stations/random';
const remote = await api.get(ep);
// The kiosk player needs the internal numeric id (used by /resolve etc.).
// The favorites endpoint returns it directly; the v1 endpoint does not,
// so resolve via the cached station list (or skip if missing).
let station = remote;
if (station.id == null) {
station = state.stations.find((s) => s.uuid === remote.uuid) || null;
}
if (!station) { toast('Random station not in cache'); return; }
playStation(station);
} catch (err) {
// Fallback: pick locally so the button still does something offline.
const pool = state.randomMode === 'favorites' ? state.favorites : state.stations;
if (!pool.length) { toast(err.message || 'No stations available'); return; }
playStation(pool[Math.floor(Math.random() * pool.length)]);
}
}
function recordHistory(stationId) {
// Server-side history insertion can be added later; for now, optimistic local insert.
state.history.unshift({ station_id: stationId, started_at: new Date().toISOString() });
}
// Play wrapper: starts playback, pings server play counter, fetches vote state
// so the up/down buttons in the now-playing bar reflect the current station.
async function playStation(station) {
// Reaching playStation always comes from a user gesture (card tap,
// favorite click, random button). Credit it once and never gate this
// path on the start-modal — clicking ▶ IS the gesture.
markGesture();
const ac = newStationAbort();
state.player.votes = null;
// Close whatever was playing before; the upcoming POST opens a fresh row.
endCurrentSession();
// Remember this as the last station for Solo restore-on-reload.
if (state.mode === 'solo') writeLastStation(station);
if (state.mode === 'synced' && !state.syncedAudio) {
// Don't touch local audio — ask the room's display to play and let
// the resulting `state` message update our UI.
setPendingCommand({ stationId: station.id, playing: true });
ws?.send({ type: 'command', action: 'play', stationId: station.id });
// Optimistically reflect locally so the card highlights immediately.
state.player = {
...state.player,
stationId: station.id,
stationName: station.name,
genres: station.genres || [],
playing: true,
loading: false,
error: null
};
render();
// No local audio means no local session — the master records its own.
try {
const stats = await api.get(`/api/stations/${station.id}/votes`, ac ? { signal: ac.signal } : undefined);
if (state.player.stationId === station.id) {
state.player.votes = stats;
mergeStats(station.id, stats);
render();
}
} catch (err) { if (!isAbort(err)) { /* ignore */ } }
return;
}
player.play(station);
recordHistory(station.id);
if (broadcasts()) ws?.send({ type: 'command', action: 'play', stationId: station.id });
try {
const stats = await api.post(`/api/stations/${station.id}/play`, null, ac ? { signal: ac.signal } : undefined);
// Only apply if user hasn't switched stations in the meantime.
if (state.player.stationId === station.id) {
state.player.votes = stats;
// Remember the session so we can close it when the user stops or switches.
if (stats.sessionId) {
state.session = { id: stats.sessionId, stationId: station.id, startedAt: Date.now() };
}
mergeStats(station.id, stats);
render();
} else if (stats.sessionId) {
// Already moved on while the POST was in flight — close it immediately.
api.post(`/api/stations/${station.id}/play/end`, {
sessionId: stats.sessionId, duration_ms: 0
}).catch(() => { });
}
} catch (err) {
if (isAbort(err)) return;
try {
const stats = await api.get(`/api/stations/${station.id}/votes`, ac ? { signal: ac.signal } : undefined);
if (state.player.stationId === station.id) {
state.player.votes = stats;
mergeStats(station.id, stats);
render();
}
} catch { /* ignore */ }
}
}
// Close the currently-open play_history row, crediting the elapsed wall-clock
// time toward the station's total_play_ms. Safe to call multiple times.
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(() => { });
}
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => endCurrentSession({ beacon: true }));
window.addEventListener('beforeunload', () => endCurrentSession({ beacon: true }));
}
async function votePlayer(value) {
const id = state.player.stationId;
if (!id) return;
// Toggle off when clicking the already-active button.
const cur = state.player.votes?.myVote || 0;
const next = cur === value ? 0 : value;
try {
const stats = await api.post(`/api/stations/${id}/vote`, { value: next });
state.player.votes = stats;
mergeStats(id, stats);
render();
} catch (err) {
toast(err.message || 'Vote failed');
}
}
function mergeStats(stationId, stats) {
const list = [state.stations, state.favorites];
for (const arr of list) {
const hit = arr.find((s) => s.id === stationId);
if (hit) {
hit.up = stats.up; hit.down = stats.down;
hit.plays = stats.plays; hit.score = stats.score;
hit.my_vote = stats.myVote;
}
}
}
// ---- API endpoints context menu ----
let menuEl = null;
function closeContextMenu() {
if (menuEl) { menuEl.remove(); menuEl = null; }
}
function apiEndpoints(s) {
const origin = location.origin;
const base = `${origin}/api/v1`;
const items = [];
// Original (internal) endpoint — always available, keyed by station id.
if (s.id != null) {
items.push({ label: 'Station (original)', url: `${origin}/api/stations/${s.id}` });
}
// Public v1 endpoints — require uuid.
if (s.uuid) {
items.push(
{ label: 'Station detail', url: `${base}/stations/${s.uuid}` },
{ label: 'Stream redirect', url: `${base}/stations/${s.uuid}/stream` },
{ label: 'MP3 stream', url: `${base}/stations/${s.uuid}/stream?format=mp3` },
{ label: 'AAC stream', url: `${base}/stations/${s.uuid}/stream?format=aac` },
{ label: 'HLS stream', url: `${base}/stations/${s.uuid}/stream?format=hls` }
);
}
items.push(
{ label: 'All stations', url: `${base}/stations` },
{ label: 'Health', url: `${base}/health` }
);
return items;
}
function openContextMenu(x, y, station) {
closeContextMenu();
const items = apiEndpoints(station);
menuEl = el('div', { class: 'ctx-menu', role: 'menu' },
el('div', { class: 'ctx-title' }, station.name),
el('div', { class: 'ctx-sub' },
station.uuid ? `uuid · ${station.uuid}` : (station.id != null ? `id · ${station.id} (no uuid — public v1 hidden)` : 'no identifier')),
...(items.length ? items.map((it) => el('div', { class: 'ctx-row' },
el('div', { class: 'ctx-row-text' },
el('div', { class: 'ctx-label' }, it.label),
el('div', { class: 'ctx-url' }, it.url)
),
el('button', {
class: 'ctx-btn', title: 'Copy', onClick: async (e) => {
e.stopPropagation();
try { await navigator.clipboard.writeText(it.url); toast('Copied'); } catch { toast('Copy failed'); }
}
}, '⧉'),
el('button', {
class: 'ctx-btn', title: 'Open', onClick: (e) => {
e.stopPropagation();
window.open(it.url, '_blank', 'noopener');
}
}, '↗')
)) : [el('div', { class: 'ctx-empty' }, 'No public API for this station yet (missing uuid).')]),
state.user.role === 'admin' ? el('button', {
class: 'ctx-danger', onClick: async () => {
closeContextMenu();
if (!confirm(`Delete ${station.name}?`)) return;
try { await api.del(`/api/stations/${station.id}`); await refreshAll(); render(); toast('Deleted'); }
catch (e) { toast(e.message || 'Delete failed'); }
}
}, '🗑 Delete') : null
);
document.body.appendChild(menuEl);
// Position within viewport
const w = menuEl.offsetWidth, h = menuEl.offsetHeight;
const px = Math.min(x, window.innerWidth - w - 8);
const py = Math.min(y, window.innerHeight - h - 8);
menuEl.style.left = `${Math.max(8, px)}px`;
menuEl.style.top = `${Math.max(8, py)}px`;
}
document.addEventListener('click', (e) => {
if (menuEl && !menuEl.contains(e.target)) closeContextMenu();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeContextMenu(); });
// ---- Add station dialog (admin only) ----
async function openAddStation() {
const dlg = document.createElement('dialog');
dlg.className = 'add-station';
const data = { name: '', country: '', genres: '', image_url: '', homepage: '', streamUrl: '', streamFormat: 'mp3' };
const errBox = el('div', { class: 'err' });
dlg.appendChild(el('form', {
method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
errBox.textContent = '';
const payload = {
name: data.name.trim(),
country: data.country.trim() || null,
homepage: data.homepage.trim() || null,
image_url: data.image_url.trim() || null,
genres: data.genres.split(',').map((g) => g.trim()).filter(Boolean),
streams: data.streamUrl.trim() ? [{ url: data.streamUrl.trim(), format: data.streamFormat, priority: 0 }] : []
};
if (!payload.name) { errBox.textContent = 'Name is required.'; return; }
try {
await api.post('/api/stations', payload);
dlg.close();
await refreshAll();
render();
toast('Station added');
} catch (err) {
errBox.textContent = err.message || 'Failed to add station';
}
}
},
el('h2', {}, 'Add station'),
el('label', {}, 'Name', el('input', { required: true, autofocus: true, onInput: (e) => data.name = e.target.value })),
el('div', { class: 'row2' },
el('label', {}, 'Country', el('input', { maxlength: 4, placeholder: 'NL', onInput: (e) => data.country = e.target.value })),
el('label', {}, 'Genres', el('input', { placeholder: 'jazz, electronic', onInput: (e) => data.genres = e.target.value }))
),
el('label', {}, 'Homepage', el('input', { type: 'url', placeholder: 'https://…', onInput: (e) => data.homepage = e.target.value })),
el('label', {}, 'Image URL', el('input', { type: 'url', placeholder: 'https://…/logo.png', onInput: (e) => data.image_url = e.target.value })),
el('div', { class: 'row2' },
el('label', {}, 'Stream URL', el('input', { type: 'url', placeholder: 'https://…/stream', onInput: (e) => data.streamUrl = e.target.value })),
el('label', {}, 'Format',
el('select', { onChange: (e) => data.streamFormat = e.target.value },
...['mp3', 'aac', 'ogg', 'hls', 'm3u', 'pls', 'unknown'].map((f) =>
el('option', { value: f, selected: f === 'mp3' }, f))))
),
errBox,
el('div', { class: 'actions' },
el('button', { class: 'btn-ghost', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn-primary', type: 'submit' }, 'Add')
)
));
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
// ---- Toast ----
let toastTimer = null;
function toast(text) {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const t = el('div', { class: 'toast' }, text);
document.body.appendChild(t);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.remove(), 2200);
}
async function requestWakeLock() {
try { await navigator.wakeLock?.request('screen'); } catch { }
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') navigator.wakeLock?.request('screen').catch(() => { });
});
}
document.addEventListener('contextmenu', (e) => {
// Only suppress the menu in real kiosk mode (e.g. Chromium --kiosk),
// so devtools right-click stays available during normal use.
if (window.matchMedia('(display-mode: fullscreen)').matches) e.preventDefault();
});
bootstrap();