diff --git a/frontend/js/app.js b/frontend/js/app.js
index 22c5820..237ea1f 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -16,6 +16,7 @@ import * as onboarding from './views/onboarding.js';
import * as help from './views/help.js';
import * as teams from './views/teams.js';
import * as admin from './views/admin.js';
+import * as adminPlayerDebug from './views/admin-player-debug.js';
import * as designer from './views/designer.js';
import * as playlists from './views/playlists.js';
import { applyBranding } from './branding.js';
@@ -213,6 +214,10 @@ function route() {
} else if (hash === '#/help' || hash.startsWith('#/help')) {
currentView = help;
help.render(app);
+ } else if (hash.startsWith('#/admin/player-debug')) {
+ // Match prefix so query params (?page=2&ua=Tizen) route correctly.
+ currentView = adminPlayerDebug;
+ adminPlayerDebug.render(app);
} else if (hash === '#/admin') {
currentView = admin;
admin.render(app);
diff --git a/frontend/js/views/admin-player-debug.js b/frontend/js/views/admin-player-debug.js
new file mode 100644
index 0000000..202e5cc
--- /dev/null
+++ b/frontend/js/views/admin-player-debug.js
@@ -0,0 +1,336 @@
+// Admin view for the player_debug_logs telemetry sink. Platform-admin only.
+// Mounted at #/admin/player-debug. Reads from /api/player-debug/list,
+// /api/player-debug/summary, /api/player-debug/older-than (DELETE).
+//
+// Server-side pagination - we never render all 10k rows at once. Page param
+// in the URL hash so refresh preserves position.
+//
+// IMPORTANT: device_id is whatever the player POSTed. The submitter is
+// unauthenticated by design (so unpaired players can also send), which means
+// device_id is self-reported, NOT server-verified. Surfaced via column label
+// "device_id (self-reported)" and the help-text caption below the filters.
+
+import { isPlatformAdmin } from '../utils.js';
+import { showToast } from '../components/toast.js';
+
+const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
+const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts });
+
+// Parse a query string from a hash like '#/admin/player-debug?page=2&ua=Tizen'.
+// Returns a plain object - no URLSearchParams since the hash format isn't
+// a standard URL.
+function parseHashParams() {
+ const h = window.location.hash || '';
+ const qi = h.indexOf('?');
+ if (qi < 0) return {};
+ const out = {};
+ const qs = h.substring(qi + 1);
+ for (const part of qs.split('&')) {
+ if (!part) continue;
+ const eq = part.indexOf('=');
+ const k = eq >= 0 ? part.substring(0, eq) : part;
+ const v = eq >= 0 ? part.substring(eq + 1) : '';
+ try { out[decodeURIComponent(k)] = decodeURIComponent(v); } catch { out[k] = v; }
+ }
+ return out;
+}
+
+function setHashParams(updates) {
+ const base = '#/admin/player-debug';
+ const merged = { ...parseHashParams(), ...updates };
+ // Strip empty values so the URL stays tidy
+ const pairs = [];
+ for (const [k, v] of Object.entries(merged)) {
+ if (v == null || v === '') continue;
+ pairs.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
+ }
+ // Replace, don't push - we don't want every filter keystroke in browser history
+ history.replaceState(null, '', pairs.length ? base + '?' + pairs.join('&') : base);
+}
+
+// Pretty-print JSON for the expanded-row display. Returns the original string
+// if parsing fails so we don't lose data when the field isn't JSON-shaped.
+function prettyJson(s) {
+ if (s == null || s === '') return '(empty)';
+ try {
+ return JSON.stringify(JSON.parse(s), null, 2);
+ } catch {
+ return String(s);
+ }
+}
+
+function esc(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
+
+function fmtTime(unixSec) {
+ if (!unixSec) return '';
+ try { return new Date(unixSec * 1000).toLocaleString(); } catch { return String(unixSec); }
+}
+
+function uaShort(ua) {
+ if (!ua) return '';
+ // Keep just the part most useful for at-a-glance scanning. Full UA in the
+ // expanded row.
+ return ua.length > 60 ? ua.substring(0, 60) + '...' : ua;
+}
+
+export async function render(container) {
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
+ if (!isPlatformAdmin(user)) {
+ container.innerHTML = '
Access denied Platform-admin role required.
';
+ return;
+ }
+
+ const params = parseHashParams();
+ const currentPage = parseInt(params.page) || 1;
+ const currentUa = params.ua || '';
+ const currentSince = params.since || '';
+ const currentUntil = params.until || '';
+ const currentHasError = params.has_error === '1';
+
+ container.innerHTML = `
+
+
+
+
+
+
Filters
+
+
+ User agent contains
+
+
+
+ Since (YYYY-MM-DD)
+
+
+
+ Until (YYYY-MM-DD)
+
+
+
+
+ Has error data
+
+
+
Apply
+
Clear
+
+
Delete older than 30 days
+
+
+ Note: device_id is self-reported by the player and is not server-verified. The submission endpoint is unauthenticated by design so unpaired players can also report errors.
+
+
+
+
+ `;
+
+ // ---- handlers ----
+ document.getElementById('pdApplyFilters').onclick = () => {
+ const ua = document.getElementById('pdFilterUa').value.trim();
+ const since = document.getElementById('pdFilterSince').value.trim();
+ const until = document.getElementById('pdFilterUntil').value.trim();
+ const hasError = document.getElementById('pdFilterHasError').checked ? '1' : '';
+ setHashParams({ page: 1, ua, since, until, has_error: hasError });
+ loadList();
+ };
+ document.getElementById('pdClearFilters').onclick = () => {
+ document.getElementById('pdFilterUa').value = '';
+ document.getElementById('pdFilterSince').value = '';
+ document.getElementById('pdFilterUntil').value = '';
+ document.getElementById('pdFilterHasError').checked = false;
+ setHashParams({ page: 1, ua: '', since: '', until: '', has_error: '' });
+ loadList();
+ };
+ document.getElementById('pdDeleteOld').onclick = async () => {
+ if (!confirm('Delete all logs older than 30 days? This cannot be undone.')) return;
+ try {
+ const res = await API('/player-debug/older-than?days=30', { method: 'DELETE' });
+ const data = await res.json();
+ showToast(`Deleted ${data.deleted} log${data.deleted === 1 ? '' : 's'} older than 30 days`, 'success');
+ loadSummary();
+ loadList();
+ } catch (err) {
+ showToast('Delete failed: ' + (err.message || err), 'error');
+ }
+ };
+
+ loadSummary();
+ loadList();
+}
+
+async function loadSummary() {
+ const el = document.getElementById('pdSummary');
+ if (!el) return;
+ try {
+ const res = await API('/player-debug/summary');
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ const data = await res.json();
+ const families = [
+ ['Tizen', data.byFamily.tizen, '#3b82f6'],
+ ['WebOS', data.byFamily.webos, '#a3e635'],
+ ['Fire TV', data.byFamily.fire_tv, '#f97316'],
+ ['Bravia', data.byFamily.bravia, '#a855f7'],
+ ['Edge', data.byFamily.edge, '#06b6d4'],
+ ['Chrome', data.byFamily.chrome, '#fbbf24'],
+ ['Firefox', data.byFamily.firefox, '#ef4444'],
+ ['Safari', data.byFamily.safari, '#64748b'],
+ ['Other', data.byFamily.other, '#94a3b8'],
+ ];
+ el.innerHTML = `
+ Total: ${data.total}
+ ${families.map(([name, count, color]) => `
+
+
+ ${name}: ${count}
+
+ `).join('')}
+ `;
+ } catch (err) {
+ el.innerHTML = 'Failed to load summary: ' + esc(err.message || err) + ' ';
+ }
+}
+
+function ymdToUnix(s, endOfDay) {
+ if (!s) return '';
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
+ if (!m) return '';
+ const [, y, mo, d] = m;
+ const dt = new Date(Date.UTC(+y, +mo - 1, +d, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0));
+ return Math.floor(dt.getTime() / 1000);
+}
+
+async function loadList() {
+ const el = document.getElementById('pdList');
+ const meta = document.getElementById('pdRowMeta');
+ const pag = document.getElementById('pdPagination');
+ if (!el) return;
+ el.innerHTML = 'Loading...
';
+
+ const params = parseHashParams();
+ const page = Math.max(1, parseInt(params.page) || 1);
+ const limit = 50;
+ const qs = new URLSearchParams();
+ qs.set('page', page);
+ qs.set('limit', limit);
+ if (params.ua) qs.set('ua_contains', params.ua);
+ const since = ymdToUnix(params.since, false);
+ const until = ymdToUnix(params.until, true);
+ if (since) qs.set('since', since);
+ if (until) qs.set('until', until);
+ if (params.has_error === '1') qs.set('has_error', '1');
+
+ try {
+ const res = await API('/player-debug/list?' + qs.toString());
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ const data = await res.json();
+ const totalPages = Math.max(1, Math.ceil(data.total / data.limit));
+ meta.textContent = `(${data.total} total, page ${data.page} of ${totalPages})`;
+
+ if (data.rows.length === 0) {
+ el.innerHTML = 'No logs match the current filters.
';
+ } else {
+ el.innerHTML = `
+
+
+
+ ID
+ Time
+ device_id (self-reported)
+ IP
+ User agent
+ Fingerprint
+
+
+
+ ${data.rows.map(r => `
+
+ ${r.id}
+ ${esc(fmtTime(r.created_at))}
+ ${esc(r.device_id || '(none)')}
+ ${esc(r.ip || '')}
+ ${esc(uaShort(r.user_agent))}
+ ${esc(r.error_fingerprint || '')}
+
+ Expand
+
+
+
+
+
+
+
URL
+
${esc(r.url || '(none)')}
+
Full User Agent
+
${esc(r.user_agent || '(none)')}
+
error_data
+
${esc(prettyJson(r.error_data))}
+
+
+
context
+
${esc(prettyJson(r.context))}
+
+
+
+
+ `).join('')}
+
+
+
+ `;
+
+ el.querySelectorAll('button[data-expand]').forEach(btn => {
+ btn.onclick = () => {
+ const id = btn.getAttribute('data-expand');
+ const exp = el.querySelector(`tr[data-expanded-for="${id}"]`);
+ if (exp) {
+ const visible = exp.style.display !== 'none';
+ exp.style.display = visible ? 'none' : '';
+ btn.textContent = visible ? 'Expand' : 'Collapse';
+ }
+ };
+ });
+ }
+
+ // ---- pagination ----
+ pag.innerHTML = '';
+ if (totalPages > 1) {
+ const prev = document.createElement('button');
+ prev.className = 'btn btn-secondary btn-sm';
+ prev.textContent = '< Prev';
+ prev.disabled = page <= 1;
+ prev.onclick = () => { setHashParams({ page: page - 1 }); loadList(); };
+ pag.appendChild(prev);
+
+ const indicator = document.createElement('span');
+ indicator.style.cssText = 'padding:0 12px;font-size:13px;color:var(--text-muted)';
+ indicator.textContent = `Page ${page} of ${totalPages}`;
+ pag.appendChild(indicator);
+
+ const next = document.createElement('button');
+ next.className = 'btn btn-secondary btn-sm';
+ next.textContent = 'Next >';
+ next.disabled = page >= totalPages;
+ next.onclick = () => { setHashParams({ page: page + 1 }); loadList(); };
+ pag.appendChild(next);
+ }
+ } catch (err) {
+ el.innerHTML = 'Failed to load: ' + esc(err.message || err) + '
';
+ }
+}
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 01cf146..4575128 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -436,6 +436,33 @@ CREATE TABLE IF NOT EXISTS device_status_log (
timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
+-- ===================== PLAYER DEBUG LOGS =====================
+-- Smart TVs (Tizen, WebOS, Fire TV, etc.) have no accessible devtools. The
+-- player captures errors into window.__debugLog client-side and POSTs them
+-- to /api/player-debug. This table stores those reports. Submitter is
+-- unauthenticated by design - the player may not have paired yet when an
+-- error fires. device_id is nullable for unpaired players.
+--
+-- Capped at 10,000 rows with FIFO eviction on insert (route-side, no sweep).
+-- error_fingerprint is a client-computed hash of (error message + first stack
+-- frame) - indexed so a future "top N unique errors this week" query is fast
+-- without a schema change.
+
+CREATE TABLE IF NOT EXISTS player_debug_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ device_id TEXT,
+ ip TEXT,
+ user_agent TEXT,
+ url TEXT,
+ error_fingerprint TEXT,
+ error_data TEXT,
+ context TEXT,
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_player_debug_fingerprint ON player_debug_logs(error_fingerprint);
+CREATE INDEX IF NOT EXISTS idx_player_debug_created_at ON player_debug_logs(created_at);
+
-- ===================== SCHEMA MIGRATIONS =====================
CREATE TABLE IF NOT EXISTS schema_migrations (
diff --git a/server/player/debug-overlay.js b/server/player/debug-overlay.js
new file mode 100644
index 0000000..07f8c98
--- /dev/null
+++ b/server/player/debug-overlay.js
@@ -0,0 +1,564 @@
+// Player debug overlay. Renders a fixed top-40% overlay on the screen so a
+// user on a TV (no devtools) can take a phone photo and send it to support.
+//
+// Activation:
+// - ?debug=1 in the URL (always shows on load)
+// - Keyboard sequence d-e-b-u-g typed within 5 seconds (toggle)
+// - Samsung remote red button, keyCode 403 (toggle)
+// - smart-TV UA (/Tizen|SMART-TV|WebOS|AFTS|AFTT|AFTM|BRAVIA/i) +
+// ?autodebug=1 (auto - lets us flip on via env config later)
+//
+// Freeze (so the overlay stops scrolling while a phone photo is taken):
+// - 'f' key (toggle)
+// - Samsung remote green button, keyCode 404 (toggle)
+// - Obvious "[F] FROZEN" yellow badge top-right when active
+//
+// ES5 syntax throughout to match section 1's compatibility floor. No
+// template literals, no arrow functions, no const/let, no destructuring.
+// Defensive try/catch wraps everything: this script must never be the
+// reason the player won't boot.
+
+(function () {
+ try {
+ var SMART_TV_RE = /Tizen|SMART-TV|WebOS|AFTS|AFTT|AFTM|BRAVIA/i;
+ var KEY_LOG_MAX = 10;
+ var LOG_VISIBLE = 30;
+ var REFRESH_MS = 500;
+ var SEQ_TARGET = 'debug';
+ var SEQ_TTL_MS = 5000;
+
+ var active = false;
+ var frozen = false;
+ var overlay = null;
+ var refreshTimer = null;
+ var keydownLog = []; // newest first; [{keyCode, key, t}, ...]
+ var seq = '';
+ var seqResetTimer = null;
+ var bootT = nowMs();
+
+ function nowMs() {
+ try { return Date.now(); } catch (e) { return new Date().getTime(); }
+ }
+
+ // Manual URL param parse - URLSearchParams may not exist on Tizen 4 era WebKit.
+ function getParam(name) {
+ var qs = (location && location.search) || '';
+ if (qs.charAt(0) === '?') qs = qs.substring(1);
+ var parts = qs.split('&');
+ for (var i = 0; i < parts.length; i++) {
+ var eq = parts[i].indexOf('=');
+ var k = eq >= 0 ? parts[i].substring(0, eq) : parts[i];
+ var v = eq >= 0 ? parts[i].substring(eq + 1) : '';
+ try { k = decodeURIComponent(k); } catch (e) {}
+ try { v = decodeURIComponent(v); } catch (e) {}
+ if (k === name) return v;
+ }
+ return null;
+ }
+
+ function shouldAutoActivate() {
+ if (getParam('debug') !== null) return true;
+ if (getParam('autodebug') !== null && SMART_TV_RE.test((navigator && navigator.userAgent) || '')) return true;
+ return false;
+ }
+
+ function esc(s) {
+ s = String(s == null ? '' : s);
+ return s.replace(/&/g, '&').replace(//g, '>');
+ }
+
+ // Pad-right helper. Keeps log lines column-aligned even on monospace
+ // fallback fonts. Using "[type ] message" feels noisy at TV
+ // viewing distance but the alignment makes scanning easier in a photo.
+ function padRight(s, n) {
+ s = String(s);
+ while (s.length < n) s += ' ';
+ return s;
+ }
+
+ // Read connection state. The player's main inline script declares
+ // `socket` with `let` at script-top-level. By spec, classic-script
+ // top-level let/const declarations live in the document's global
+ // declarative environment record - accessible by bare name from other
+ // classic scripts (NOT on `window`). This should work since our overlay
+ // script is also classic and runs after the player's inline script via
+ // `defer`. But: verify on a real player in browser (load with ?debug=1,
+ // confirm the Conn line transitions unknown -> connected after the
+ // socket establishes). If it stays "unknown" the fallback fix is one
+ // line in the player's main script: `window.__playerState = { socket: socket }`
+ // after `socket = io(...)`, and we read window.__playerState.socket here.
+ function connectionState() {
+ try {
+ if (typeof socket !== 'undefined' && socket && typeof socket.connected === 'boolean') {
+ return socket.connected ? 'connected' : 'disconnected';
+ }
+ } catch (e) {}
+ // Belt-and-suspenders fallback if the cross-script bare reference
+ // doesn't resolve on a given browser.
+ try {
+ if (window.__playerState && window.__playerState.socket && typeof window.__playerState.socket.connected === 'boolean') {
+ return window.__playerState.socket.connected ? 'connected' : 'disconnected';
+ }
+ } catch (e) {}
+ return 'unknown';
+ }
+
+ function buildHtml() {
+ var ua = (navigator && navigator.userAgent) || '';
+ var w = (window.innerWidth || 0) + 'x' + (window.innerHeight || 0);
+ var sd = ((screen && screen.width) || 0) + 'x' + ((screen && screen.height) || 0);
+ var uptime = Math.floor((nowMs() - bootT) / 1000) + 's';
+ var conn = connectionState();
+
+ var topHtml = ''
+ + ''
+ + '
UA: ' + esc(ua.substring(0, 140)) + '
'
+ + '
Screen: ' + esc(sd) + ' Viewport: ' + esc(w) + ' Conn: ' + esc(conn) + ' Uptime: ' + esc(uptime) + '
'
+ + '
URL: ' + esc((location.href || '').substring(0, 220)) + '
'
+ + '
';
+
+ var log = (window.__debugLog && window.__debugLog.length) ? window.__debugLog : [];
+ var start = Math.max(0, log.length - LOG_VISIBLE);
+ var slice = log.slice(start).reverse(); // newest at top
+
+ var middleHtml = '';
+ for (var i = 0; i < slice.length; i++) {
+ var e = slice[i] || {};
+ var color = '#fff';
+ var t = e.type || '?';
+ if (t === 'error' || t === 'rejection' || t === 'console.error') color = '#f87171';
+ else if (t === 'console.warn') color = '#fbbf24';
+ else if (t === 'init' || t === 'timing') color = '#a3e635';
+ var msg = (e.message || '');
+ if (t === 'error' && e.source) msg = msg + ' @ ' + e.source + ':' + e.line;
+ if (t === 'timing') msg = e.event + ' +' + e.sinceInit + 'ms';
+ if (t === 'init') msg = 'ua=' + (e.ua || '').substring(0, 60) + ' screen=' + (e.sw || '?') + 'x' + (e.sh || '?');
+ var line = '[' + padRight(t, 14) + '] ' + esc(String(msg).substring(0, 240));
+ middleHtml += '
' + line + '
';
+ }
+ if (slice.length === 0) middleHtml += '
(no entries yet)
';
+ middleHtml += '
';
+
+ var keysHtml = '';
+ keysHtml += '
KEYS (last ' + keydownLog.length + ', newest first):
';
+ for (var j = 0; j < keydownLog.length; j++) {
+ var k = keydownLog[j];
+ keysHtml += '
[' + esc(k.keyCode) + '] ' + esc(k.key || '(no key field)') + '
';
+ }
+ if (keydownLog.length === 0) keysHtml += '
(press any key to start logging)
';
+ keysHtml += '
';
+
+ var freezeBadge = frozen
+ ? '[F] FROZEN
'
+ : 'press F to freeze
';
+
+ var helpLabel = 'Debug Overlay - take a photo and send to support
';
+
+ return topHtml + middleHtml + keysHtml + freezeBadge + helpLabel;
+ }
+
+ function render() {
+ if (!overlay || frozen) return;
+ try { overlay.innerHTML = buildHtml(); } catch (e) {}
+ }
+
+ // Force a render bypassing the frozen gate. Used right after freeze
+ // toggles so the [F] FROZEN badge actually updates on the screen.
+ function forceRender() {
+ if (!overlay) return;
+ try { overlay.innerHTML = buildHtml(); } catch (e) {}
+ }
+
+ function activate() {
+ if (active) return;
+ active = true;
+ try {
+ overlay = document.createElement('div');
+ overlay.id = '__player-debug-overlay';
+ overlay.style.cssText = ''
+ + 'position:fixed;top:0;left:0;width:100%;height:40%;'
+ + 'background:rgba(0,0,0,0.85);color:#fff;'
+ + 'font-family:Menlo,Consolas,Courier,monospace,monospace;'
+ + 'font-size:18px;line-height:1.3;'
+ + 'padding:12px 16px;box-sizing:border-box;'
+ + 'z-index:2147483647;overflow:hidden;'
+ + 'pointer-events:none;word-break:break-all;'
+ + 'text-shadow:0 0 1px #000';
+ document.body.appendChild(overlay);
+ forceRender();
+ refreshTimer = setInterval(render, REFRESH_MS);
+ } catch (e) {}
+ }
+
+ function deactivate() {
+ if (!active) return;
+ active = false;
+ frozen = false;
+ try {
+ if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
+ } catch (e) {}
+ overlay = null;
+ if (refreshTimer) { try { clearInterval(refreshTimer); } catch (e) {} refreshTimer = null; }
+ }
+
+ function toggle() { active ? deactivate() : activate(); }
+
+ function toggleFreeze() {
+ if (!active) return;
+ frozen = !frozen;
+ forceRender(); // bypass frozen gate so the badge updates immediately
+ }
+
+ function handleKeydown(ev) {
+ try {
+ var kc = ev.keyCode || ev.which || 0;
+ var key = (ev.key || '');
+
+ // Capture into keydown log (newest first)
+ keydownLog.unshift({ keyCode: kc, key: key, t: nowMs() });
+ if (keydownLog.length > KEY_LOG_MAX) keydownLog.length = KEY_LOG_MAX;
+
+ // 'debug' sequence detection. Only ASCII letters contribute to the
+ // sequence; arrow keys, space, etc. don't reset progress but don't
+ // advance it either.
+ var ch = (key + '').toLowerCase();
+ if (ch.length === 1 && ch >= 'a' && ch <= 'z') {
+ seq += ch;
+ if (seq.length > SEQ_TARGET.length) seq = seq.substring(seq.length - SEQ_TARGET.length);
+ if (seq === SEQ_TARGET) {
+ toggle();
+ seq = '';
+ }
+ if (seqResetTimer) clearTimeout(seqResetTimer);
+ seqResetTimer = setTimeout(function () { seq = ''; }, SEQ_TTL_MS);
+ }
+
+ // Samsung remote red button (keyCode 403) - toggle overlay
+ if (kc === 403) toggle();
+
+ // 'f' key (only when overlay active) or Samsung remote green button
+ // (keyCode 404) - toggle freeze
+ if (active && (ch === 'f' || kc === 404)) toggleFreeze();
+ } catch (e) {}
+ }
+
+ // Capture phase = true so we see keys even if the player adds its own
+ // keydown handlers that call stopPropagation. The overlay's keys are
+ // operator/diagnostic only - we don't want them blocked.
+ try { document.addEventListener('keydown', handleKeydown, true); } catch (e) {}
+
+ // Boot
+ if (shouldAutoActivate()) {
+ if (document.body) {
+ activate();
+ } else {
+ try { document.addEventListener('DOMContentLoaded', activate); } catch (e) {}
+ }
+ }
+
+ // ====================================================================
+ // Section 4: error reporter
+ // ====================================================================
+ // Auto-posts captured errors to /api/player-debug. Runs independent of
+ // the visible overlay (target population is smart TVs that may never
+ // open the overlay). Triggered by:
+ // - A new error-ish entry pushed to __debugLog (debounced 5s, batched)
+ // - Page unload (navigator.sendBeacon with size cap)
+ //
+ // Gated by:
+ // - window.__playerConfig.debugReporting (server-injected from
+ // PLAYER_DEBUG_REPORTING env var; default true; kill switch for
+ // self-hosters)
+ // - UA allow-list (smart TV markers) and deny-list (modern desktop
+ // browsers - they have devtools, we don't need their telemetry)
+ // - 5-minute backoff after a 429 from the server
+
+ var REPORT_ENDPOINT = '/api/player-debug';
+ var DEBOUNCE_MS = 5000;
+ var RATE_LIMIT_BACKOFF_MS = 5 * 60 * 1000;
+ var BEACON_SIZE_CAP = 50000; // leave headroom under the ~64KB beacon limit
+ var BEACON_FALLBACK_ENTRIES = 30;
+ var LOG_TAIL_FOR_REPORT = 50;
+
+ var reportingEnabled = false;
+ var pendingQueue = [];
+ var debounceTimer = null;
+ var nextRetryAt = 0;
+
+ // ---- UA gating ----
+ // Order: allow-list smart-TV markers first (always report - this is the
+ // population we built for), then deny-list modern desktop browsers (they
+ // have devtools, no telemetry needed), then default to report for
+ // everything else (unknown UAs are the long tail of weird embedded
+ // browsers we want to catch).
+ function uaShouldReport(ua) {
+ if (!ua) return true; // empty UA is suspicious enough to report
+ if (SMART_TV_RE.test(ua)) return true;
+ // Modern desktop deny-list. Note: SMART_TV_RE already matched and
+ // returned above, so even if a Tizen UA contains "Chrome/108" (Tizen 7
+ // is Chromium 108) it cannot be deny-listed here.
+ var DESKTOP_RE = /Chrome\/1[0-9]{2}\.|Firefox\/1[0-9]{2}\.|Version\/(?:1[5-9]|2[0-9])\..*\sSafari\//;
+ if (DESKTOP_RE.test(ua)) return false;
+ return true;
+ }
+
+ // ---- Fingerprint ----
+ // Two-pass djb2 (forward + reverse, different seeds) producing 16 hex
+ // chars. Pure ES5, deterministic, collision-resistant enough for
+ // grouping ("top N unique errors this week"). Not crypto.
+ function hash16(s) {
+ var pad = '00000000';
+ var a = 5381;
+ var b = 0x9e3779b1 | 0; // unrelated seed
+ for (var i = 0; i < s.length; i++) a = ((a << 5) + a + s.charCodeAt(i)) | 0;
+ for (var j = s.length - 1; j >= 0; j--) b = ((b << 5) + b + s.charCodeAt(j)) | 0;
+ return (pad + (a >>> 0).toString(16)).slice(-8) + (pad + (b >>> 0).toString(16)).slice(-8);
+ }
+
+ // Hash inputs per spec:
+ // message (first 200 chars) + first stack frame's function name +
+ // filename (line/col stripped for line-drift stability).
+ // For console-only entries or resource-load failures with no stack, fall
+ // back to type + message.
+ function fingerprintOf(entry) {
+ if (!entry) return '';
+ var msg = String(entry.message || '').substring(0, 200);
+ var stackKey = '';
+ var stack = entry.stack || '';
+ if (stack) {
+ var lines = String(stack).split('\n');
+ for (var i = 0; i < lines.length; i++) {
+ var trimmed = String(lines[i]).replace(/^\s+/, '');
+ if (trimmed.indexOf('at ') === 0) {
+ // Strip ":line:col" or ":line" suffix so the fingerprint is
+ // stable across small refactors that move code by lines.
+ stackKey = trimmed.substring(3).replace(/:\d+(?::\d+)?\)?\s*$/, '');
+ break;
+ }
+ }
+ }
+ if (!stackKey) stackKey = String(entry.type || '');
+ return hash16(msg + '|' + stackKey);
+ }
+
+ // ---- Device id / player state (best-effort, no PII) ----
+ // Privacy rules from spec: do NOT capture input field values, pairing
+ // codes (after pairing completes), or content URLs. We capture only
+ // deviceId (a server-assigned UUID, harmless) and a coarse player state.
+ function getDeviceId() {
+ try {
+ if (typeof config !== 'undefined' && config && config.deviceId) return config.deviceId;
+ } catch (e) {}
+ try {
+ if (window.__playerState && window.__playerState.config && window.__playerState.config.deviceId) {
+ return window.__playerState.config.deviceId;
+ }
+ } catch (e) {}
+ return null;
+ }
+
+ function getPlayerState() {
+ try {
+ if (typeof isPlaying !== 'undefined') {
+ if (isPlaying) return 'playing';
+ if (typeof playlist !== 'undefined' && playlist && playlist.length === 0) return 'waiting';
+ return 'idle';
+ }
+ } catch (e) {}
+ return 'unknown';
+ }
+
+ // ---- Build report payload ----
+ function buildPayload(triggerErrors) {
+ var log = (window.__debugLog && window.__debugLog.length) ? window.__debugLog : [];
+ var tailStart = Math.max(0, log.length - LOG_TAIL_FOR_REPORT);
+ var fp = '';
+ if (triggerErrors && triggerErrors.length) {
+ try { fp = fingerprintOf(triggerErrors[0]); } catch (e) {}
+ }
+ return {
+ deviceId: getDeviceId(),
+ userAgent: (navigator && navigator.userAgent) || '',
+ url: (location && location.href) || '',
+ error_fingerprint: fp,
+ errors: triggerErrors || [],
+ context: {
+ screenW: (screen && screen.width) || 0,
+ screenH: (screen && screen.height) || 0,
+ viewportW: window.innerWidth || 0,
+ viewportH: window.innerHeight || 0,
+ deviceId: getDeviceId(),
+ playerState: getPlayerState(),
+ logTail: log.slice(tailStart)
+ }
+ };
+ }
+
+ // ---- Send (regular fetch path) ----
+ function sendReport(triggerErrors) {
+ if (Date.now() < nextRetryAt) return; // in 429 backoff
+ var payload;
+ var body;
+ try {
+ payload = buildPayload(triggerErrors);
+ body = JSON.stringify(payload);
+ } catch (e) { return; }
+ try {
+ // keepalive lets the request survive page hide/navigation when the
+ // browser supports it (modern). Doesn't replace sendBeacon for unload
+ // but reduces the loss surface for in-flight POSTs.
+ fetch(REPORT_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: body,
+ keepalive: true
+ }).then(function (resp) {
+ if (resp && resp.status === 429) {
+ nextRetryAt = Date.now() + RATE_LIMIT_BACKOFF_MS;
+ }
+ }).catch(function () { /* silent - we are telemetry, not a feature */ });
+ } catch (e) {}
+ }
+
+ // ---- Send (unload via beacon path) ----
+ function sendBeaconReport(triggerErrors) {
+ if (Date.now() < nextRetryAt) return;
+ if (typeof navigator.sendBeacon !== 'function') {
+ // Old browser without Beacon API. Best-effort: try keepalive fetch.
+ sendReport(triggerErrors);
+ return;
+ }
+ var payload, body, blob;
+ try {
+ payload = buildPayload(triggerErrors);
+ body = JSON.stringify(payload);
+ } catch (e) { return; }
+ // Beacon caps around 64KB depending on browser. If we're over the
+ // safe cap, trim the heaviest field (errors[] usually, then context.logTail).
+ if (body.length > BEACON_SIZE_CAP) {
+ try {
+ if (payload.errors && payload.errors.length > BEACON_FALLBACK_ENTRIES) {
+ payload.errors = payload.errors.slice(-BEACON_FALLBACK_ENTRIES);
+ }
+ if (payload.context && payload.context.logTail && payload.context.logTail.length > BEACON_FALLBACK_ENTRIES) {
+ payload.context.logTail = payload.context.logTail.slice(-BEACON_FALLBACK_ENTRIES);
+ }
+ body = JSON.stringify(payload);
+ } catch (e) { return; }
+ }
+ try {
+ blob = new Blob([body], { type: 'application/json' });
+ var ok = navigator.sendBeacon(REPORT_ENDPOINT, blob);
+ if (!ok) {
+ // Beacon refused (typically too-large payload). Retry once with
+ // an aggressively-trimmed payload.
+ try {
+ payload.errors = (payload.errors || []).slice(-BEACON_FALLBACK_ENTRIES);
+ payload.context = payload.context || {};
+ payload.context.logTail = (payload.context.logTail || []).slice(-BEACON_FALLBACK_ENTRIES);
+ navigator.sendBeacon(REPORT_ENDPOINT, new Blob([JSON.stringify(payload)], { type: 'application/json' }));
+ } catch (e) {}
+ }
+ } catch (e) {}
+ }
+
+ // ---- Queue + debounce ----
+ function queueReport(entry) {
+ if (!reportingEnabled) return;
+ if (Date.now() < nextRetryAt) return;
+ pendingQueue.push(entry);
+ if (debounceTimer) return; // already armed
+ debounceTimer = setTimeout(function () {
+ debounceTimer = null;
+ if (pendingQueue.length === 0) return;
+ var batch = pendingQueue;
+ pendingQueue = [];
+ sendReport(batch);
+ }, DEBOUNCE_MS);
+ }
+
+ function flushOnUnload() {
+ if (!reportingEnabled) return;
+ // Always send on unload, even with empty queue - the logTail context
+ // is useful for understanding what happened before the page went away.
+ var batch = pendingQueue.slice();
+ pendingQueue = [];
+ if (debounceTimer) { try { clearTimeout(debounceTimer); } catch (e) {} debounceTimer = null; }
+ sendBeaconReport(batch);
+ }
+
+ // ---- Wire it up ----
+ // Reporter activates if (a) PLAYER_DEBUG_REPORTING isn't off AND (b) the
+ // UA passes the gating. Both checks happen here once at module init -
+ // we re-evaluate nothing at runtime since UA and config are stable.
+ try {
+ var cfg = (window.__playerConfig && typeof window.__playerConfig.debugReporting === 'boolean')
+ ? window.__playerConfig.debugReporting
+ : true; // default on when no config injected (e.g. dev without the env var)
+ var ua = (navigator && navigator.userAgent) || '';
+ reportingEnabled = cfg && uaShouldReport(ua);
+
+ if (reportingEnabled) {
+ // Wrap __debugLog_push so we trigger the debounce on error-ish
+ // entries pushed by section 1's inline trap. Non-error pushes
+ // (init, timing, console.log) accumulate in __debugLog as context
+ // but don't trigger a send.
+ try {
+ var origPush = window.__debugLog_push;
+ if (typeof origPush === 'function') {
+ window.__debugLog_push = function (entry) {
+ origPush(entry);
+ try {
+ if (entry && (entry.type === 'error' || entry.type === 'rejection' || entry.type === 'console.error')) {
+ queueReport(entry);
+ }
+ } catch (e) {}
+ };
+ }
+ } catch (e) {}
+
+ // Catch errors that landed BEFORE this script wrapped __debugLog_push.
+ // The inline trap captured them into __debugLog; if any are error-ish,
+ // trigger an initial debounced send so they don't sit silently until
+ // the next error or page unload.
+ try {
+ var log = window.__debugLog || [];
+ for (var i = 0; i < log.length; i++) {
+ var e = log[i];
+ if (e && (e.type === 'error' || e.type === 'rejection' || e.type === 'console.error')) {
+ queueReport(e);
+ break; // one trigger is enough; debounce will batch
+ }
+ }
+ } catch (e) {}
+
+ // Unload handlers. pagehide is preferred on iOS Safari and
+ // bfcache-aware browsers; beforeunload covers everything else.
+ try { window.addEventListener('pagehide', flushOnUnload); } catch (e) {}
+ try { window.addEventListener('beforeunload', flushOnUnload); } catch (e) {}
+ }
+ } catch (e) {}
+
+ // Expose a manual control surface for support sessions ("paste this in the
+ // address bar"). Browsers and TV WebViews vary on whether javascript:
+ // URLs work, but where they do, support can ask the user to run
+ // javascript:__playerDebug.activate() to open the overlay without the
+ // user understanding query params.
+ window.__playerDebug = {
+ activate: activate,
+ deactivate: deactivate,
+ toggle: toggle,
+ toggleFreeze: toggleFreeze,
+ isActive: function () { return active; },
+ isFrozen: function () { return frozen; },
+ // Force a report now (manual control). Useful in support sessions:
+ // "open the player, run __playerDebug.report(), I'll see it in the
+ // admin view".
+ report: function () { sendReport([]); },
+ reportingEnabled: function () { return reportingEnabled; },
+ _internal: { fingerprintOf: fingerprintOf, uaShouldReport: uaShouldReport, hash16: hash16 }
+ };
+ } catch (outerErr) {
+ // If this whole script fails to parse or init, the player must still boot.
+ }
+})();
diff --git a/server/player/index.html b/server/player/index.html
index 9749ba3..6c9fd8e 100644
--- a/server/player/index.html
+++ b/server/player/index.html
@@ -3,6 +3,119 @@
+
+
+
+
ScreenTinker Player