// 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. } })();