// 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 */ } }; }