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

357 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

// Sync-status debug overlay.
//
// Gated by `?log=debug` in the URL. When active:
// - Sets `localStorage.oradio.debugSync = '1'` so `logSync()` in
// player.js starts emitting `[player:sync]` console traces.
// - Persists the flag in localStorage so reloads keep debug on for the
// session (clear it via `?log=off`).
// - Mounts a fixed-position overlay at bottom-right showing clock, audio,
// stream, and room state plus a tail of recent log lines.
//
// Skin is a few classes in web/style.css (.oradio-debug-*). No frameworks,
// no external state — the pane reads live values from the player, clock,
// and a tapped `console.log` each refresh.
const LS_KEY = 'oradio.log';
const TAIL_SIZE = 20;
const REFRESH_MS = 250;
// Injected once; each entry point (kiosk / master / admin) ships its own CSS
// bundle so it's cleaner to carry the overlay's styling here than to fork it
// into three stylesheets.
const STYLE_TEXT = `
.oradio-debug {
position: fixed; bottom: 12px; right: 12px; z-index: 99999;
width: 280px; max-height: 70vh; overflow: auto;
background: rgba(12, 14, 20, 0.92); color: #e2e8f0;
font: 11px/1.35 ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
border: 1px solid #2a3240; border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.oradio-debug-head {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px; background: #1a2030;
border-bottom: 1px solid #2a3240; border-radius: 6px 6px 0 0;
}
.oradio-debug-title {
font-weight: 600; color: #93c5fd;
text-transform: uppercase; letter-spacing: 0.04em;
}
.oradio-debug-collapse {
background: transparent; color: #cbd5e1;
border: 1px solid #334155; border-radius: 3px;
width: 20px; height: 20px; line-height: 18px;
cursor: pointer; font: inherit; padding: 0;
}
.oradio-debug-body { padding: 6px 10px 10px; }
.oradio-debug-section { margin-top: 8px; }
.oradio-debug-section:first-child { margin-top: 0; }
.oradio-debug-section-h {
color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;
font-size: 10px; padding: 4px 0 3px;
border-bottom: 1px solid #1f2937; margin-bottom: 4px;
}
.oradio-debug-grid {
display: grid; grid-template-columns: 90px 1fr; gap: 1px 8px;
}
.oradio-debug-k { color: #94a3b8; }
.oradio-debug-v {
color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.oradio-debug-v[data-status="in-sync"] { color: #4ade80; }
.oradio-debug-v[data-status="lagging"] { color: #fbbf24; }
.oradio-debug-v[data-status="no-buffer"],
.oradio-debug-v[data-status="no-anchor"] { color: #f87171; }
.oradio-debug-v[data-status="measuring"] { color: #60a5fa; }
.oradio-debug-tail {
max-height: 160px; overflow: auto;
background: #0c1018; border: 1px solid #1f2937;
border-radius: 3px; padding: 4px;
}
.oradio-debug-tail-row {
color: #cbd5e1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-size: 10px; padding: 1px 0;
}
`;
let _styleInjected = false;
function injectStyleOnce() {
if (_styleInjected || typeof document === 'undefined') return;
_styleInjected = true;
const style = document.createElement('style');
style.id = 'oradio-debug-style';
style.textContent = STYLE_TEXT;
document.head.appendChild(style);
}
/** Returns true if the debug overlay should be active. */
export function isDebugEnabled() {
try {
const sp = new URLSearchParams(location.search);
const q = sp.get('log');
if (q === 'debug') {
localStorage.setItem(LS_KEY, 'debug');
localStorage.setItem('oradio.debugSync', '1');
return true;
}
if (q === 'off' || q === '0') {
localStorage.removeItem(LS_KEY);
localStorage.removeItem('oradio.debugSync');
return false;
}
if (localStorage.getItem(LS_KEY) === 'debug') {
// Keep logSync wired up across reloads too.
localStorage.setItem('oradio.debugSync', '1');
return true;
}
} catch { /* private mode, no-op */ }
return false;
}
let _tail = [];
let _tailListeners = new Set();
let _consoleHooked = false;
function pushTail(line) {
_tail.push({ t: Date.now(), line });
if (_tail.length > TAIL_SIZE) _tail.shift();
for (const fn of _tailListeners) { try { fn(); } catch { /* ignore */ } }
}
function hookConsole() {
if (_consoleHooked || typeof console === 'undefined') return;
_consoleHooked = true;
const orig = console.log.bind(console);
console.log = (...args) => {
try {
const first = args[0];
if (typeof first === 'string'
&& (first.startsWith('[player:sync]') || first.startsWith('[clock]') || first.startsWith('[ws]'))) {
pushTail(args.map(formatArg).join(' '));
}
} catch { /* ignore */ }
orig(...args);
};
}
function formatArg(a) {
if (a == null) return String(a);
if (typeof a === 'string') return a;
if (typeof a === 'number') return Number.isInteger(a) ? String(a) : a.toFixed(3);
try { return JSON.stringify(a); } catch { return String(a); }
}
function fmtMs(v) {
if (v == null || !Number.isFinite(v)) return '—';
const abs = Math.abs(v);
if (abs < 1) return v.toFixed(2) + 'ms';
return Math.round(v) + 'ms';
}
function fmtSec(v) {
if (v == null || !Number.isFinite(v)) return '—';
return v.toFixed(3) + 's';
}
function fmtRate(v) {
if (v == null || !Number.isFinite(v)) return '—';
return v.toFixed(5) + '×';
}
function fmtTime(t) {
if (!t) return '—';
const d = new Date(t);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function section(title) {
const wrap = document.createElement('div');
wrap.className = 'oradio-debug-section';
const h = document.createElement('div');
h.className = 'oradio-debug-section-h';
h.textContent = title;
wrap.appendChild(h);
const grid = document.createElement('div');
grid.className = 'oradio-debug-grid';
wrap.appendChild(grid);
return { wrap, grid };
}
function row(grid, label) {
const k = document.createElement('div');
k.className = 'oradio-debug-k';
k.textContent = label;
const v = document.createElement('div');
v.className = 'oradio-debug-v';
v.textContent = '—';
grid.appendChild(k);
grid.appendChild(v);
return v;
}
/**
* Mount the debug overlay. Safe to call without `player` (admin pages):
* pass `null` for the player and the Audio/Stream sections collapse to "no
* player on this page". Returns an `unmount()` function.
*/
export function mountDebugPane({ player, clock, ws, getWs, role }) {
if (!isDebugEnabled() || typeof document === 'undefined') return () => { };
hookConsole();
injectStyleOnce();
const root = document.createElement('div');
root.className = 'oradio-debug';
root.setAttribute('role', 'complementary');
root.setAttribute('aria-label', 'sync debug');
const head = document.createElement('div');
head.className = 'oradio-debug-head';
const title = document.createElement('span');
title.className = 'oradio-debug-title';
title.textContent = `sync · ${role || '?'}`;
head.appendChild(title);
const collapseBtn = document.createElement('button');
collapseBtn.className = 'oradio-debug-collapse';
collapseBtn.textContent = '';
collapseBtn.title = 'Collapse';
head.appendChild(collapseBtn);
root.appendChild(head);
const body = document.createElement('div');
body.className = 'oradio-debug-body';
root.appendChild(body);
let collapsed = false;
collapseBtn.addEventListener('click', () => {
collapsed = !collapsed;
body.style.display = collapsed ? 'none' : '';
collapseBtn.textContent = collapsed ? '+' : '';
});
// ----- Clock -----
const clk = section('Clock');
const clk_offset = row(clk.grid, 'offset');
const clk_rtt = row(clk.grid, 'rtt');
const clk_std = row(clk.grid, 'std');
const clk_samples = row(clk.grid, 'samples');
const clk_stable = row(clk.grid, 'stable');
body.appendChild(clk.wrap);
// ----- Audio -----
const aud = section('Audio');
const aud_status = row(aud.grid, 'status');
const aud_source = row(aud.grid, 'anchor');
const aud_drift = row(aud.grid, 'drift');
const aud_rate = row(aud.grid, 'rate');
const aud_ct = row(aud.grid, 'currentTime');
const aud_delay = row(aud.grid, 'delay');
const aud_buffer = row(aud.grid, 'bufferMs');
const aud_paused = row(aud.grid, 'paused');
body.appendChild(aud.wrap);
// ----- Stream -----
const str = section('Stream');
const str_kind = row(str.grid, 'kind');
const str_station = row(str.grid, 'station');
const str_pdt = row(str.grid, 'PDT');
body.appendChild(str.wrap);
// ----- Room -----
const rm = section('Room');
const rm_role = row(rm.grid, 'role');
const rm_ws = row(rm.grid, 'ws');
const rm_started = row(rm.grid, 'started_at');
const rm_master = row(rm.grid, 'last master-pos');
body.appendChild(rm.wrap);
rm_role.textContent = role || '—';
// ----- Tail -----
const tail = document.createElement('div');
tail.className = 'oradio-debug-section';
const tailH = document.createElement('div');
tailH.className = 'oradio-debug-section-h';
tailH.textContent = 'Log';
tail.appendChild(tailH);
const tailList = document.createElement('div');
tailList.className = 'oradio-debug-tail';
tail.appendChild(tailList);
body.appendChild(tail);
function renderTail() {
tailList.textContent = '';
for (const e of _tail.slice().reverse()) {
const div = document.createElement('div');
div.className = 'oradio-debug-tail-row';
div.textContent = `${fmtTime(e.t)} ${e.line}`;
tailList.appendChild(div);
}
}
_tailListeners.add(renderTail);
function refresh() {
// Clock
if (clock) {
clk_offset.textContent = fmtMs(clock.offset);
clk_rtt.textContent = Number.isFinite(clock.rtt) ? fmtMs(clock.rtt) : '—';
clk_std.textContent = Number.isFinite(clock.offsetStd) ? fmtMs(clock.offsetStd) : '—';
clk_samples.textContent = String(clock.samples?.length ?? 0);
clk_stable.textContent = clock.isStable?.() ? 'yes' : 'no';
}
// Audio
if (player) {
const s = player.sync;
aud_status.textContent = s.status || '—';
aud_status.dataset.status = s.status || '';
aud_source.textContent = s.anchorSource || '—';
aud_drift.textContent = fmtMs(s.driftMs);
aud_rate.textContent = fmtRate(s.rate);
aud_ct.textContent = fmtSec(player.audio?.currentTime);
aud_delay.textContent = fmtSec(s.currentDelay);
aud_buffer.textContent = fmtMs(s.bufferMs);
aud_paused.textContent = player.audio?.paused ? 'yes' : 'no';
const st = player.station;
str_station.textContent = st ? `${st.id} ${st.name || ''}` : '—';
str_kind.textContent = player.hls ? 'hls' : (st ? 'direct' : '—');
str_pdt.textContent = s.pdtMs ? fmtTime(s.pdtMs) : '—';
rm_started.textContent = s.startedAt ? fmtTime(s.startedAt) : '—';
// masterAt is in server time. Age == server-now masterAt.
if (s.masterAt && clock) {
const ageMs = (Date.now() + clock.offset) - s.masterAt;
rm_master.textContent = ageMs >= 0
? (ageMs < 1000 ? ageMs + 'ms ago' : (ageMs / 1000).toFixed(1) + 's ago')
: '—';
} else {
rm_master.textContent = '—';
}
} else {
aud_status.textContent = 'no-player';
aud_source.textContent = '—';
aud_drift.textContent = '—';
aud_rate.textContent = '—';
aud_ct.textContent = '—';
aud_delay.textContent = '—';
aud_buffer.textContent = '—';
aud_paused.textContent = '—';
str_kind.textContent = '—';
str_station.textContent = '—';
str_pdt.textContent = '—';
rm_started.textContent = '—';
rm_master.textContent = '—';
}
// WS — accept a static handle or a getter so re-openings stay visible.
const wsNow = typeof getWs === 'function' ? getWs() : ws;
const rs = wsNow?.readyState;
rm_ws.textContent = rs === 1 ? 'open' : rs === 0 ? 'connecting' : rs === 2 ? 'closing' : rs === 3 ? 'closed' : '—';
}
document.body.appendChild(root);
refresh();
renderTail();
const refreshId = setInterval(refresh, REFRESH_MS);
return function unmount() {
clearInterval(refreshId);
_tailListeners.delete(renderTail);
try { root.remove(); } catch { /* ignore */ }
};
}