1571 lines
70 KiB
JavaScript
1571 lines
70 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;
|
|
}
|
|
// Persist the last station along with whether it was playing at the time.
|
|
// On reload we only auto-resume audio when `playing` was true — a user who
|
|
// hit Stop explicitly should not be ambushed by the stream restarting.
|
|
function writeLastStation(station, { playing = true } = {}) {
|
|
if (!station || !Number.isFinite(station.id)) return;
|
|
try {
|
|
localStorage.setItem('oradio.lastStation', JSON.stringify({
|
|
id: station.id, name: station.name, genres: station.genres || [],
|
|
playing: !!playing
|
|
}));
|
|
} 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);
|
|
}
|
|
|
|
// Single entry point for transport commands (play/pause/stop/etc). Returns
|
|
// false when the WS isn't OPEN — callers use the return value to roll back
|
|
// optimistic state so a transient disconnect is visible to the user instead
|
|
// of silently dropping the click.
|
|
function sendCommand(payload) {
|
|
const ok = !!ws?.send({ type: 'command', ...payload });
|
|
if (!ok) {
|
|
// Roll back any optimistic UI flip — the command never reached the
|
|
// server, so the room state we'd be predicting will not arrive.
|
|
clearPendingCommand();
|
|
state.player = { ...state.player, error: 'Disconnected — reconnecting…' };
|
|
render();
|
|
}
|
|
return ok;
|
|
}
|
|
// 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. Only auto-resume audio
|
|
// when the last-known state was `playing: true` — a user who hit Stop
|
|
// and reloaded should see the station selected but silent. If the
|
|
// playing flag is missing (older localStorage entries) we treat it as
|
|
// true for backwards compatibility on first reload after upgrading.
|
|
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) {
|
|
const wasPlaying = last.playing !== false;
|
|
if (wasPlaying) {
|
|
await soloResumeStation(full);
|
|
} else {
|
|
// Adopt metadata so the card shows the station as
|
|
// selected, but do not start audio.
|
|
state.player = {
|
|
...state.player,
|
|
stationId: full.id,
|
|
stationName: full.name,
|
|
genres: full.genres || [],
|
|
playing: false,
|
|
loading: false,
|
|
error: null
|
|
};
|
|
render();
|
|
}
|
|
}
|
|
} 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);
|
|
// The server emits a synthetic `state` immediately after handling
|
|
// any command (with `started_at: null` while a display is present)
|
|
// BEFORE the master has actually anchored on the new station. Only
|
|
// clear the optimistic pending command when this is a truly
|
|
// authoritative echo: the master has anchored (`started_at` set)
|
|
// OR the room moved to stopped/paused. Otherwise the kiosk would
|
|
// briefly snap back to a stale "loading" view, then snap forward
|
|
// again 100-500 ms later when the master actually starts.
|
|
if (msg.started_at || msg.playing === false || !msg.station_id) {
|
|
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;
|
|
if (!target) return;
|
|
setPendingCommand({ stationId: target, playing: !view.playing });
|
|
sendCommand({ 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) {
|
|
// Synced+audio: send command first; only flip local
|
|
// audio after the WS send actually succeeded. The
|
|
// canonical `state` echo will mirror back to the
|
|
// local <audio> via the state handler so both ends
|
|
// stay in lockstep.
|
|
setPendingCommand({ stationId: view.stationId, playing: !wasPlaying });
|
|
const ok = sendCommand({ action: wasPlaying ? 'pause' : 'play', stationId: view.stationId });
|
|
if (!ok) return;
|
|
player.togglePause();
|
|
} else {
|
|
// Solo: local only.
|
|
player.togglePause();
|
|
// Persist new playing state so reload honours it.
|
|
const last = readLastStation();
|
|
if (last && last.id === view.stationId) {
|
|
writeLastStation({ id: last.id, name: last.name, genres: last.genres }, { playing: !wasPlaying });
|
|
}
|
|
}
|
|
} 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 });
|
|
sendCommand({ action: 'stop' });
|
|
render();
|
|
return;
|
|
}
|
|
if (state.mode === 'synced' && state.syncedAudio) {
|
|
setPendingCommand({ stationId: null, playing: false });
|
|
const ok = sendCommand({ action: 'stop' });
|
|
if (!ok) return;
|
|
player.stop();
|
|
endCurrentSession();
|
|
return;
|
|
}
|
|
// Solo: local only. Mark last-station as not playing so
|
|
// the next reload doesn't surprise the user with audio.
|
|
const stopping = view.stationId;
|
|
player.stop();
|
|
endCurrentSession();
|
|
const last = readLastStation();
|
|
if (last && last.id === stopping) {
|
|
writeLastStation({ id: last.id, name: last.name, genres: last.genres }, { playing: false });
|
|
}
|
|
}
|
|
}, '■'),
|
|
// 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, { playing: true });
|
|
if (state.mode === 'synced') {
|
|
// Synced mode (audio on OR off): the room is the source of truth.
|
|
// Send the command, optimistically flip the card, and let the
|
|
// resulting `state` echo + autoJoinRoomPlayback drive any local
|
|
// audio start (when syncedAudio is on). Single code path = no
|
|
// race between the optimistic local player.play and the join
|
|
// triggered by the state echo.
|
|
setPendingCommand({ stationId: station.id, playing: true });
|
|
const ok = sendCommand({ action: 'play', stationId: station.id });
|
|
if (!ok) return;
|
|
// Optimistically flip the card to the new station name immediately.
|
|
// Do NOT set loading=true here — joinStationSilently uses
|
|
// (loading && stationId===id) as its re-entry guard, and we want
|
|
// the state-echo-driven autoJoinRoomPlayback to actually start the
|
|
// local <audio>. The "On air — local audio loading…" sub-line
|
|
// covers the brief window where view.playing is true but the local
|
|
// audio hasn't begun yet.
|
|
state.player = {
|
|
...state.player,
|
|
stationId: station.id,
|
|
stationName: station.name,
|
|
genres: station.genres || [],
|
|
playing: true,
|
|
loading: false,
|
|
error: null
|
|
};
|
|
render();
|
|
// No local audio = no local session — the master records its own.
|
|
// When syncedAudio is on the local session will be opened by
|
|
// joinStationSilently once autoJoin kicks in.
|
|
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;
|
|
}
|
|
|
|
// Solo path: drive local audio directly.
|
|
player.play(station);
|
|
recordHistory(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();
|