screentinker/server/player/index.html
screentinker 2b137bc40b
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
fix(widgets): honest webpage-widget note — blocked sites don't work on device (#230)
The webpage-widget preview note claimed: "the site blocks embedding in a browser
— it will still display on the device screen." The second half is false. The
widget renders the URL in an <iframe> (renderWebpage), and the device player
loads that page in a Chromium WebView, so a site sending X-Frame-Options /
CSP frame-ancestors (Amazon, Google, most large sites/banks) is refused on the
device exactly as in the browser preview. The note set the wrong expectation —
a customer (and we) chased CORS and "should work on device" when the live
device screen was blank too.

Reword to tell the truth in all 6 languages (en/es/fr/de/it/pt), both the
frontend i18n key (widget.webpage_blocked_note) and the player's
preview_webpage_blocked string: if the preview is blank the site blocks
embedding and won't display on the device either — try a page that allows it.

Copy-only; no behaviour change. This is not an Amazon-side fix (embedding refusal
is the site's choice) — just accurate messaging.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 19:17:00 -05:00

3214 lines
173 KiB
HTML
Raw 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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<!--
Player debug error trap. MUST stay first inside <head> after the two
mandatory <meta> tags (charset must come first per HTML spec). Runs
before any other script so we capture errors even from parse-time
failures in scripts that come after. Vanilla ES5 syntax (var, function,
no arrow/const/template) so it loads on ancient WebKit forks (Tizen 4,
older WebOS, Fire TV stick Gen 1, embedded signage browsers). Wrapped
in defensive try/catch so this script can never be the reason the
player won't boot.
-->
<script>
(function () {
try {
if (!window.__debugLog) window.__debugLog = [];
var MAX_LOG = 200;
var INIT_T = (function () { try { return Date.now(); } catch (e) { return new Date().getTime(); } })();
function nowMs() {
try { return Date.now(); } catch (e) { return new Date().getTime(); }
}
function pushLog(entry) {
try {
entry.t = nowMs();
window.__debugLog.push(entry);
if (window.__debugLog.length > MAX_LOG) {
window.__debugLog.splice(0, window.__debugLog.length - MAX_LOG);
}
} catch (e) { /* we are the safety net; do not crash */ }
}
window.__debugLog_push = pushLog; // shared pusher for debug-overlay.js
pushLog({
type: 'init',
ua: (navigator && navigator.userAgent) || '',
url: location.href,
sw: typeof screen !== 'undefined' ? screen.width : null,
sh: typeof screen !== 'undefined' ? screen.height : null
});
// Uncaught script errors. Capture phase so we also see errors from
// <img>/<script>/<link> resource failures (ev.target.src on those).
window.addEventListener('error', function (ev) {
try {
var src = ev.filename || (ev.target && ev.target.src) || '';
pushLog({
type: 'error',
message: ev.message || (ev.error && String(ev.error)) || 'error',
source: src,
line: ev.lineno || 0,
col: ev.colno || 0,
stack: (ev.error && ev.error.stack) || ''
});
} catch (e) {}
}, true);
window.addEventListener('unhandledrejection', function (ev) {
try {
var reason = ev.reason;
pushLog({
type: 'rejection',
message: (reason && reason.message) || String(reason || 'rejection'),
stack: (reason && reason.stack) || ''
});
} catch (e) {}
});
// Wrap console methods. Original behavior preserved so devtools still
// sees the real call; we additionally append a sanitized text record.
var methods = ['log', 'warn', 'error'];
for (var i = 0; i < methods.length; i++) {
(function (m) {
var orig = console[m];
console[m] = function () {
try {
var args = Array.prototype.slice.call(arguments);
var msg = '';
for (var j = 0; j < args.length; j++) {
var part;
try {
part = typeof args[j] === 'string' ? args[j] : JSON.stringify(args[j]);
} catch (e) {
part = String(args[j]);
}
msg += (j > 0 ? ' ' : '') + part;
}
if (msg.length > 1000) msg = msg.slice(0, 1000) + '...[trunc]';
pushLog({ type: 'console.' + m, message: msg });
} catch (e) {}
try { return orig.apply(console, arguments); } catch (e) {}
};
})(methods[i]);
}
// Page-load timing relative to this script's execution.
try {
document.addEventListener('DOMContentLoaded', function () {
pushLog({ type: 'timing', event: 'DOMContentLoaded', sinceInit: nowMs() - INIT_T });
});
window.addEventListener('load', function () {
pushLog({ type: 'timing', event: 'load', sinceInit: nowMs() - INIT_T });
});
} catch (e) {}
} catch (outerErr) {
// If even init failed, the player must still boot. Do nothing.
}
})();
</script>
<!-- Debug overlay module (section 2). Defers so HTML parse doesn't block
on the fetch. Auto-activation check fires after DOMContentLoaded;
few-ms delay vs the inline trap is fine since errors before this
loads are already captured in __debugLog by the inline trap above. -->
<script src="/player/debug-overlay.js" defer></script>
<title>ScreenTinker Player</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: -apple-system, sans-serif; }
/* Setup Screen */
#setupScreen {
position: fixed; inset: 0; background: #111827; display: flex; flex-direction: column;
align-items: center; justify-content: center; z-index: 1000; color: #f1f5f9;
}
#setupScreen h1 { font-size: 36px; color: #3b82f6; margin-bottom: 8px; }
#setupScreen .subtitle { color: #94a3b8; font-size: 16px; margin-bottom: 48px; }
#setupScreen .form { width: 400px; max-width: 90vw; }
#setupScreen label { display: block; font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
#setupScreen input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #f1f5f9; font-size: 16px; margin-bottom: 24px; outline: none; }
#setupScreen input:focus { border-color: #3b82f6; }
#setupScreen button { width: 100%; padding: 12px; background: #3b82f6; color: white;
border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
#setupScreen button:hover { background: #2563eb; }
#setupScreen button:disabled { opacity: 0.5; cursor: not-allowed; }
.pairing-code { font-size: 72px; font-weight: 700; color: #3b82f6; font-family: monospace;
letter-spacing: 12px; margin: 24px 0; }
.pairing-hint { color: #64748b; font-size: 14px; }
.status-msg { color: #94a3b8; font-size: 14px; margin-top: 16px; }
.spinner { width: 40px; height: 40px; border: 3px solid #334155; border-top-color: #3b82f6;
border-radius: 50%; animation: spin 1s linear infinite; margin: 24px auto; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Player */
#playerContainer { position: fixed; inset: 0; background: #000; }
/* Fullscreen single-zone playback: YouTube's IFrame API measures the placeholder
at construction time. If that happens before layout settles, YT bakes in a
300x150 fallback as inline pixel dimensions on the iframe, which our %-based
rules can't override. Force fullscreen via absolute positioning + !important. */
#playerContainer > iframe,
#playerContainer > div > iframe {
position: absolute !important;
top: 0 !important; left: 0 !important;
width: 100% !important; height: 100% !important;
border: none !important; display: block !important;
}
.zone { position: absolute; overflow: hidden; }
.zone video { width: 100%; height: 100%; object-fit: cover; }
.zone img { width: 100%; height: 100%; object-fit: cover; }
.zone iframe { width: 100%; height: 100%; border: none; }
/* Video wall mode.
wall-stage maps the wall's player_rect into this device's viewport
using vw/vh — so the device fills its full viewport edge-to-edge
(no pillarbox at the seam between adjacent screens).
object-fit:fill is intentional: it stretches the source to the stage,
which keeps vertical position identical between devices that share
a viewport height — without that, cover-cropping on different stage
aspects (different innerWidths) shifts content vertically. */
#playerContainer.wall-mode { overflow: hidden; background: #000; }
.wall-stage { position: absolute; }
.wall-stage > video,
.wall-stage > img { width: 100%; height: 100%; object-fit: fill; display: block; }
.wall-stage > iframe { width: 100%; height: 100%; border: none; display: block; }
.wall-mode #playerContainer > iframe,
.wall-mode #playerContainer > div > iframe { position: static !important; width: 100% !important; height: 100% !important; }
/* #109: PiP overlay layer — a fixed full-viewport layer ABOVE #playerContainer that
the playlist never touches. The same orientation transform is applied to it as to
#playerContainer, so corner positions track the visible content in every orientation.
Pointer-transparent and empty (invisible) until an overlay is shown. */
#pipContainer { position: fixed; inset: 0; pointer-events: none; z-index: 9000; }
#pipContainer > .pip-box { position: absolute; overflow: hidden; box-sizing: border-box; box-shadow: 0 6px 28px rgba(0,0,0,0.55); }
#pipContainer > .pip-box > .pip-title { font: 600 16px sans-serif; padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#pipContainer > .pip-box > img,
#pipContainer > .pip-box > iframe { display: block; width: 100%; border: 0; }
/* Status overlay */
#statusOverlay {
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #94a3b8; z-index: 500;
}
#statusOverlay h2 { color: #3b82f6; font-size: 28px; margin-bottom: 8px; }
#statusOverlay p { font-size: 16px; }
</style>
</head>
<body>
<!-- Setup Screen -->
<div id="setupScreen">
<h1>ScreenTinker</h1>
<div class="subtitle">Web Player</div>
<div class="form" id="urlForm">
<label>Server URL</label>
<input type="url" id="serverUrl" placeholder="https://sign.yourdomain.com" autofocus>
<button id="connectBtn">Connect</button>
</div>
<div id="pairingSection" style="display:none;text-align:center">
<p>Pairing Code</p>
<div class="pairing-code" id="pairingCode">------</div>
<p class="pairing-hint">Enter this code in the dashboard to pair this display</p>
</div>
<div class="spinner" id="setupSpinner" style="display:none"></div>
<div class="status-msg" id="setupStatus"></div>
</div>
<!-- Player Container -->
<div id="playerContainer" style="display:none"></div>
<!-- #109: PiP overlay layer (above the player; never touched by playlist render) -->
<div id="pipContainer"></div>
<!-- Status Overlay -->
<div id="statusOverlay" style="display:none">
<div class="spinner"></div>
<h2>ScreenTinker</h2>
<p id="statusText" data-i18n="connecting">Connecting...</p>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="/player/schedule-eval.js"></script>
<script src="/player/player-media-health.js"></script>
<!-- feat/transition-engine: WebGL transition runtime (renderer + shaders). Optional; if it fails to
load the player just hard-cuts. Not deferred so it's ready before the first content swap. -->
<script src="/player/transitions.js"></script>
<script>
// ==================== i18n ====================
// Lightweight inline i18n for the player. The player is a standalone page
// on display devices — it doesn't import the dashboard's i18n module.
// Keep keys in sync with the player overlay strings; falls back to en.
const PLAYER_I18N = {
en: {
web_player: 'Web Player',
server_url: 'Server URL',
server_url_placeholder: 'https://sign.yourdomain.com',
connect: 'Connect',
pairing_code: 'Pairing Code',
pairing_hint: 'Enter this code in the dashboard to pair this display',
connecting: 'Connecting...',
connecting_muted: 'Connecting (audio muted)...',
info_title: 'ScreenTinker Web Player',
info_close_hint: 'Press Back again or click to close',
info_device_id: 'Device ID',
info_device_name: 'Device Name',
info_server: 'Server',
info_status: 'Status',
info_now_playing: 'Now Playing',
info_resolution: 'Resolution',
info_uptime: 'Uptime',
info_platform: 'Platform',
info_cache: 'Cache',
info_connected: 'Connected',
info_disconnected: 'Disconnected',
info_active: 'Active',
info_inactive: 'Inactive',
info_nothing: 'Nothing',
info_na: 'N/A',
info_sw: 'Service Worker',
nothing_scheduled: 'Nothing scheduled right now',
preview_webpage_blocked: 'If this area is blank, the site blocks being embedded and wont display on the device either. Try a page that allows embedding.',
},
es: {
web_player: 'Reproductor web', server_url: 'URL del servidor', server_url_placeholder: 'https://signage.tudominio.com', connect: 'Conectar', pairing_code: 'Código de vinculación', pairing_hint: 'Ingresa este código en el panel para vincular esta pantalla', connecting: 'Conectando...', connecting_muted: 'Conectando (audio silenciado)...', info_title: 'Reproductor web ScreenTinker', info_close_hint: 'Presiona Atrás de nuevo o haz clic para cerrar', info_device_id: 'ID del dispositivo', info_device_name: 'Nombre del dispositivo', info_server: 'Servidor', info_status: 'Estado', info_now_playing: 'Reproduciendo', info_resolution: 'Resolución', info_uptime: 'Tiempo activo', info_platform: 'Plataforma', info_cache: 'Caché', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Activo', info_inactive: 'Inactivo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'No hay nada programado en este momento', preview_webpage_blocked: 'Si esta área está en blanco, el sitio bloquea la inserción y tampoco se mostrará en el dispositivo. Prueba con una página que permita la inserción.',
},
fr: {
web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code dappairage', pairing_hint: 'Saisissez ce code dans le tableau de bord pour apparier cet écran', connecting: 'Connexion...', connecting_muted: 'Connexion (audio coupé)...', info_title: 'Lecteur web ScreenTinker', info_close_hint: 'Appuyez à nouveau sur Retour ou cliquez pour fermer', info_device_id: 'ID de lappareil', info_device_name: 'Nom de lappareil', info_server: 'Serveur', info_status: 'État', info_now_playing: 'En lecture', info_resolution: 'Résolution', info_uptime: 'Disponibilité', info_platform: 'Plateforme', info_cache: 'Cache', info_connected: 'Connecté', info_disconnected: 'Déconnecté', info_active: 'Actif', info_inactive: 'Inactif', info_nothing: 'Rien', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Rien de programmé pour le moment', preview_webpage_blocked: 'Si cette zone est vide, le site bloque lintégration et ne saffichera pas non plus sur lappareil. Essayez une page qui lautorise.',
},
de: {
web_player: 'Web-Player', server_url: 'Server-URL', server_url_placeholder: 'https://signage.ihredomain.com', connect: 'Verbinden', pairing_code: 'Kopplungscode', pairing_hint: 'Geben Sie diesen Code im Dashboard ein, um diesen Bildschirm zu koppeln', connecting: 'Verbindung wird hergestellt...', connecting_muted: 'Verbindung (Audio stummgeschaltet)...', info_title: 'ScreenTinker Web-Player', info_close_hint: 'Erneut Zurück drücken oder klicken zum Schließen', info_device_id: 'Geräte-ID', info_device_name: 'Gerätename', info_server: 'Server', info_status: 'Status', info_now_playing: 'Aktuelle Wiedergabe', info_resolution: 'Auflösung', info_uptime: 'Betriebszeit', info_platform: 'Plattform', info_cache: 'Cache', info_connected: 'Verbunden', info_disconnected: 'Getrennt', info_active: 'Aktiv', info_inactive: 'Inaktiv', info_nothing: 'Nichts', info_na: 'N/V', info_sw: 'Service Worker', nothing_scheduled: 'Derzeit ist nichts geplant', preview_webpage_blocked: 'Wenn dieser Bereich leer ist, blockiert die Website die Einbettung und wird auch auf dem Gerät nicht angezeigt. Verwenden Sie eine Seite, die dies erlaubt.',
},
pt: {
web_player: 'Player web', server_url: 'URL do servidor', server_url_placeholder: 'https://sign.seudominio.com', connect: 'Conectar', pairing_code: 'Código de pareamento', pairing_hint: 'Digite este código no painel para parear esta tela', connecting: 'Conectando...', connecting_muted: 'Conectando (áudio mudo)...', info_title: 'Player web ScreenTinker', info_close_hint: 'Pressione Voltar novamente ou clique para fechar', info_device_id: 'ID do dispositivo', info_device_name: 'Nome do dispositivo', info_server: 'Servidor', info_status: 'Status', info_now_playing: 'Reproduzindo', info_resolution: 'Resolução', info_uptime: 'Tempo ativo', info_platform: 'Plataforma', info_cache: 'Cache', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Ativo', info_inactive: 'Inativo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Nada programado no momento', preview_webpage_blocked: 'Se esta área estiver em branco, o site bloqueia a incorporação e também não será exibido no dispositivo. Tente uma página que a permita.',
},
};
const PLAYER_LANG = (() => {
const stored = localStorage.getItem('rd_lang');
const detected = (stored || navigator.language || 'en').split('-')[0];
return PLAYER_I18N[detected] ? detected : 'en';
})();
const _t = (k) => (PLAYER_I18N[PLAYER_LANG] && PLAYER_I18N[PLAYER_LANG][k]) || PLAYER_I18N.en[k] || k;
// Apply translations to the static setup screen markup
document.addEventListener('DOMContentLoaded', () => {
const setSubtitle = document.querySelector('#setupScreen .subtitle');
if (setSubtitle) setSubtitle.textContent = _t('web_player');
const lblServer = document.querySelector('#urlForm label');
if (lblServer) lblServer.textContent = _t('server_url');
const inputServer = document.getElementById('serverUrl');
if (inputServer) inputServer.placeholder = _t('server_url_placeholder');
const connectBtn = document.getElementById('connectBtn');
if (connectBtn) connectBtn.textContent = _t('connect');
const pairingP = document.querySelector('#pairingSection p:first-child');
if (pairingP) pairingP.textContent = _t('pairing_code');
const pairingHint = document.querySelector('#pairingSection .pairing-hint');
if (pairingHint) pairingHint.textContent = _t('pairing_hint');
// Translate any element with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const k = el.getAttribute('data-i18n');
if (k) el.textContent = _t(k);
});
});
// ==================== Config ====================
const STORAGE_KEY = 'rd_web_player';
const HEARTBEAT_INTERVAL = 15000;
const PLAYLIST_REFRESH_INTERVAL = 60000;
// #104: device-free dashboard preview mode (set by the ?preview=1 boot branch).
// Gates the webpage-widget honest note and keeps the pairing/socket path off.
let PREVIEW_MODE = false;
function getConfig() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
}
function saveConfig(cfg) { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); }
const PLAYLIST_CACHE_KEY = 'rd_playlist_cache';
function savePlaylistCache(items) {
try { localStorage.setItem(PLAYLIST_CACHE_KEY, JSON.stringify(items)); } catch {}
}
function loadPlaylistCache() {
try { return JSON.parse(localStorage.getItem(PLAYLIST_CACHE_KEY) || '[]'); } catch { return []; }
}
// Cache the layout alongside the playlist so a cold start renders the correct
// zone layout on the FIRST pass, instead of rendering fullscreen and only
// switching to zones once the server payload arrives.
const LAYOUT_CACHE_KEY = 'rd_layout_cache';
function saveLayoutCache(l) {
try { localStorage.setItem(LAYOUT_CACHE_KEY, JSON.stringify(l || null)); } catch {}
}
function loadLayoutCache() {
try { return JSON.parse(localStorage.getItem(LAYOUT_CACHE_KEY) || 'null'); } catch { return null; }
}
// ==================== State ====================
let socket = null;
let config = getConfig();
// feat/offline-cause-log: connectivity-report state — track in-session disconnects so the next
// reconnect can report WHY it was gone (local link lost vs server/upstream unreachable). A browser
// can't see SSID/RSSI, so we send only offline_ms + link_lost + cold_start:false.
let disconnectedAt = 0; // Date.now() at the first disconnect of the current gap (0 = not in a gap)
let linkLostDuringGap = false; // navigator went offline at any point during the gap
// feat/offline-cause-log: typed incident feed (device:event) — server inserts a device_events row.
// Best-effort + auth-guarded (the reconnected socket is authenticated by the time we emit).
function emitDeviceEvent(type, reason, detail) {
try {
if (!socket?.connected || !config.deviceId) return;
const m = { device_id: config.deviceId, type };
if (reason) m.reason = reason;
if (detail) m.detail = detail;
socket.emit('device:event', m);
} catch (e) {}
}
let playlist = [];
let currentIndex = -1;
let imgPreloadCache = {}; // src -> decoded HTMLImageElement, warmed one item ahead (feat/player-image-preload)
// #157: deferred rotation-out. When a playlist update removes the item currently on screen
// (e.g. it just expired) in solo playback, we keep it up and rotate to deferredSuccessorId on
// the next natural advance instead of interrupting/restarting.
let deferredRotation = false;
let deferredSuccessorId = null;
function itemIdentity(x) { return x ? `${x.content_id || ''}|${x.widget_id || ''}|${x.remote_url || ''}|${x.filepath || ''}` : ''; }
// @exit-signal-slice:start — v4-exit-signal-phase3.test.js evals the lines between these markers.
// ==================== Exit-signal contract v1 (best-effort last gasp) ====================
// Announce manner-of-death via navigator.sendBeacon (reliable-on-unload — survives the socket
// teardown). crashed: real uncaught SCRIPT error / unhandled rejection. clean_exit: pagehide with
// persisted=false (a genuine unload — NOT a bfcache suspend, which the liveness watchdog handles,
// and NOT mere visibility-hidden). Honesty: only these two confident categories are ever sent;
// anything uncertain sends nothing -> the server infers 'silent'. Idempotent (first signal wins,
// so a crash is never relabelled clean_exit by the pagehide that follows it).
let __exitSent = false;
function sendExitBeacon(reason, detail) {
try {
if (__exitSent) return;
if (reason !== 'crashed' && reason !== 'clean_exit') return;
if (!config || !config.deviceId || !config.deviceToken) return; // unpaired -> nothing to attribute
__exitSent = true;
const url = (config.serverUrl || window.location.origin) + '/api/device/exit';
const body = JSON.stringify({ device_id: config.deviceId, device_token: config.deviceToken,
reason, detail: (typeof detail === 'string' && detail) ? detail.slice(0, 200) : undefined });
const blob = new Blob([body], { type: 'application/json' }); // Content-Type so express.json parses it
if (navigator.sendBeacon && navigator.sendBeacon(url, blob)) return;
fetch(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(() => {});
} catch (e) { /* a dying page must never throw */ }
}
window.addEventListener('error', (ev) => {
// ONLY a real uncaught script error is a crash — a resource (img/script/link) load failure is NOT.
if (!ev) return;
const isResourceError = ev.target && ev.target !== window && (ev.target.src || ev.target.href);
if (isResourceError) return;
sendExitBeacon('crashed', (ev.error && ev.error.message) || ev.message || 'error');
});
window.addEventListener('unhandledrejection', (ev) => {
const r = ev && ev.reason;
sendExitBeacon('crashed', (r && (r.message || String(r))) || 'unhandledrejection');
});
window.addEventListener('pagehide', (ev) => {
if (ev && ev.persisted) return; // bfcache SUSPEND (may restore) — NOT a death; watchdog owns it
sendExitBeacon('clean_exit', 'pagehide');
});
// @exit-signal-slice:end
let isPlaying = false;
let playerTimezone = null; // #74/#75: device-effective IANA tz for schedule eval
let scheduleRetryTimer = null; // re-check when every item is filtered out
let heartbeatTimer = null;
let refreshTimer = null;
let remoteStreaming = false;
let streamTimer = null;
let layout = null;
let zones = {};
// Tracks whether the user has gestured in *this* page load. Browser autoplay
// policy is per-document — a flag from a previous session does NOT grant
// autoplay rights here, so we always start as false. The cold-load tap overlay
// is the only thing that flips this to true (or its 5s timeout, which keeps
// playback muted).
let userHasInteracted = false;
let advanceTimer = null;
// Buffered widget swap (#directory-board black-cycle): build the next widget iframe
// behind the current content and reveal it only on 'load', so a widget reload never
// blanks the screen. WIDGET_SWAP_TIMEOUT_MS reveals anyway if 'load' never fires (a
// network hang must not leave a frozen/blank board). A solo/held widget re-fetches its
// data every WIDGET_SOLO_REFRESH_MS — decoupled from duration_sec, because a static
// board re-querying the DB + re-rendering every few seconds, fleet-wide, is pure waste.
const WIDGET_SWAP_TIMEOUT_MS = 8000;
const WIDGET_SOLO_REFRESH_MS = 60000;
let pendingWidgetSwap = null; // { iframe, timer } while a new widget iframe loads
// Per-zone rotation timers (multi-zone). Each zone advances independently on
// its own interval, decoupled from the fullscreen advanceTimer/nextItem.
let zoneTimers = {};
// #zone-orphan: parts of the operator-only PREVIEW banner (never shown on a live
// player). 'layout' = dominant-layout note when items span >1 layout; 'orphans' =
// the list of items whose zone isn't in the active layout. renderPreviewBanner()
// composes them into a single #previewBanner element.
let previewBannerParts = {};
// Video wall state. wallConfig is the tile assignment from the server
// (null when this device isn't in a wall). The leader runs the playlist
// normally and broadcasts wall:sync every second; followers don't run
// their own advance timers and instead align their currentIndex and
// video position to whatever the leader is playing.
let wallConfig = null;
let wallSyncTimer = null;
// #group-sync: synchronized group playback. NOT leader/follower — every member derives its
// tick locally from a server-disciplined clock + the shared playlist schedule. Works offline.
let groupSync = null;
let groupSyncTimer = null;
let groupDbgLast = 0;
// A fresh item snaps ONCE to the exact schedule position (load-and-hold) instead of nudging away
// the ~0.3s load offset over ~10s. Steady-state drift still rides the gentle nudge afterward.
let groupAlignPending = true;
let groupLastAlignedIndex = -1;
let groupLastSeekAt = 0; // seek cooldown — don't hard-seek every tick (decoder-thrash guard)
// Double buffer: a hidden <video> for the NEXT clip, buffered/decoded ahead of the boundary so the
// switch is instant (no black hold). Reused by renderContent when it reaches that index.
let groupPreloadEl = null;
let groupPreloadIdx = -1;
// #group-sync clock discipline. The server is the time authority (see heartbeat-ack). We keep a
// smoothed offset so synced_now = Date.now() + clockOffsetMs, and CACHE it in localStorage so a
// player that has synced even once stays aligned through an internet outage (RTC drift is tiny).
let clockOffsetMs = 0;
let clockRttMs = null;
try { const c = localStorage.getItem('st_clock_offset'); if (c != null && isFinite(Number(c))) clockOffsetMs = Number(c); } catch (e) {}
function syncedNow() { return Date.now() + clockOffsetMs; }
function ingestClockSample(serverMs, clientMs) {
if (!serverMs || !clientMs) return;
const t4 = Date.now();
const rtt = Math.max(0, t4 - clientMs);
if (rtt > 5000) return; // absurd RTT (sleep/GC stall) — don't poison the offset
const sample = serverMs - (clientMs + t4) / 2; // NTP-style: offset = server - (t1+t4)/2
// Snap on the first sample or a big jump (clock step); otherwise EMA-smooth out jitter.
if (clockRttMs === null || Math.abs(sample - clockOffsetMs) > 1000) clockOffsetMs = Math.round(sample);
else clockOffsetMs = Math.round(clockOffsetMs * 0.8 + sample * 0.2);
clockRttMs = Math.round(rtt);
try { localStorage.setItem('st_clock_offset', String(clockOffsetMs)); } catch (e) {}
}
// Stream a group-sync diagnostic to the dashboard live-log (tag 'sync') + the in-page overlay.
function groupReport(level, msg) {
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'sync', level, message: msg }); } catch (e) {}
try { window.__debugLog_push && window.__debugLog_push({ type: 'sync', level: level, msg: msg }); } catch (e) {}
}
let lastWallSync = null;
let currentVideoEl = null;
let currentItemStartedAt = 0;
// Bumped on every renderContent dispatch; a buffered (async warm-play) render captures it at start
// and bails if it changes. MUST be declared here with the other top-level state — the cold-start
// cached-playlist restore calls renderContent (→ renderSeq++) during initial script execution, which
// is long before line ~2400 where the buffered-video code lives. A `let` down there left renderSeq in
// the temporal dead zone on that early path → "can't access 'renderSeq' before initialization".
let renderSeq = 0;
// Followers in a video wall must stay silent — N copies of the same audio
// slightly out of sync produce a flanged echo across the wall. Only the
// leader is allowed to make sound. This helper is the single source of
// truth used by every code path that would otherwise unmute audio.
function isWallFollower() { return !!(wallConfig && !wallConfig.is_leader); }
// YouTube player state. Declared up front because the cached-playlist restore
// (a few lines below) may synchronously call into createYoutubeEmbed before the
// script reaches the original declaration site, which used to throw a temporal
// dead zone error.
let ytApiReady = false;
let ytApiCallbacks = [];
let activeYtPlayer = null;
let ytGeneration = 0;
// #215: fallback advance timer for YouTube. Shorts (and some Android TV WebViews)
// never fire the ENDED state, so onStateChange alone can stall the playlist. Armed
// in onReady from the reported duration, cleared on ENDED/onError/teardown.
let ytSafetyNet = null;
// AudioContext is created lazily on the first user gesture. Resuming it
// is what convinces stricter browsers (Firefox) that the site is "user-
// activated" for audio. Reused across all later unmute attempts.
let _audioCtx = null;
function unlockAudioContext() {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(() => {});
// Play a 1-sample silent buffer to fully promote the context to running.
const buf = _audioCtx.createBuffer(1, 1, 22050);
const src = _audioCtx.createBufferSource();
src.buffer = buf;
src.connect(_audioCtx.destination);
src.start(0);
} catch (e) { /* harmless */ }
}
// Try to unmute and play the leader video. MUST be called synchronously
// from inside a real user-gesture handler — any preceding await would
// throw away the gesture's user-activation in stricter browsers (Firefox).
// Returns immediately; the play() promise is resolved/rejected async.
function tryUnmuteLeader() {
const video = document.querySelector('#playerContainer video');
if (!video) return false;
if (!video.muted) return true;
// Capture state, unmute, do a fresh pause+play within the same task.
// Firefox is more permissive when play() is treated as a brand-new
// gesture-driven start rather than the unmute of an autoplaying video.
const t = video.currentTime;
video.muted = false;
video.volume = 1.0;
video.pause();
const p = video.play();
if (p && typeof p.then === 'function') {
p.then(() => {
if (isFinite(t)) { try { video.currentTime = t; } catch {} }
console.log('[wall/audio] unmuted play() ok muted=' + video.muted + ' volume=' + video.volume);
hideEnableAudioPrompt();
}).catch((err) => {
console.warn('[wall/audio] unmuted play() rejected: ' + (err?.name || err?.message || err));
// Remute so playback continues; surface the prompt for explicit consent.
video.muted = true;
video.play().catch((e2) => console.error('[wall/audio] muted-fallback play() failed: ' + (e2?.name || e2?.message || e2)));
showEnableAudioPrompt();
});
}
return true;
}
// Visible "tap to enable audio" prompt for leaders whose unmute failed.
// The user clicking this prompt is itself a fresh gesture, which is the
// most reliable path past Firefox's autoplay restriction.
function showEnableAudioPrompt() {
if (isWallFollower()) return;
if (document.getElementById('enableAudioPrompt')) return;
const ov = document.createElement('div');
ov.id = 'enableAudioPrompt';
ov.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:12px 22px;border-radius:8px;cursor:pointer;z-index:10000;font-size:14px;display:flex;gap:10px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,0.4)';
ov.innerHTML = '<span style="font-size:20px">&#128263;</span><span>Tap to enable audio</span>';
ov.addEventListener('click', () => {
unlockAudioContext();
tryUnmuteLeader();
});
document.body.appendChild(ov);
}
function hideEnableAudioPrompt() {
document.getElementById('enableAudioPrompt')?.remove();
}
// Track user interaction for autoplay policy
['click', 'touchstart', 'keydown'].forEach(evt => {
document.addEventListener(evt, () => {
const wasFirst = !userHasInteracted;
userHasInteracted = true;
// First gesture: prime the AudioContext. This signals "site activated"
// to Firefox and unlocks subsequent <video> unmute attempts.
if (wasFirst) unlockAudioContext();
// Followers in a video wall must stay muted forever — even after a
// user gesture. Otherwise tapping a follower screen would unmute it
// and cause echo with the leader.
if (isWallFollower()) return;
if (wasFirst) console.log('[wall/audio] first user gesture detected — attempting unmute');
tryUnmuteLeader();
// Unmute YouTube player if active
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
try { activeYtPlayer.unMute(); activeYtPlayer.setVolume(100); console.log('Unmuted YouTube player'); } catch {}
}
}, { once: false });
});
// ==================== Browser Fingerprint ====================
function generateBrowserFingerprint() {
const components = [
navigator.userAgent,
navigator.language,
screen.width + 'x' + screen.height,
screen.colorDepth,
new Date().getTimezoneOffset(),
navigator.hardwareConcurrency || 0,
navigator.platform,
// Canvas fingerprint
(() => {
try {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillText('ScreenTinker fingerprint', 2, 2);
return c.toDataURL().slice(-50);
} catch { return ''; }
})(),
];
// Simple hash
const str = components.join('|');
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'web-' + Math.abs(hash).toString(36) + '-' + str.length.toString(36);
}
// ==================== Boot ====================
// Function used by connect button and auto-connect
let autoContinueTimer;
function connectBtnFunc() {
if (autoContinueTimer) {
clearInterval(autoContinueTimer);
autoContinueTimer = null;
document.getElementById('connectBtn').textContent = _t('connect');
}
unlockAudio();
const url = document.getElementById('serverUrl').value.trim().replace(/\/$/, '');
if (!url) return;
config.serverUrl = url;
saveConfig(config);
document.getElementById('connectBtn').disabled = true;
document.getElementById('setupSpinner').style.display = 'block';
document.getElementById('setupStatus').textContent = 'Connecting...';
connect(url);
};
// #104: device-free dashboard preview. Render a draft playlist by id with NO
// pairing and NO socket. Gated here, before the normal boot, so the unpaired
// auto-connect timer below can never fire underneath a preview.
const _previewQS = new URLSearchParams(location.search);
if (_previewQS.get('preview') === '1' && (_previewQS.get('playlist') || _previewQS.get('device'))) {
bootPreview(_previewQS);
} else {
// Auto-detect server URL from origin since player is served from the same server
if (!config.serverUrl) {
config.serverUrl = window.location.origin;
saveConfig(config);
}
if (config.serverUrl && config.deviceId && config.paired) {
// Restore cached playlist immediately so content plays even if offline —
// but ONLY if we also know the layout, else we'd guess fullscreen and flash
// before the payload arrives. Key presence (not value) is the test: an absent
// key means "layout unknown" (e.g. first run after this shipped, or cleared
// cache), while a stored `null` is a real fullscreen device. Both caches are
// written on every payload, so after the first connection this always renders
// immediately; only a genuinely-unknown layout waits (~1s) for the payload.
const cachedPlaylist = loadPlaylistCache();
const layoutKnown = localStorage.getItem(LAYOUT_CACHE_KEY) !== null;
if (cachedPlaylist.length > 0 && layoutKnown) {
console.log('Restored cached playlist:', cachedPlaylist.length, 'items');
playlist = cachedPlaylist;
layout = loadLayoutCache();
document.getElementById('setupScreen').style.display = 'none';
startPlaybackAt(0); // #74/#75: honour schedules from the first frame on cold-start
// #group-sync: if this device was in a sync group, resume the schedule immediately from the
// cached clock offset — so a reboot mid-outage comes back aligned WITHOUT waiting for a server.
try { const cg = localStorage.getItem('st_group_sync'); if (cg) applyGroupSync(JSON.parse(cg)); } catch (e) {}
}
// Always show the tap overlay on cold load. Browser autoplay policy is
// per-document — a localStorage flag from a prior session does not grant
// audio autoplay to a fresh page. The overlay auto-dismisses after 5s and
// connects muted, so unattended kiosks still recover without a human tap.
{
const tapOverlay = document.createElement('div');
tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
tapOverlay.innerHTML = `
<h1 style="color:#3b82f6;font-size:36px;font-family:sans-serif;margin-bottom:12px">ScreenTinker</h1>
<p style="color:#94a3b8;font-size:18px;font-family:sans-serif">Tap anywhere to start</p>
<p style="color:#64748b;font-size:13px;font-family:sans-serif;margin-top:24px">Audio requires user interaction</p>
`;
tapOverlay.onclick = () => {
unlockAudio();
tapOverlay.remove();
if (!isPlaying) showStatus(_t('connecting'));
connect(config.serverUrl);
};
document.body.appendChild(tapOverlay);
// Auto-dismiss after 5 seconds if no interaction (plays muted)
setTimeout(() => {
if (tapOverlay.parentNode) {
tapOverlay.remove();
if (!isPlaying) showStatus(_t('connecting_muted'));
connect(config.serverUrl);
}
}, 5000);
}
} else {
// Auto-Continue after 5s if not configured. If user interacts with form (typing in the box), stop the timer.
let countdown = 5;
const connectBtn = document.getElementById('connectBtn');
connectBtn.textContent = `${_t('connect')} (${countdown})`;
autoContinueTimer = setInterval(() => {
countdown--;
if (countdown > 0) {
connectBtn.textContent = `${_t('connect')} (${countdown})`;
} else {
clearInterval(autoContinueTimer);
connectBtn.textContent = _t('connect');
connectBtnFunc()
}
}, 1000);
document.getElementById('serverUrl').addEventListener('input', () => {
if (countdown > 0) {
clearInterval(autoContinueTimer);
connectBtn.textContent = _t('connect');
}
});
}
} // #104: end preview-mode gate (else branch wrapping the normal boot)
// ==================== Setup UI ====================
const savedUrl = config.serverUrl || window.location.origin;
document.getElementById('serverUrl').value = savedUrl;
// Unlock audio on any user interaction
function unlockAudio() {
userHasInteracted = true;
// Create and resume AudioContext (unlocks audio for the session)
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
ctx.resume().then(() => { console.log('AudioContext unlocked'); });
// Play a silent buffer to fully unlock
const buf = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(0);
} catch(e) { console.warn('Audio unlock failed:', e); }
// Wall followers must stay muted — leader is the only audio source.
if (isWallFollower()) return;
// Unmute any playing HTML5 video
document.querySelectorAll('video').forEach(v => { v.muted = false; });
// Unmute the active YouTube embed (iframe — querySelectorAll('video') misses it)
try {
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
activeYtPlayer.unMute();
activeYtPlayer.setVolume(100);
}
} catch (e) { console.warn('YT unmute failed:', e); }
}
document.getElementById('connectBtn').onclick = connectBtnFunc;
// ==================== #104 Device-free preview ====================
// #104: device-free dashboard preview. Renders EITHER a draft playlist
// (?playlist=ID — layout DERIVED from the playlist's zones, orientation togglable)
// OR a device exactly as it shows now (?device=ID — layout/orientation from the
// DEVICE row). Both produce the same payload shape and feed the UNMODIFIED renderer.
function bootPreview(qs) {
const playlistId = qs.get('playlist');
const deviceId = qs.get('device');
let url;
if (playlistId) {
const orientation = qs.get('orientation');
const q = orientation ? ('?orientation=' + encodeURIComponent(orientation)) : '';
url = '/api/playlists/' + encodeURIComponent(playlistId) + '/preview-payload' + q;
} else {
// Device preview: the device's own layout/orientation come from the server; no
// orientation override (we show what the device actually shows).
url = '/api/devices/' + encodeURIComponent(deviceId) + '/preview-payload';
}
return renderPreviewFromUrl(url);
}
// Shared: fetch a preview payload (same shape the device socket sends) and hand it
// straight to the UNMODIFIED renderer. No socket, no pairing.
async function renderPreviewFromUrl(url) {
PREVIEW_MODE = true;
config.serverUrl = window.location.origin; // same-origin -> /uploads + /api/widgets resolve
const setup = document.getElementById('setupScreen');
if (setup) setup.style.display = 'none';
try {
const token = localStorage.getItem('token'); // same-origin: shares the dashboard's Bearer token
const res = await fetch(url, { headers: token ? { Authorization: 'Bearer ' + token } : {} });
if (!res.ok) return showPreviewError(res.status);
const payload = await res.json();
// playlist-only: items span >1 layout (rare) — server picked the dominant one.
// Device payloads never carry this flag (layout is device-bound, unambiguous).
if (payload.layout && payload.layout._preview_ambiguous) {
previewBannerParts.layout = 'Previewing dominant layout "' + (payload.layout.name || '—') + '" — items span more than one layout';
}
renderPreviewBanner();
handlePlaylistUpdate(payload);
} catch (e) {
console.error('preview fetch failed', e);
showPreviewError(0);
}
}
// #zone-orphan: one operator-only banner, built from previewBannerParts. Created on
// first use, removed when there's nothing to report. PREVIEW_MODE only — the live
// player/wall must stay clean for the audience.
function renderPreviewBanner() {
if (!PREVIEW_MODE) return;
const lines = [previewBannerParts.layout, previewBannerParts.orphans].filter(Boolean);
let el = document.getElementById('previewBanner');
if (!lines.length) { if (el) el.remove(); return; }
if (!el) {
el = document.createElement('div');
el.id = 'previewBanner';
el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.94);color:#000;font:12px sans-serif;padding:5px 10px;text-align:center;line-height:1.4';
document.body.appendChild(el);
}
el.innerHTML = lines.join('<br>');
}
function showPreviewError(status) {
const msg = (status === 401 || status === 403) ? 'Not authorized to preview this playlist'
: status ? ('Preview failed (' + status + ')') : 'Preview failed to load';
const div = document.createElement('div');
div.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#e5e7eb;background:#111827;font:18px sans-serif;z-index:3000;text-align:center;padding:24px';
div.textContent = msg;
document.body.appendChild(div);
}
// #104: the always-visible honest note for webpage widgets. No auto-detection —
// an XFO-refused frame is provably indistinguishable client-side from a working
// one, so we never guess; we just tell the truth. Preview-only (never on device).
function addWebpageNote(container) {
if (!PREVIEW_MODE || !container) return;
try { if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; } catch (e) {}
const note = document.createElement('div');
note.className = 'preview-webpage-note';
note.textContent = _t('preview_webpage_blocked');
note.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:10;background:rgba(17,24,39,.82);color:#e5e7eb;font:13px/1.4 sans-serif;padding:6px 10px;text-align:center;pointer-events:none';
container.appendChild(note);
}
// ==================== v4 liveness watchdog ====================
// Brings /player onto the LOCKED v4 contract, IDENTICAL on the wire to the APK and .wgt.
// Threshold 45s ± up to 10s jitter (canonical); arm ONLY after a device:heartbeat-ack
// (degrade-safe — an ack-less server never arms us); lastServerMessageAt refreshes on ANY
// inbound (the SILENCE check) while ARMING gates on the ack. Backoff (1s→30s ±20%) is on the
// socket.io Manager (io opts below). NO status/health poll — load is read from ack-silence.
// Browser-specific half-open triggers (visibility/resume/online) drive the SAME check + the
// #148 teardown-first reconnect — additional triggers, not a separate mechanism.
const PLAYER_VERSION = '1.1.0-web';
const V4_THRESHOLD_BASE_MS = 45000, V4_THRESHOLD_JITTER_MS = 10000;
function v4ThresholdMs(rand) { return V4_THRESHOLD_BASE_MS + Math.round((rand - 0.5) * 2 * V4_THRESHOLD_JITTER_MS); }
// Wall-clock (Date.now) so silence COUNTS sleep/background time — the browser half-open causes
// (a setInterval tick alone would be throttled/frozen in a hidden tab and undercount).
let lastServerMessageAt = 0;
let livenessConfirmed = false;
let livenessWindowMs = V4_THRESHOLD_BASE_MS;
let watchdogTimer = null;
function markAlive() { lastServerMessageAt = Date.now(); } // ANY inbound refreshes silence (does NOT arm)
// Pure decision (matches the APK/.wgt watchdogShouldReconnect): reconnect only a connected +
// registered socket whose liveness we've ARMED that has gone silent past the jittered window.
function watchdogShouldReconnect(hasSocket, connected, armed, silentMs, windowMs) {
return !!(hasSocket && connected && armed && silentMs > windowMs);
}
function checkLiveness() {
// THROTTLE-AWARE. (1) Silence is computed by TIMESTAMP (now - lastServerMessageAt), never by
// timer-fire-count, so a background-THROTTLED/late timer still measures the ACTUAL elapsed
// silence — it won't miss a real half-open for firing late, nor false-fire for firing late.
// (2) While the tab is HIDDEN, timers are throttled and the gap is EXPECTED — do NOT act on it
// (never reconnect a backgrounded tab). The hidden->visible transition resets the grace via
// verifyLivenessSoon(), so we don't false-fire on resume either.
if (document.visibilityState !== 'visible') return;
const silentMs = lastServerMessageAt ? (Date.now() - lastServerMessageAt) : 0;
if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), livenessConfirmed, silentMs, livenessWindowMs)) {
console.log('[v4] half-open (silent ' + silentMs + 'ms > ' + livenessWindowMs + ') — teardown+reconnect');
connect(config.serverUrl); // #148 teardown-before-reopen: connect() disconnects the old socket first
}
}
// Fresh liveness check for a resume / bfcache-restore / network-change: silence accumulated
// while hidden or throttled is NOT trustworthy, so NEVER tear down a possibly-live socket on it.
// Reset the grace so the socket gets a fresh window to prove itself — a live socket's engine
// ping/ack refreshes silence within the window (no reconnect); a genuinely dead one stays silent
// past the window and the (now-visible) watchdog reconnects it. #148 teardown-first via connect().
function verifyLivenessSoon() {
lastServerMessageAt = Date.now(); // fresh grace — don't false-fire on stale hidden-silence
if (!socket) connect(config.serverUrl); // never connected / torn down -> establish (teardown-first)
// socket connected -> grace reset above; the watchdog verifies over the next window.
// socket present-but-disconnected -> socket.io's own reconnection already owns it.
}
function startWatchdog() { stopWatchdog(); watchdogTimer = setInterval(checkLiveness, 10000); }
function stopWatchdog() { if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; } }
function browserPlatform() {
try {
const m = navigator.userAgent.match(/(Edg|OPR|Chrome|Firefox|Version)\/(\d+)/);
if (m) { const name = { Edg: 'Edge', OPR: 'Opera', Version: 'Safari' }[m[1]] || m[1]; return name + ' ' + m[2]; }
return 'Browser';
} catch (e) { return 'Browser'; }
}
if (typeof window !== 'undefined') { window.__v4ThresholdMs = v4ThresholdMs; window.__v4WatchdogShouldReconnect = watchdogShouldReconnect; }
// ==================== Socket Connection ====================
function connect(serverUrl) {
if (socket) { socket.disconnect(); socket = null; }
socket = io(serverUrl + '/device', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000, // v4 canonical: 1s start (was 2s)
reconnectionDelayMax: 30000, // v4 canonical: 30s cap, within the ~30-60s band (was 10s)
randomizationFactor: 0.2, // v4 canonical: ±20% jitter (was the socket.io 0.5 default)
timeout: 20000,
// Prefer WebSocket but allow polling fallback. Socket.IO default is
// polling-first with an upgrade dance that's fragile on TV WebKits
// (LG webOS especially). Reversing the order opens a WebSocket directly;
// if that fails (rare - blocked by firewall), it falls back to polling
// on the same connect attempt. Tradeoff: WS-blocked networks add a few
// seconds to first connect while WS times out. Worth it for the common
// case where WS is fine but the upgrade dance was hanging the device.
transports: ['websocket', 'polling'],
});
// v4 liveness: a fresh socket is assumed alive; DIS-arm until a heartbeat-ack re-arms; pick a
// fresh jittered window for this connection. markAlive on ANY inbound (app events via onAny +
// the engine ping) refreshes the SILENCE timer only — it does NOT arm.
lastServerMessageAt = Date.now();
livenessConfirmed = false;
livenessWindowMs = v4ThresholdMs(Math.random());
socket.onAny(markAlive);
socket.io.on('ping', markAlive);
// v4 degrade-safe ARM: the watchdog arms ONLY after the first app-level device:heartbeat-ack.
socket.on('device:heartbeat-ack', (data) => { livenessConfirmed = true; if (data) ingestClockSample(data.server_ms, data.client_ms); });
socket.on('connect', () => {
console.log('Connected');
register();
});
socket.on('disconnect', () => {
console.log('Disconnected');
stopHeartbeat();
stopWatchdog(); // socket.io owns the reconnect once it KNOWS it's down; watchdog is for half-open only
// feat/offline-cause-log: open an offline gap so the next reconnect can report cause.
if (!disconnectedAt) {
disconnectedAt = Date.now();
linkLostDuringGap = (typeof navigator !== 'undefined' && navigator.onLine === false);
}
});
socket.on('connect_error', (err) => {
document.getElementById('setupStatus').textContent = 'Connection failed: ' + err.message;
document.getElementById('setupSpinner').style.display = 'none';
document.getElementById('connectBtn').disabled = false;
});
socket.on('device:registered', (data) => {
config.deviceId = data.device_id;
if (data.device_token) config.deviceToken = data.device_token;
saveConfig(config);
console.log('Registered:', data.device_id);
// feat/offline-cause-log: reconnected after an in-session disconnect -> report the gap length
// + whether the local link dropped. cold_start:false because the page SURVIVED the gap (a
// reboot/reload would have reset disconnectedAt). Browser has no SSID/RSSI to add.
if (disconnectedAt && config.deviceId) {
try {
socket.emit('device:connectivity-report', {
device_id: config.deviceId,
offline_ms: Math.max(0, Date.now() - disconnectedAt),
link_lost: linkLostDuringGap,
cold_start: false,
});
} catch (e) {}
disconnectedAt = 0; linkLostDuringGap = false;
}
if (!config.paired) {
// Show pairing code
document.getElementById('urlForm').style.display = 'none';
document.getElementById('setupSpinner').style.display = 'none';
document.getElementById('pairingSection').style.display = 'block';
document.getElementById('pairingCode').textContent = config.pairingCode || '------';
document.getElementById('setupStatus').textContent = '';
}
startHeartbeat();
startWatchdog(); // v4: arm-gated half-open watchdog (no-op until a heartbeat-ack arms it)
startPlaylistRefresh();
startVersionCheck();
});
socket.on('device:paired', (data) => {
config.paired = true;
config.deviceName = data.name;
saveConfig(config);
console.log('Paired as:', data.name);
document.getElementById('setupScreen').style.display = 'none';
// #146 fix: the server re-emits device:paired on EVERY re-register of an already-
// paired device (deviceSocket.js), i.e. on every reconnect — while content is already
// playing. Showing the idle "Waiting for content..." overlay unconditionally covered
// the live video (audio kept playing underneath) and the subsequent "Playlist
// unchanged" left it up. Only fall to idle when nothing is actually playing.
// Guard the METHOD, not just the object: a device running a stale-cached
// player-media-health.js (older module, no shouldShowIdle) would otherwise throw
// "shouldShowIdle is not a function" here and abort the rest of this handler. The
// !isPlaying fallback is equivalent for the playing case (playing -> not idle).
const PMH = window.PlayerMediaHealth;
const showIdle = (PMH && typeof PMH.shouldShowIdle === 'function')
? PMH.shouldShowIdle({ isPlaying: isPlaying, hasContent: playlist.length > 0 })
: !isPlaying;
if (showIdle) showStatus('Waiting for content...');
});
socket.on('device:unpaired', () => {
console.warn('Device not found on server — clearing credentials');
delete config.deviceId;
delete config.deviceToken;
config.paired = false;
saveConfig(config);
savePlaylistCache([]);
document.getElementById('setupScreen').style.display = 'flex';
document.getElementById('urlForm').style.display = 'block';
document.getElementById('pairingSection').style.display = 'none';
document.getElementById('setupStatus').textContent = 'Device was removed from server. Please reconnect.';
});
socket.on('device:auth-error', (data) => {
console.warn('Device auth rejected:', data?.error || 'unknown');
delete config.deviceId;
delete config.deviceToken;
config.paired = false;
saveConfig(config);
document.getElementById('setupScreen').style.display = 'flex';
document.getElementById('urlForm').style.display = 'block';
document.getElementById('pairingSection').style.display = 'none';
document.getElementById('setupStatus').textContent = 'Authentication failed. Please re-pair this device.';
});
socket.on('device:playlist-update', (data) => {
console.log('Playlist update:', data.assignments?.length, 'items');
handlePlaylistUpdate(data);
});
// Video wall sync (leader broadcasts; followers align)
socket.on('wall:sync', (data) => {
if (!wallConfig || wallConfig.is_leader) return;
if (data.wall_id !== wallConfig.wall_id) return;
lastWallSync = data;
// If leader switched item, jump to it
if (typeof data.current_index === 'number' && data.current_index !== currentIndex && playlist.length > 0) {
currentIndex = ((data.current_index % playlist.length) + playlist.length) % playlist.length;
playCurrentItem();
}
// Hold the follower close to the leader's clock. Account for relay
// latency: the leader was at position_sec when sent_at was stamped;
// by now a bit more time has elapsed, so target = position + latency.
if (currentVideoEl && typeof data.position_sec === 'number') {
const now = Date.now();
const latency = data.sent_at ? Math.max(0, (now - data.sent_at) / 1000) : 0;
const target = data.position_sec + latency;
const drift = (currentVideoEl.currentTime || 0) - target;
const absDrift = Math.abs(drift);
if (absDrift > 0.3 && isFinite(currentVideoEl.duration) && target < currentVideoEl.duration) {
// Big drift: hard seek and reset rate.
try { currentVideoEl.currentTime = target; } catch (_) {}
try { currentVideoEl.playbackRate = 1.0; } catch (_) {}
} else if (absDrift > 0.05) {
// Small drift: nudge playbackRate to converge gently. ±3% is
// imperceptible on most content but pulls in 50ms drift in <2s.
try { currentVideoEl.playbackRate = drift > 0 ? 0.97 : 1.03; } catch (_) {}
} else if (currentVideoEl.playbackRate !== 1.0) {
// In-window: ride at normal rate.
try { currentVideoEl.playbackRate = 1.0; } catch (_) {}
}
}
});
// Leader receives a sync-request from a (re)connecting follower and
// immediately broadcasts its position so the requester can align without
// waiting for the next periodic tick.
socket.on('wall:sync-request', (data) => {
if (!wallConfig?.is_leader) return;
if (data?.wall_id && data.wall_id !== wallConfig.wall_id) return;
console.log('[wall] sync-request received from ' + data?.requested_by + ', broadcasting current position');
emitWallSync();
});
// #group-sync: index+position are computed LOCALLY from the disciplined clock + the shared
// schedule (see groupScheduleTick). There is deliberately NO leader and NO server relay of
// positions — every member derives the same tick from the same clock, so sync survives an
// internet outage and there is no leader to go split-brain. The server only (a) disciplines
// the clock via the heartbeat-ack and (b) can nudge an immediate re-align on demand:
socket.on('group:resync', (data) => {
if (!groupSync) return;
if (data?.group_id && data.group_id !== groupSync.group_id) return;
groupReport('info', 'manual resync requested');
groupScheduleTick(); // recompute + snap to the schedule target right now
});
socket.on('device:screenshot-request', () => { console.log('Screenshot requested'); captureAndSend(); });
socket.on('device:remote-start', () => { console.log('Remote start received'); remoteStreaming = true; startStreaming(); });
socket.on('device:remote-stop', () => { console.log('Remote stop received'); remoteStreaming = false; stopStreaming(); });
socket.on('device:remote-touch', (data) => {
// Simulate click at normalized coordinates within the player
const container = document.getElementById('playerContainer');
if (!container) return;
const x = data.x * container.offsetWidth;
const y = data.y * container.offsetHeight;
const el = document.elementFromPoint(x, y);
if (el) el.click();
console.log('Touch:', data.x, data.y, '-> element:', el?.tagName);
});
socket.on('device:remote-key', (data) => {
console.log('Key:', data.keycode);
const video = document.querySelector('#playerContainer video');
switch (data.keycode) {
case 'KEYCODE_DPAD_RIGHT':
// Skip to next content
nextItem();
break;
case 'KEYCODE_DPAD_LEFT':
// Go to previous content
currentIndex = (currentIndex - 2 + playlist.length) % playlist.length;
nextItem();
break;
case 'KEYCODE_DPAD_CENTER':
case 'KEYCODE_ENTER':
// Toggle play/pause
if (video) { video.paused ? video.play() : video.pause(); }
break;
case 'KEYCODE_VOLUME_UP':
// Wall followers ignore volume changes — they stay silent.
if (video && !isWallFollower()) { video.volume = Math.min(1, video.volume + 0.1); video.muted = false; }
break;
case 'KEYCODE_VOLUME_DOWN':
if (video) { video.volume = Math.max(0, video.volume - 0.1); }
break;
case 'KEYCODE_MENU':
// Toggle mute (followers can't unmute)
if (video && !(isWallFollower() && video.muted)) { video.muted = !video.muted; }
break;
case 'KEYCODE_HOME':
// Go back to first item
currentIndex = -1;
nextItem();
break;
case 'KEYCODE_BACK':
// Show/hide status overlay with device info
const overlay = document.getElementById('infoOverlay');
if (overlay) { overlay.style.display = overlay.style.display === 'none' ? 'flex' : 'none'; }
break;
case 'KEYCODE_POWER':
// Toggle screen (show black overlay)
toggleScreenOff();
break;
}
});
socket.on('device:command', (data) => {
console.log('Command:', data.type);
if (data.type === 'refresh') location.reload();
if (data.type === 'launch') { document.getElementById('screenOffOverlay')?.remove(); }
if (data.type === 'screen_off') toggleScreenOff();
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); }
});
// #129: real-time mute. Apply immediately if the toggled item is the one playing now;
// the value is also persisted in the snapshot so it sticks on the next playlist load.
socket.on('device:mute-changed', (data) => {
const item = playlist[currentIndex];
if (data && item && data.content_id && item.content_id === data.content_id && currentVideoEl) {
try { currentVideoEl.muted = !!data.muted; } catch (_) {}
}
});
// #109: PiP overlay — a pushed floating layer above the playlist. The player
// fetches uri itself (same trust model as remote_url content).
socket.on('device:pip-show', (data) => pipShow(data));
socket.on('device:pip-clear', (data) => pipClear(data && data.pip_id));
}
// ==================== PiP overlay (#109) ====================
// Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
// (id-aware) or timer tears down. Renders into #pipContainer, never the player. Mirrors
// the Tizen PipOverlay (tizen/js/pip-overlay.js). Teardown is wrapped so a malformed
// payload can't wedge the layer.
let pipTimer = null, pipCurrent = null;
const PIP_POS = {
'top-left': { top: '4%', left: '4%' }, 'top-right': { top: '4%', right: '4%' },
'bottom-left': { bottom: '4%', left: '4%' }, 'bottom-right': { bottom: '4%', right: '4%' },
'center': { top: '50%', left: '50%', transform: 'translate(-50%,-50%)' },
};
const pipColor = (c) => (typeof c === 'string' && /^#[0-9A-Fa-f]{6}$/.test(c)) ? c : null;
const pipPx = (v, d) => { const n = Number(v); return (isFinite(n) && n > 0 ? n : d) + 'px'; };
function pipReport(level, msg) {
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'pip', level, message: msg }); } catch (e) {}
}
// Zone diagnostics (orphaned zone_id fallback). Mirrors pipReport: stream to the
// dashboard device-log (tag 'zone') AND the in-page debug overlay buffer.
function zoneReport(level, msg) {
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'zone', level, message: msg }); } catch (e) {}
try { window.__debugLog_push && window.__debugLog_push({ type: 'zone', level: level, msg: msg }); } catch (e) {}
try { console.warn('[zone] ' + msg); } catch (e) {}
}
function pipTeardown() {
try { if (pipTimer) clearTimeout(pipTimer); } catch (e) {}
pipTimer = null; pipCurrent = null;
const c = document.getElementById('pipContainer'); if (c) c.innerHTML = '';
}
function pipShow(p) {
const container = document.getElementById('pipContainer');
if (!p || !container) return;
try {
pipTeardown(); // single slot, last-show-wins
const box = document.createElement('div');
box.className = 'pip-box';
box.style.width = pipPx(p.width, 480);
box.style.height = pipPx(p.height, 360);
box.style.background = pipColor(p.background_color) || '#000000';
if (p.opacity != null && isFinite(Number(p.opacity))) box.style.opacity = String(Math.max(0, Math.min(1, Number(p.opacity))));
if (p.border_radius != null && isFinite(Number(p.border_radius))) box.style.borderRadius = pipPx(p.border_radius, 0);
const pos = PIP_POS[p.position] || PIP_POS['top-right'];
Object.keys(pos).forEach((k) => { box.style[k] = pos[k]; });
const hasTitle = p.title != null && p.title !== '';
if (hasTitle) {
const bar = document.createElement('div');
bar.className = 'pip-title';
bar.textContent = String(p.title);
bar.style.color = pipColor(p.title_color) || '#ffffff';
bar.style.background = 'rgba(0,0,0,0.45)';
box.appendChild(bar);
}
let media;
if (p.type === 'web') {
media = document.createElement('iframe');
media.setAttribute('frameborder', '0');
media.setAttribute('scrolling', 'no');
media.setAttribute('allow', ''); // mute web audio by default (deny autoplay)
media.src = p.uri;
} else {
media = document.createElement('img');
media.src = p.uri;
}
media.style.height = hasTitle ? 'calc(100% - 32px)' : '100%';
media.style.objectFit = 'cover';
box.appendChild(media);
container.appendChild(box);
pipCurrent = p.pip_id || '(anon)';
const dur = Number(p.duration);
if (isFinite(dur) && dur > 0) pipTimer = setTimeout(() => pipClear(pipCurrent), dur * 1000);
pipReport('info', `pip show ${p.type || '?'} ${p.pip_id || ''} pos=${p.position || 'top-right'} dur=${isFinite(dur) ? dur : 0}`);
} catch (e) {
pipTeardown();
pipReport('warn', 'pip show failed: ' + (e && e.message ? e.message : e));
}
}
function pipClear(pipId) {
// A clear carrying a pip_id only clears if it matches the showing overlay.
if (pipId && pipCurrent && pipId !== pipCurrent) return;
const had = !!pipCurrent;
pipTeardown();
if (had) pipReport('info', 'pip cleared' + (pipId ? ' ' + pipId : ''));
}
function register() {
const data = {};
// #163: always send device identity when we have it, regardless of paired
// status. Before this fix, a reconnect before pairing would omit device_id
// and device_token, causing the server's fingerprint reclaim guard to fire
// device:auth-error ("Authentication failed. Please re-pair this device.")
// because the same browser fingerprint looked like a reinstall attack.
if (config.deviceId) {
data.device_id = config.deviceId;
if (config.deviceToken) data.device_token = config.deviceToken;
}
if (!config.paired) {
// Reuse the existing pairing code across reconnects so the operator
// doesn't see a new code every time the socket flaps. Only generate a
// fresh one on the very first registration when no code exists yet.
if (!config.pairingCode) {
const code = String(Math.floor(100000 + Math.random() * 900000));
config.pairingCode = code;
saveConfig(config);
}
data.pairing_code = config.pairingCode;
}
data.device_info = {
android_version: 'Web/' + navigator.userAgent.split(' ').pop(),
app_version: '1.1.0-web',
screen_width: screen.width,
screen_height: screen.height,
};
// v4 client identity block — additive, canonical snake_case (same shape as APK/.wgt so the
// server consumes one thing). Backward-compatible: an old server ignores unknown fields.
data.client_type = 'player';
data.client_version = PLAYER_VERSION;
data.platform = browserPlatform();
data.contract_version = 'v4';
// Browser fingerprint (survives localStorage clear)
data.fingerprint = generateBrowserFingerprint();
console.log(`[register] device_id=${data.device_id || 'none'}, has_token=${!!data.device_token}, token_len=${data.device_token?.length || 0}, paired=${config.paired}, pairing_code=${data.pairing_code || 'none'}`);
socket.emit('device:register', data);
}
// ==================== Heartbeat ====================
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (!socket?.connected || !config.deviceId) return;
socket.emit('device:heartbeat', {
device_id: config.deviceId,
client_ms: Date.now(), // #group-sync: t1 for NTP-style clock discipline (echoed in the ack)
telemetry: {
battery_level: null,
battery_charging: false,
storage_free_mb: null,
storage_total_mb: null,
ram_free_mb: null,
ram_total_mb: null,
cpu_usage: null,
wifi_ssid: 'Web Player',
wifi_rssi: null,
uptime_seconds: Math.floor(performance.now() / 1000),
// #74/#75: report OS timezone + UTC clock (effective-tz resolution + skew indicator)
timezone: (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { return null; } })(),
device_utc: Date.now(),
}
});
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
function startPlaylistRefresh() {
// No longer needed — server pushes playlist updates instantly via WebSocket.
// Kept as a fallback with a long interval in case a push is missed.
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
if (socket?.connected && config.deviceId && config.paired) {
const data = { device_id: config.deviceId, device_info: {} };
if (config.deviceToken) data.device_token = config.deviceToken;
console.log(`[refresh-register] device_id=${config.deviceId}, has_token=${!!config.deviceToken}`);
socket.emit('device:register', data);
}
}, 300000); // 5 minutes fallback
}
// ==================== Auto-reload on code update ====================
let knownServerHash = null;
let versionCheckTimer = null;
function startVersionCheck() {
if (versionCheckTimer) clearInterval(versionCheckTimer);
// Initial fetch to learn current hash
fetch(config.serverUrl + '/api/version').then(r => r.json()).then(data => {
knownServerHash = data.hash;
console.log('Server version:', data.version, 'hash:', data.hash);
}).catch(() => {});
// Poll every 30s
versionCheckTimer = setInterval(() => {
fetch(config.serverUrl + '/api/version').then(r => r.json()).then(data => {
if (knownServerHash && data.hash !== knownServerHash) {
console.log('Server code updated, reloading...', knownServerHash, '->', data.hash);
location.reload();
}
}).catch(() => {});
}, 30000);
}
// ==================== Video Wall ====================
// Convert a wall_config payload into CSS that sizes & positions the wall
// stage so this device's tile is the visible portion. Each tile is
// 100vw × 100vh; the stage is the full grid, translated by this tile's
// grid position (plus bezel offsets in px between tiles).
// #200: keep advanceTimer consistent with the current playback mode. A schedule-driven device (wall
// FOLLOWER or group member) runs NO local advance timer — its tick drives the index, and a stale solo
// timer would spuriously advance and desync (zombie timer, Bug B). A solo player or wall LEADER
// advances locally, so on the way OUT of a schedule-driven mode a surviving image/widget item (which
// never armed a timer while driven) would freeze (Bug C) — re-render it (buffered, no flash) to re-arm.
// Video/YouTube self-advance via their own end handlers and re-rendering would restart them, so leave
// those alone. Called on every mode enter/exit (applyWallMode/applyGroupSync), which is the single
// chokepoint every transition path routes through.
function reconcileAdvanceTimerForMode() {
if (isWallFollower() || groupSync) {
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
return;
}
if (!isPlaying) return;
const item = playlist[currentIndex];
if (!item || advanceTimer) return; // solo/leader that already has its timer armed -> nothing to do
const needsSoloTimer = !!item.widget_id || (typeof item.mime_type === 'string' && item.mime_type.startsWith('image/'));
if (needsSoloTimer) playCurrentItem(); // re-render buffered + re-arm the solo advance timer
}
function applyWallMode(config) {
const container = document.getElementById('playerContainer');
// Tear down previous wall mode (clear sync timer regardless of new state)
if (wallSyncTimer) { clearInterval(wallSyncTimer); wallSyncTimer = null; }
lastWallSync = null;
if (!config) {
wallConfig = null;
container.classList.remove('wall-mode');
console.log('[wall] exited wall mode');
reconcileAdvanceTimerForMode(); // #200: back to solo -> re-arm a surviving image/widget's timer
return;
}
wallConfig = config;
container.classList.add('wall-mode');
console.log('[wall] applyWallMode wall=' + config.wall_id + ' is_leader=' + config.is_leader + ' userHasInteracted=' + userHasInteracted);
// Enforce the audio rule on the currently-mounted video right now.
// If the role flipped (e.g., leader was reassigned mid-stream), the
// existing video element keeps its old muted state until we touch it.
if (currentVideoEl) {
if (config.is_leader) {
// Defer to autoplay policy — leader can be unmuted once the user
// has gestured. Don't yank audio if it's already playing.
} else {
if (!currentVideoEl.muted) currentVideoEl.muted = true;
}
}
if (config.is_leader) {
// Leader emits at 4Hz so followers can apply small playbackRate
// corrections instead of jerk-seeking. Higher rates would saturate
// the relay; 4Hz balances tightness against server load.
wallSyncTimer = setInterval(emitWallSync, 250);
// Immediate broadcast so any follower that's already up aligns now,
// without waiting up to 250ms for the first scheduled tick. This
// also covers a leader reclaiming the role after a reconnect.
setTimeout(emitWallSync, 100);
} else {
// Follower: ask the leader for its current position. Without this,
// the screen shows the start of the current item until the leader's
// next periodic tick (up to ~1s of visible drift on a fresh join).
if (socket?.connected) {
console.log('[wall] follower emitting sync-request for wall ' + config.wall_id);
socket.emit('wall:sync-request', { wall_id: config.wall_id });
}
}
reconcileAdvanceTimerForMode(); // #200: follower -> kill any stale solo timer; leader keeps its own
}
function emitWallSync() {
if (!wallConfig?.is_leader || !socket?.connected || playlist.length === 0) return;
const item = playlist[currentIndex];
if (!item) return;
const position = currentVideoEl
? (currentVideoEl.currentTime || 0)
: Math.max(0, (Date.now() - currentItemStartedAt) / 1000);
socket.emit('wall:sync', {
wall_id: wallConfig.wall_id,
device_id: config.deviceId,
current_index: currentIndex,
content_id: item.content_id || null,
position_sec: position,
sent_at: Date.now(),
});
}
// #group-sync schedule engine. The shared, deterministic playlist schedule (each item occupies
// duration_sec, in order, skipping dayparted-out items) is laid on the synced clock: phase =
// syncedNow mod totalPeriod. Every same-playlist member computes the identical (index, position)
// — no leader, no relay, no split-brain — and it keeps running with no server at all.
function groupScheduleSlots() {
const slots = []; let acc = 0;
for (let i = 0; i < playlist.length; i++) {
if (!scheduleAllows(playlist[i])) continue; // same daypart filter as solo playback
const dur = Math.max(1, Number(playlist[i].duration_sec) || 10) * 1000;
slots.push({ index: i, start: acc, dur }); acc += dur;
}
return { slots, period: acc };
}
function groupScheduleTarget() {
const { slots, period } = groupScheduleSlots();
if (!slots.length || period <= 0) return null;
const phase = ((syncedNow() % period) + period) % period;
let ci = slots.findIndex(x => phase >= x.start && phase < x.start + x.dur);
if (ci < 0) ci = slots.length - 1;
const s = slots[ci];
const next = slots[(ci + 1) % slots.length];
// nextIndex + secToBoundary drive the double buffer (preload the upcoming clip a few s early).
return { index: s.index, posSec: (phase - s.start) / 1000, nextIndex: next.index, secToBoundary: (s.start + s.dur - phase) / 1000 };
}
// Runs at 4Hz while in a group: snap the index to the schedule, and for video correct drift with
// the same seek/nudge maths the wall uses — but toward the SCHEDULE target, not a leader broadcast.
function groupScheduleTick() {
if (!groupSync || playlist.length === 0) return;
const t = groupScheduleTarget();
if (!t) return;
// Double buffer: warm the next clip ~6s before the boundary (once per boundary).
if (t.nextIndex !== t.index && t.secToBoundary >= 0 && t.secToBoundary <= 6 && groupPreloadIdx !== t.nextIndex) {
groupPreloadNext(t.nextIndex);
}
let action = 'hold';
if (t.index !== currentIndex) {
currentIndex = t.index;
playCurrentItem();
action = 'jump>' + t.index;
} else if (currentVideoEl && isFinite(currentVideoEl.duration) && currentVideoEl.duration > 0) {
const dur = currentVideoEl.duration;
const target = t.posSec % dur; // loop-safe when duration_sec > clip length
const drift = (currentVideoEl.currentTime || 0) - target;
const ad = Math.abs(drift);
if (currentIndex !== groupLastAlignedIndex) groupAlignPending = true;
if (groupAlignPending) {
if (ad > 0.05) { try { currentVideoEl.currentTime = target; } catch (e) {} groupLastSeekAt = Date.now(); }
try { currentVideoEl.playbackRate = 1.0; } catch (e) {}
groupAlignPending = false; groupLastAlignedIndex = currentIndex;
action = 'align ' + drift.toFixed(2);
}
else if (ad > 0.3 && Date.now() - groupLastSeekAt > 1200) { try { currentVideoEl.currentTime = target; } catch (e) {} try { currentVideoEl.playbackRate = 1.0; } catch (e) {} groupLastSeekAt = Date.now(); action = 'seek ' + drift.toFixed(2); }
else if (ad > 0.05) { try { currentVideoEl.playbackRate = drift > 0 ? 0.97 : 1.03; } catch (e) {} action = 'nudge ' + drift.toFixed(2); }
else if (currentVideoEl.playbackRate !== 1.0) { try { currentVideoEl.playbackRate = 1.0; } catch (e) {} }
}
// Log discrete corrections (jump/align/seek) immediately so the transition is visible; only the
// routine steady-state line (hold/nudge) is throttled — else the one-tick "align" on load is
// sampled over by a later "hold"/"nudge" and reads misleadingly.
const now = Date.now();
const discrete = action.indexOf('jump') === 0 || action.indexOf('align') === 0 || action.indexOf('seek') === 0;
if (discrete || now - groupDbgLast > 1000) {
groupDbgLast = now;
groupReport('info', 'idx=' + currentIndex + ' tgt=' + t.index + ' pos=' + t.posSec.toFixed(2) + ' off=' + clockOffsetMs + 'ms rtt=' + clockRttMs + 'ms ' + action);
}
}
// Double buffer: build a hidden, buffering <video> for the next clip so renderContent can mount it
// instantly at the boundary (no black hold). Only for videos; images/widgets/youtube skip it.
function groupPreloadNext(idx) {
const item = playlist[idx];
if (!item) return;
const isVid = item.mime_type && item.mime_type.indexOf('video/') === 0 && item.mime_type !== 'video/youtube';
if (!isVid) { groupPreloadIdx = idx; groupPreloadEl = null; return; } // mark handled, nothing to warm
const url = item.remote_url || (config.serverUrl + '/uploads/content/' + item.filepath);
try {
if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} }
const v = document.createElement('video');
v.src = url; v.muted = true; v.playsInline = true; v.preload = 'auto';
v.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;left:-9999px';
v.load();
document.body.appendChild(v);
groupPreloadEl = v; groupPreloadIdx = idx;
groupReport('info', 'preload>' + idx);
} catch (e) { groupPreloadEl = null; groupPreloadIdx = -1; }
}
// Hand off the preloaded element for `idx` (or null). Caller re-parents + plays it; src is already
// set and buffered, so playback starts near-instantly with no reload.
function takeGroupPreload(idx) {
if (groupPreloadIdx === idx && groupPreloadEl) { const el = groupPreloadEl; groupPreloadEl = null; groupPreloadIdx = -1; return el; }
return null;
}
// Enter/leave group sync. No CSS transform, no forced mute (per-item mute honored), no leader —
// just start the schedule tick. Idempotent across refreshes/role churn (there is no role now).
function applyGroupSync(cfg) {
if (groupSyncTimer) { clearInterval(groupSyncTimer); groupSyncTimer = null; }
try { if (cfg) localStorage.setItem('st_group_sync', JSON.stringify(cfg)); else localStorage.removeItem('st_group_sync'); } catch (e) {}
if (!cfg) {
if (groupSync) groupReport('info', 'group-sync exited'); groupSync = null; console.log('[group-sync] exited');
if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} groupPreloadEl = null; groupPreloadIdx = -1; }
reconcileAdvanceTimerForMode(); // #200: back to solo -> re-arm a surviving image/widget's timer
return;
}
const first = !groupSync;
groupSync = cfg;
groupAlignPending = true; groupLastAlignedIndex = -1; // snap the first item into sync on entry
console.log('[group-sync] group=' + cfg.group_id + ' (clock/schedule, offset=' + clockOffsetMs + 'ms)');
groupReport('info', 'group-sync ' + (first ? 'entered' : 'refresh') + ' group=' + String(cfg.group_id).slice(0, 8) + ' off=' + clockOffsetMs + 'ms');
groupScheduleTick(); // align immediately
groupSyncTimer = setInterval(groupScheduleTick, 250); // 4Hz local correction
reconcileAdvanceTimerForMode(); // #200: group member runs no solo timer -> kill any zombie
}
// Map the player rect into this device's viewport using vw/vh so the
// viewport fills edge-to-edge (no pillarbox at the seam between adjacent
// screens). With object-fit:fill on the video, the source stretches to
// the stage — which keeps the vertical position of every source pixel
// identical across devices that share a viewport height (1vh maps to
// the same physical pixel on each).
function styleWallStage(stageEl) {
if (!wallConfig?.screen_rect || !wallConfig?.player_rect) return;
const s = wallConfig.screen_rect;
const p = wallConfig.player_rect;
if (!s.w || !s.h) return;
const left = ((p.x - s.x) / s.w) * 100;
const top = ((p.y - s.y) / s.h) * 100;
const width = (p.w / s.w) * 100;
const height = (p.h / s.h) * 100;
const dev = (config.deviceId || '?').slice(0, 8);
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p));
console.log('[wall/render ' + dev + '] viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' DPR=' + window.devicePixelRatio);
console.log('[wall/render ' + dev + '] stage: left=' + left.toFixed(4) + 'vw top=' + top.toFixed(4) + 'vh width=' + width.toFixed(4) + 'vw height=' + height.toFixed(4) + 'vh');
stageEl.style.left = left + 'vw';
stageEl.style.top = top + 'vh';
stageEl.style.width = width + 'vw';
stageEl.style.height = height + 'vh';
stageEl.style.transform = '';
}
// No-op kept for callers that bind a resize listener (kept around in case
// future zoom/orientation tweaks need it). vw/vh stage updates with the
// viewport automatically, so explicit re-style isn't needed today.
function bindWallResizeOnce() {}
// ==================== Playlist ====================
function handlePlaylistUpdate(data) {
// Check if device is suspended (trial expired / over limit)
if (data.suspended) {
isPlaying = false;
playlist = [];
document.getElementById('playerContainer').style.display = 'none';
const overlay = document.getElementById('statusOverlay');
overlay.style.display = 'flex';
overlay.innerHTML = `
<div style="text-align:center;max-width:500px">
<div style="font-size:64px;margin-bottom:16px">&#9888;</div>
<h2 style="color:#f59e0b;margin-bottom:8px">${data.message || 'Account Suspended'}</h2>
<p style="color:#94a3b8;font-size:16px;margin-bottom:24px">${data.detail || 'Please upgrade your plan.'}</p>
<p style="color:#64748b;font-size:13px">Visit your dashboard to manage your subscription</p>
</div>
`;
return;
}
const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change.
// #74/#75: include schedules so a schedule edit (same content) is detected too.
// transition-engine: include the per-item transition too, or a transition config change (effects,
// duration, scope, or presence) keeps the SAME fingerprint -> "unchanged" -> the player keeps a
// stale cached playlist and never applies the new transitions. This bug hid every transition edit.
// STRUCTURAL fingerprint only (identity + order + schedules + transition). duration_sec is
// deliberately EXCLUDED so a duration-only edit is applied IN PLACE (not a full change/restart).
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(',');
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
// Apply orientation. #109: the PiP layer gets the SAME transform as the player so a
// corner overlay tracks the visible content (not the physical panel) in every orientation.
if (data.orientation) {
const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' };
const portrait = data.orientation.includes('portrait');
[document.getElementById('playerContainer'), document.getElementById('pipContainer')].forEach((el) => {
if (!el) return;
el.style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
if (portrait) {
el.style.transformOrigin = 'center center';
el.style.width = '100vh';
el.style.height = '100vw';
} else {
el.style.width = '';
el.style.height = '';
}
});
}
// Apply (or clear) wall mode. Force re-render when wall config changes
// even if the playlist itself didn't, so leader/follower role transitions
// and tile reassignments take effect immediately.
function wallKey(c) {
if (!c) return '';
const s = c.screen_rect || {}, p = c.player_rect || {};
return `${c.wall_id}:${c.is_leader}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
}
const wallChanged = wallKey(wallConfig) !== wallKey(data.wall_config);
if (wallChanged) applyWallMode(data.wall_config || null);
// A fresh playlist-update on a follower (typical after socket reconnect)
// is a good signal to ask the leader for its current position even when
// the wall config itself didn't change. Cheap, debounced server-side.
if (!wallChanged && wallConfig && !wallConfig.is_leader && socket?.connected) {
socket.emit('wall:sync-request', { wall_id: wallConfig.wall_id });
}
// #group-sync: enter/leave on group membership change (mutually exclusive with wall — the
// server sends group_sync=null for a wall member). There's no leader role, so the key is just
// the group id; a plain refresh re-aligns the schedule locally (no server round-trip needed).
const groupKey = (g) => (g ? String(g.group_id) : '');
const groupChanged = groupKey(groupSync) !== groupKey(data.group_sync);
if (groupChanged) applyGroupSync(data.group_sync || null);
else if (groupSync) groupScheduleTick();
layout = data.layout || null;
saveLayoutCache(layout);
playerTimezone = data.timezone || null; // #74/#75: effective tz for schedule eval
if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
console.log('Playlist unchanged');
// In-place duration refresh: a duration-only edit keeps the structural fingerprint identical,
// so patch duration_sec onto the live items here. The group schedule tick re-anchors on the
// new period next tick; solo advance uses it on the next item. No restart, no reload.
for (let i = 0; i < playlist.length && i < newItems.length; i++) {
if (playlist[i].duration_sec !== newItems[i].duration_sec) playlist[i].duration_sec = newItems[i].duration_sec;
}
// #146 fix: a no-change refresh used to blindly return — so if the <video> surface
// had been lost (detached from the DOM while its element kept decoding audio: video
// gone, audio still playing), the re-attach (which lives ONLY in the content-changed
// branch below) never ran. Re-render the CURRENT item, but ONLY when the surface is
// actually unhealthy, so a healthy poll stays a no-op (no flicker on every refresh).
try {
const item = playlist[currentIndex];
const kind = !item ? 'other'
: item.widget_id ? 'widget'
: item.mime_type === 'video/youtube' ? 'youtube'
: (typeof item.mime_type === 'string' && item.mime_type.startsWith('video/')) ? 'video'
: (typeof item.mime_type === 'string' && item.mime_type.startsWith('image/')) ? 'image' : 'other';
const container = document.getElementById('playerContainer');
const state = {
isPlaying: isPlaying,
hasCurrentItem: !!item,
itemKind: kind,
videoEl: currentVideoEl
? { attached: document.contains(currentVideoEl), ended: !!currentVideoEl.ended, errored: !!currentVideoEl.error }
: null,
surfaceAttached: !!(container && container.querySelector('video,img,iframe,.wall-stage')),
};
if (window.PlayerMediaHealth && typeof PlayerMediaHealth.needsReattach === 'function' && PlayerMediaHealth.needsReattach(state)) {
console.log('[refresh] media surface lost on no-change refresh — re-attaching current item');
playCurrentItem();
} else if (isPlaying) {
// #146 fix: unchanged + already playing must LEAVE playback exactly as-is. Clear
// any stale idle overlay (e.g. the one a reconnect's device:paired put up) so the
// "unchanged" confirmation never leaves "Waiting for content..." over live content.
hideStatus();
}
} catch (e) { /* never let the health check break a refresh */ }
return;
}
console.log('Playlist changed, updating');
// Capture old state BEFORE mutating so continuity logic can find what was playing.
const identityOf = itemIdentity;
const oldPlaylist = playlist;
const oldAnchorIdx = currentIndex;
const oldAnchorId = identityOf(oldPlaylist[oldAnchorIdx]);
playlist = newItems;
imgPreloadCache = {}; // playlist changed — drop stale one-ahead preloads (feat/player-image-preload)
savePlaylistCache(playlist);
// #157: a fresh structural update supersedes any pending deferred rotation; the branches
// below re-arm it only if the current item was removed while live in solo playback.
deferredRotation = false;
deferredSuccessorId = null;
if (playlist.length === 0) {
teardownCurrentMedia();
showStatus('Waiting for content...');
isPlaying = false;
return;
}
document.getElementById('setupScreen').style.display = 'none';
// Continuity: if the playing item survives the update, keep playing it.
// Just retarget the index pointer - no re-render, no interrupt. It will
// advance naturally via onended -> nextItem.
if (oldAnchorId && oldAnchorId !== '|||') {
const stillThereIdx = playlist.findIndex(x => identityOf(x) === oldAnchorId);
if (stillThereIdx !== -1) {
currentIndex = stillThereIdx;
isPlaying = true;
return;
}
}
// Anchor is gone. Walk forward from the OLD position through the old playlist,
// pick the first item that still exists in the new one. Preserves "what was
// scheduled to play next, that still exists". Wraps past the end naturally.
let nextIdx = -1;
if (oldPlaylist.length > 0 && Number.isFinite(oldAnchorIdx)) {
for (let i = 1; i <= oldPlaylist.length; i++) {
const probe = oldPlaylist[(oldAnchorIdx + i) % oldPlaylist.length];
const probeId = identityOf(probe);
if (!probeId || probeId === '|||') continue;
const found = playlist.findIndex(x => identityOf(x) === probeId);
if (found !== -1) { nextIdx = found; break; }
}
}
if (nextIdx === -1) nextIdx = 0;
// #157: if the removed item is still live on screen in SOLO playback, don't interrupt it —
// keep it up and rotate to the successor on the next natural advance (its advanceTimer /
// video onended still fires nextItem). Schedule-driven modes (wall follower / group-sync)
// advance via their own tick, so they reconcile immediately as before.
const scheduleDriven = isWallFollower() || !!groupSync;
if (isPlaying && !scheduleDriven) {
deferredRotation = true;
deferredSuccessorId = itemIdentity(playlist[nextIdx]);
console.log('#157: current item removed but still live — deferring rotation-out');
return;
}
startPlaybackAt(nextIdx);
}
// #74/#75: per-item schedule gate. No blocks = always on. Evaluated in the
// device's effective timezone via the shared evaluator. Never let a scheduling
// hiccup stop playback.
function scheduleAllows(item) {
if (!item || !item.schedules || !item.schedules.length) return true;
try { return window.ScheduleEval ? ScheduleEval.isItemActiveNow(item.schedules, Date.now(), playerTimezone) : true; }
catch (e) { return true; }
}
function nextActiveIndex(from) {
if (!playlist.length) return -1;
for (let i = 1; i <= playlist.length; i++) {
const idx = (from + i) % playlist.length;
if (scheduleAllows(playlist[idx])) return idx;
}
return -1;
}
// Every item filtered out: show the idle screen and re-check shortly (a daypart
// may begin). Re-evaluated at item boundaries otherwise, per the locked design.
function showNothingScheduled() {
teardownCurrentMedia();
showStatus(_t('nothing_scheduled'));
isPlaying = false;
clearTimeout(scheduleRetryTimer);
scheduleRetryTimer = setTimeout(() => {
const idx = nextActiveIndex(currentIndex);
if (idx !== -1) { currentIndex = idx; isPlaying = true; playCurrentItem(); }
else showNothingScheduled();
}, 30000);
}
function startPlaybackAt(idx) {
clearTimeout(scheduleRetryTimer);
if (scheduleAllows(playlist[idx])) { currentIndex = idx; isPlaying = true; playCurrentItem(); return; }
const a = nextActiveIndex(idx);
if (a !== -1) { currentIndex = a; isPlaying = true; playCurrentItem(); }
else { currentIndex = idx; showNothingScheduled(); }
}
function playCurrentItem() {
if (!playlist.length || !Number.isFinite(currentIndex)) {
teardownCurrentMedia();
showStatus('Waiting for content...');
isPlaying = false;
return;
}
if (currentIndex < 0 || currentIndex >= playlist.length) currentIndex = 0;
hideStatus();
const item = playlist[currentIndex];
console.log('Playing:', item.filename, `(${currentIndex + 1}/${playlist.length})`);
currentItemStartedAt = Date.now();
// Only the leader (or single, non-walled players) records a play_start —
// followers would just spam duplicate proof-of-play rows for the same item.
if (socket?.connected && (!wallConfig || wallConfig.is_leader)) {
socket.emit('device:play-event', {
device_id: config.deviceId,
event: 'play_start',
content_id: item.content_id,
content_name: item.filename,
duration_sec: item.duration_sec || null,
});
}
renderContent(item);
// Push an immediate sync so followers don't have to wait up to 1s for
// the next periodic tick before snapping to the new item.
if (wallConfig?.is_leader) emitWallSync();
// (group members need no emit — the schedule tick drives them locally)
}
function nextItem() {
// #157: apply a deferred rotation-out — the removed-but-live item has finished, so rotate
// into the (already-swapped) list at the preserved successor instead of interrupting.
if (deferredRotation) {
deferredRotation = false;
const sid = deferredSuccessorId; deferredSuccessorId = null;
let idx = sid ? playlist.findIndex(x => itemIdentity(x) === sid) : -1;
if (idx < 0) idx = 0;
startPlaybackAt(idx);
return;
}
// Send play_end for current
if (playlist[currentIndex] && socket?.connected) {
socket.emit('device:play-event', {
device_id: config.deviceId,
event: 'play_end',
content_id: playlist[currentIndex].content_id,
content_name: playlist[currentIndex].filename,
completed: true,
});
}
// #74/#75: advance to the next item whose schedule allows it now (skip
// filtered items); idle if none are active.
const idx = nextActiveIndex(currentIndex);
if (idx === -1) { showNothingScheduled(); return; }
currentIndex = idx;
playCurrentItem();
}
// ==================== Content Rendering ====================
// Extract YouTube video ID from embed URL
function extractVideoId(url) {
try {
const m = url.match(/\/embed\/([a-zA-Z0-9_-]{11})/);
if (m) return m[1];
const u = new URL(url);
return u.searchParams.get('v') || url.match(/([a-zA-Z0-9_-]{11})/)?.[1] || null;
} catch { return null; }
}
// Load YouTube IFrame API once. (ytApiReady / ytApiCallbacks are declared at the
// top of the script alongside the other player state.)
function loadYoutubeApi(cb) {
if (ytApiReady) { cb(); return; }
ytApiCallbacks.push(cb);
if (!document.getElementById('yt-api-script')) {
const tag = document.createElement('script');
tag.id = 'yt-api-script';
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
window.onYouTubeIframeAPIReady = () => {
ytApiReady = true;
ytApiCallbacks.forEach(fn => fn());
ytApiCallbacks = [];
};
}
}
function createYoutubeEmbed(src, item, container) {
const videoId = extractVideoId(src);
if (!videoId) {
console.error('Could not extract YouTube video ID from:', src);
if (playlist.length > 1) setTimeout(nextItem, 2000);
return null;
}
// Invalidate any previous player's callbacks
const myGeneration = ++ytGeneration;
// Destroy old player without triggering side effects (callbacks check generation)
if (ytSafetyNet) { clearTimeout(ytSafetyNet); ytSafetyNet = null; }
if (activeYtPlayer) { try { activeYtPlayer.destroy(); } catch {} activeYtPlayer = null; }
// Vertical (Shorts) content is tagged st_aspect=vertical at ingest. Render it
// in a centered 9:16 box so it fills a portrait screen and pillarboxes cleanly
// on landscape, instead of the standard 100%x100% landscape frame.
const isVertical = /st_aspect=vertical/i.test(src || '') || /st_aspect=vertical/i.test((item && item.remote_url) || '');
// Create a div for the YT player to replace
const playerDiv = document.createElement('div');
playerDiv.id = 'yt-player-' + Date.now();
playerDiv.style.cssText = 'width:100%;height:100%;background:#000';
let mountEl = playerDiv;
if (isVertical) {
const wrap = document.createElement('div');
wrap.style.cssText = 'width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center';
const inner = document.createElement('div');
inner.style.cssText = 'height:100%;aspect-ratio:9/16;max-width:100%';
inner.appendChild(playerDiv);
wrap.appendChild(inner);
mountEl = wrap;
}
container.appendChild(mountEl);
// Add a click-to-unmute overlay on top of the YouTube iframe
if (!userHasInteracted) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;inset:0;z-index:10;cursor:pointer;display:flex;align-items:end;justify-content:center;padding-bottom:40px;';
overlay.innerHTML = '<div style="background:rgba(0,0,0,0.7);color:#fff;padding:10px 24px;border-radius:8px;font:14px sans-serif;pointer-events:none">Click to unmute</div>';
overlay.onclick = (e) => {
e.stopPropagation();
userHasInteracted = true;
unlockAudio();
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
activeYtPlayer.unMute();
activeYtPlayer.setVolume(100);
console.log('Unmuted YouTube player via overlay');
}
overlay.remove();
};
// Don't override container.style.position here — #playerContainer is already
// position:fixed so absolute children anchor to it. Setting position:relative
// collapsed the container to 0 height (no content sizing it in normal flow),
// which made the YT iframe render black.
container.appendChild(overlay);
}
loadYoutubeApi(() => {
// Bail if a newer player was created while we waited for the API
if (myGeneration !== ytGeneration) return;
const shouldLoop = playlist.length <= 1;
let playStartTime = 0;
activeYtPlayer = new YT.Player(playerDiv.id, {
videoId: videoId,
width: '100%',
height: '100%',
playerVars: {
autoplay: 1,
mute: userHasInteracted ? 0 : 1,
controls: 0,
rel: 0,
modestbranding: 1,
loop: shouldLoop ? 1 : 0,
playlist: shouldLoop ? videoId : undefined,
enablejsapi: 1,
origin: window.location.origin,
// #217: cap quality at 720p for items flagged "unstable connection" so weak
// WiFi doesn't stall on an auto-selected 1080p+ stream. vq is a hint the player
// may still override, so we also call setPlaybackQuality in onReady below.
vq: item.unstable_connection ? 'hd720' : undefined,
},
events: {
onReady: (event) => {
if (myGeneration !== ytGeneration) return;
console.log('YouTube player ready:', item.filename);
event.target.playVideo();
if (userHasInteracted) {
event.target.unMute();
event.target.setVolume(100);
}
// #215 safety net: if ENDED never fires (Shorts, flaky Android TV
// WebViews), advance from the reported duration + 3s slack. Cleared
// on a real ENDED, on error, or on teardown. Skip when looping (a
// single-item playlist intentionally loops forever).
if (!shouldLoop) {
let duration = 0;
try { duration = event.target.getDuration(); } catch {}
if (duration > 0) {
clearTimeout(ytSafetyNet);
ytSafetyNet = setTimeout(() => {
if (myGeneration !== ytGeneration) return;
console.log('YouTube safety net fired for:', item.filename);
nextItem();
}, (duration + 3) * 1000);
}
}
// #217: best-effort quality cap for weak WiFi. setPlaybackQuality is a
// hint (YouTube may still adapt), but combined with the vq playerVar it
// biases the initial selection toward 720p instead of 1080p+.
if (item.unstable_connection) {
try { event.target.setPlaybackQuality('hd720'); } catch {}
}
// #216: turn on YouTube captions when the content is flagged. loadModule
// makes the CC module available, then setOption selects the language track.
// Both are best-effort (undocumented, version-dependent) — wrapped so a
// throw can never break playback.
if (item.captions_enabled) {
try { event.target.loadModule('captions'); } catch {}
try { event.target.setOption('captions', 'track', { languageCode: item.captions_lang || 'en' }); } catch {}
}
},
onError: (event) => {
if (myGeneration !== ytGeneration) return;
clearTimeout(ytSafetyNet); ytSafetyNet = null;
console.error('YouTube error', event.data, 'for:', item.filename);
if (playlist.length > 1) {
console.log('Skipping unplayable YouTube video');
setTimeout(nextItem, 2000);
}
},
onStateChange: (event) => {
if (myGeneration !== ytGeneration) return;
// Track when video actually starts playing
if (event.data === 1) playStartTime = Date.now();
// YT.PlayerState.ENDED = 0 — advance to next video
// Ignore ENDED if video played for less than 3 seconds (spurious during init)
if (event.data === 0 && !shouldLoop && (Date.now() - playStartTime) > 3000) {
clearTimeout(ytSafetyNet); ytSafetyNet = null;
console.log('YouTube video ended:', item.filename);
nextItem();
}
},
},
});
});
// Note: YouTube advancement is handled by onStateChange ENDED event.
// Do NOT use duration_sec timeout here — it defaults to 10s for assignments
// and would cut videos short. The YouTube player tells us when it's done.
return playerDiv;
}
// Stop and release all media in the player. pause() alone leaves the decoder
// buffering on some browsers; removeAttribute('src') + load() is what actually
// releases the decoder and kills audio. Null event handlers so a late onended
// can't fire into a stale playlist state. Queries all <video> elements so
// zone-mode (multi-region) videos get cleaned up too, not just currentVideoEl.
function clearZoneTimers() {
for (const k in zoneTimers) clearTimeout(zoneTimers[k]);
zoneTimers = {};
}
function teardownCurrentMedia(keep) {
// On a buffered widget reveal (keep set) the widget's advance/refresh timer was just
// armed by renderContent and must survive; only a real teardown cancels it.
if (!keep && advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
// #215: a stale YouTube safety-net timer must never fire into a torn-down/rotated
// playlist and force a spurious advance.
if (ytSafetyNet) { clearTimeout(ytSafetyNet); ytSafetyNet = null; }
// Cancel any in-flight buffered widget swap so its deferred reveal can't fire after
// we've torn down (which would drop the incoming content). reveal() nulls this before
// calling teardownCurrentMedia(iframe), so preserving `keep` is safe.
if (pendingWidgetSwap && pendingWidgetSwap.iframe !== keep) {
clearTimeout(pendingWidgetSwap.timer); pendingWidgetSwap = null;
}
clearZoneTimers();
const container = document.getElementById('playerContainer');
if (container) {
container.querySelectorAll('video').forEach(v => {
try {
v.onended = null; v.onerror = null; v.onloadeddata = null;
v.pause();
v.removeAttribute('src');
v.load();
} catch (e) { /* element may already be detached */ }
});
if (keep) {
// Buffered widget swap: drop the outgoing content but keep the freshly-loaded
// iframe we're swapping in.
Array.from(container.children).forEach(ch => { if (ch !== keep) { try { ch.remove(); } catch (e) {} } });
} else {
container.innerHTML = '';
}
}
// #146 fix: also release currentVideoEl even if it was DETACHED from the container —
// a detached-but-playing <video> keeps emitting audio and the querySelectorAll above
// (scoped to the container) can't find it. This is what kills the "ghost audio".
if (currentVideoEl) {
try {
currentVideoEl.onended = null; currentVideoEl.onerror = null; currentVideoEl.onloadeddata = null;
currentVideoEl.pause();
currentVideoEl.removeAttribute('src');
currentVideoEl.load();
} catch (e) { /* element may already be gone */ }
}
currentVideoEl = null;
}
// Discard the in-flight buffered swap — superseded by a newer render, OR timed out (the
// new iframe never loaded, e.g. server unreachable). Remove the still-hidden frame and
// drop its timer so a late 'load' can't fire against stale state; the last-good board
// already on screen is left untouched. Shared by the supersede and timeout paths.
function discardPendingSwap() {
if (!pendingWidgetSwap) return;
clearTimeout(pendingWidgetSwap.timer);
try { pendingWidgetSwap.iframe.remove(); } catch (e) {}
pendingWidgetSwap = null;
}
// Buffered widget render (#directory-board black-cycle): build the new widget iframe
// BEHIND the current content (hidden) and reveal it only once it fires 'load' — then tear
// down the outgoing content. Kills the black flash on every widget transition, and lets a
// solo board refresh for freshness without ever blanking. On a load timeout we keep the
// last-good board and discard the dead frame (see below) rather than reveal a blank one.
function renderWidgetBuffered(item) {
const container = document.getElementById('playerContainer');
container.style.display = 'block';
// A newer render supersedes a still-loading swap (rapid re-render / playlist churn).
discardPendingSwap();
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
// Positioned + sized by the `#playerContainer > iframe` CSS rule. Hidden while it
// loads so its black background never shows over the outgoing content.
iframe.style.background = '#000';
iframe.style.visibility = 'hidden';
iframe.allow = 'autoplay; fullscreen';
// Sandbox into a unique origin so widget scripts can't read window.parent state.
iframe.setAttribute('sandbox', 'allow-scripts');
const reveal = () => {
if (!pendingWidgetSwap || pendingWidgetSwap.iframe !== iframe) return; // superseded / discarded
clearTimeout(pendingWidgetSwap.timer);
pendingWidgetSwap = null;
iframe.style.visibility = 'visible';
teardownCurrentMedia(iframe); // remove the outgoing content, keep this iframe
if (PREVIEW_MODE && item.widget_type === 'webpage') addWebpageNote(container); // #104
};
iframe.addEventListener('load', reveal);
container.appendChild(iframe);
// Timeout: the new iframe never loaded (network hang). DON'T reveal a maybe-blank frame —
// keep the last-good board visible and discard the dead hidden iframe through the shared
// cleanup (so it can't leak or fire a late 'load'). The solo refresh / next advance retries,
// so a transient server blip self-heals without ever showing black.
pendingWidgetSwap = { iframe, timer: setTimeout(discardPendingSwap, WIDGET_SWAP_TIMEOUT_MS) };
}
// A held widget/board is left mounted (it self-refreshes its own data in place — no iframe
// reload, so its scroll is never reset). This just re-checks the schedule on the slow cadence:
// if it's still the sole active item, leave the live iframe alone and re-arm; if its daypart
// closed or a sibling opened, hand off to nextItem (idle or a genuine buffered transition —
// schedule-awareness / Fix A preserved).
function reevaluateHeldWidget() {
if (nextActiveIndex(currentIndex) === currentIndex) {
advanceTimer = setTimeout(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
return;
}
nextItem();
}
// feat/player-image-preload: warm the NEXT scheduled image (decoded) during the current dwell,
// and swap decode-gated so a slow panel never shows a blank/half-painted frame mid-decode.
function imgSrcFor(it) {
return it.remote_url || `${config.serverUrl}/uploads/content/${it.filepath}`;
}
// A remote image gets a proxy fallback (/media/proxy/:contentId) so it can be textured for a
// transition when its origin sends no CORS header. Local /uploads content is already CORS-enabled,
// and VIDEO never reaches here — images-only through the proxy, per the wiring rule.
function proxySrcFor(it) {
return (it && it.remote_url && it.content_id) ? `${config.serverUrl}/media/proxy/${it.content_id}` : null;
}
// Load an <img> for display + texturing. Try direct-with-CORS first (local + CORS-enabled remotes),
// then the proxy-with-CORS (non-CORS remotes -> same-origin + texturable), then a plain direct load
// so the frame still DISPLAYS as a last resort (a transition touching it just hard-cuts). onerror is
// wired before .src so a failure always advances the chain; onReady/onFail fire at most once.
function loadImageCors(src, proxySrc, onReady, onFail) {
const plan = [{ url: src, cors: true }];
if (proxySrc && proxySrc !== src) plan.push({ url: proxySrc, cors: true });
plan.push({ url: src, cors: false });
let i = 0;
const next = () => {
if (i >= plan.length) return onFail && onFail();
const step = plan[i++];
const img = new Image();
if (step.cors) img.crossOrigin = 'anonymous';
img.onload = () => onReady(img);
img.onerror = next;
try { img.src = step.url; } catch (e) { next(); }
};
next();
}
function cacheImg(src, img) {
imgPreloadCache[src] = img;
const keys = Object.keys(imgPreloadCache); // bound it: reschedule can leak entries
while (keys.length > 4) delete imgPreloadCache[keys.shift()];
}
function preloadNextImage() {
const nextIdx = nextActiveIndex(currentIndex);
if (nextIdx < 0 || nextIdx === currentIndex) return;
const it = playlist[nextIdx];
if (!it || typeof it.mime_type !== 'string' || !it.mime_type.startsWith('image/')) return;
const src = imgSrcFor(it);
if (imgPreloadCache[src]) return;
loadImageCors(src, proxySrcFor(it), (img) => {
(img.decode ? img.decode() : Promise.resolve()).then(() => cacheImg(src, img)).catch(() => cacheImg(src, img));
}, () => {}); // preload best-effort; a failed warm just means renderImageBuffered loads it live
}
// Plain hard-cut mount: tear down the outgoing frame, show the incoming image, arm the dwell.
function mountImage(img, item) {
img.style.cssText = 'width:100%;height:100%;object-fit:contain';
teardownCurrentMedia();
const c = document.getElementById('playerContainer');
c.style.display = 'block';
c.appendChild(img);
advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
preloadNextImage();
}
// ---- GL transition (feat/transition-engine). Every failure path hard-cuts — never a blank. ----
function transitionRuntimeReady() {
return !!(window.TransitionRenderer && window.TransitionParams && window.__TRANSITION_SHADERS);
}
function shaderSource(id) { return (window.__TRANSITION_SHADERS && window.__TRANSITION_SHADERS[id]) || null; }
// the frame on screen now, as a texturable (CORS-clean) source: the live <img>, or — so a wipe can
// start FROM a playing clip — a snapshot canvas of the outgoing <video>'s current frame. Returns null
// if nothing on screen is texturable yet (first item after boot, un-decoded, or tainted) -> hard cut.
function currentTexturableFrame() {
const c = document.getElementById('playerContainer');
if (!c) return null;
const img = c.querySelector('img');
if (img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) return img;
const v = c.querySelector('video');
if (v && v.readyState >= 2 && v.videoWidth > 0 && isMediaReadable(v)) {
try {
const r = c.getBoundingClientRect();
return fitToCanvas(v, Math.max(2, Math.round(r.width)), Math.max(2, Math.round(r.height)));
} catch (e) { return null; } // snapshot threw (taint slipped through) -> no from-frame -> hard cut
}
return null;
}
// draw a source (img | video | canvas) onto a container-sized canvas with object-fit:contain framing,
// so the transition matches the static letterboxing exactly (stays CORS-clean iff the source is). A
// <video> exposes its intrinsic size as videoWidth/Height, not naturalWidth/width — check both.
function fitToCanvas(src, w, h) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const cx = c.getContext('2d');
const iw = src.naturalWidth || src.videoWidth || src.width;
const ih = src.naturalHeight || src.videoHeight || src.height;
const s = Math.min(w / iw, h / ih);
cx.drawImage(src, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
return c;
}
// ONE persistent WebGL renderer, reused for EVERY transition. The canvas is attached to <body> ONCE
// and only SHOWN/HIDDEN per wipe — NEVER detached. Detaching a canvas can drop its WebGL context in
// some browsers (Firefox), which would reintroduce per-wipe context churn and kill every wipe after
// the first. Kept out of #playerContainer so teardownCurrentMedia() can't remove it. Recreated only
// on a genuine context loss.
let glTx = null, glTxAbort = null;
function getGlTransition() {
if (glTx && !glTx.renderer.lost) return glTx;
try {
const canvas = document.createElement('canvas');
canvas.id = 'txCanvas';
canvas.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;display:none;z-index:35;pointer-events:none';
const renderer = window.TransitionRenderer.createRenderer(canvas, window.TransitionParams, {
onContextLost: () => { const a = glTxAbort; glTx = null; glTxAbort = null; if (a) a(); },
});
document.body.appendChild(canvas); // attach ONCE; stays forever
glTx = { canvas, renderer };
return glTx;
} catch (e) { glTx = null; return null; }
}
// Shared GL-wipe core for EVERY buffered transition — image OR video target. Renders `fromFrame`
// (img|video|canvas) -> `toTex` (the texturable incoming frame) on the persistent canvas. When the
// wipe completes (rAF reaches p>=1, the deadline fires, or the context is lost) it calls mount(),
// which inserts the REAL incoming element (img, or video+play) and owns its teardown, then hides the
// canvas AFTER the mount so there's no flash. onStart() runs once the wipe is committed (the image
// path arms its dwell there for overlap timing; video advances on 'ended' instead). Any setup
// failure / hidden tab / missing runtime calls hardCut() — never a blank frame. dwellMs bounds the
// wipe so it can't outlast an image's on-screen time.
function runGlWipe(fromFrame, toTex, t, dwellMs, onStart, mount, hardCut) {
const effect = t.effects[Math.floor(Math.random() * t.effects.length)]; // several effects -> pick one for variety
const src = effect && shaderSource(effect.shader);
if (!src) { hardCut(); return; }
if (document.hidden) { hardCut(); return; } // rAF frozen when hidden -> hard cut
const gl = getGlTransition();
if (!gl) { hardCut(); return; }
const canvas = gl.canvas, renderer = gl.renderer;
const container = document.getElementById('playerContainer');
const rect = container.getBoundingClientRect();
const w = Math.max(2, Math.round(rect.width)), h = Math.max(2, Math.round(rect.height));
let raf = 0, startTs = 0, done = false, deadline = 0;
const finish = () => { // end the wipe; mount the incoming element, KEEP the renderer/context alive
if (done) return; done = true;
if (raf) cancelAnimationFrame(raf);
if (deadline) clearTimeout(deadline);
if (glTxAbort === finish) glTxAbort = null;
mount(); // insert incoming + own its teardown/dwell/play
canvas.style.display = 'none'; // HIDE after the incoming frame is mounted (no flash); NEVER detach
};
try {
canvas.style.left = rect.left + 'px'; canvas.style.top = rect.top + 'px';
canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
renderer.resize(w, h); // size the persistent canvas to the stage
renderer.setFrom(fitToCanvas(fromFrame, w, h));
renderer.setTo(fitToCanvas(toTex, w, h));
renderer.setShader(src); // throws on a bad shader
renderer.render(0, effect.params);
} catch (e) { canvas.style.display = 'none'; hardCut(); return; }
glTxAbort = finish; // context lost / tab hidden mid-wipe -> finish (hard cut)
canvas.style.display = 'block'; // SHOW over the current frame (fixed overlay)
container.style.display = 'block';
onStart(); // OVERLAP-not-additive: image dwell starts NOW (video: no-op)
const durMs = Math.min(t.durationMs, Math.max(150, dwellMs - 100)); // never exceed the dwell
// Safety net: also drive the final mount from a TIMER, not only the rAF loop. If rAF is throttled/
// frozen (backgrounded/occluded tab), the wipe won't animate but the content still swaps on time
// instead of the screen sticking. Whichever of {rAF hits p>=1, this deadline} fires wins.
deadline = setTimeout(finish, durMs + 80);
const frame = (ts) => {
if (done) return;
if (renderer.lost) return finish();
if (!startTs) startTs = ts;
const p = Math.min(1, (ts - startTs) / durMs);
if (!renderer.render(p, effect.params)) return finish();
if (p >= 1) return finish();
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
}
// Image target: wipe fromImg -> toImg, then mount the plain <img>. Dwell is armed at wipe START
// (overlap timing) so the transition plays INSIDE the item's duration; finish just swaps the frame.
function runImageTransition(fromImg, toImg, t, item) {
const dwellMs = (item.duration_sec || 10) * 1000;
const container = document.getElementById('playerContainer');
runGlWipe(fromImg, toImg, t, dwellMs,
() => { advanceTimer = setTimeout(nextItem, dwellMs); }, // onStart: arm the dwell for overlap
() => { // mount: swap in the image, keep the armed timer
toImg.style.cssText = 'width:100%;height:100%;object-fit:contain';
container.appendChild(toImg);
teardownCurrentMedia(toImg); // drop outgoing, KEEP toImg + the armed dwell timer
preloadNextImage();
},
() => { mountImage(toImg, item); }); // hardCut: plain mount (arms its own dwell)
}
function renderImageBuffered(item) {
const src = imgSrcFor(item);
const cached = imgPreloadCache[src];
let done = false, watchdog = null;
const swap = (img) => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
delete imgPreloadCache[src];
if (glTxAbort) glTxAbort(); // settle any in-flight wipe first — one transition at a time on the shared renderer
const from = currentTexturableFrame(); // may be an outgoing <video> snapshot -> video→image wipes
const t = item.transition;
const canTexture = img.complete && img.naturalWidth > 0 && isMediaReadable(img);
if (t && Array.isArray(t.effects) && t.effects.length && from && canTexture && transitionRuntimeReady()) {
runImageTransition(from, img, t, item); // owns teardown + advance + preload
} else {
mountImage(img, item); // hard cut (never blank)
}
};
const fail = () => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
console.error('Image error'); advanceTimer = setTimeout(nextItem, 3000); // skip broken item; hold prior frame
};
// a hung load/decode must never stall the playlist: at 3s use what we have, else skip
watchdog = setTimeout(() => { if (cached) swap(cached); else fail(); }, 3000);
if (cached) { swap(cached); return; } // decoded ahead — instant, CORS-clean
loadImageCors(src, proxySrcFor(item), (img) => { // CORS -> proxy -> plain, then decode-gate
(img.decode ? img.decode() : Promise.resolve()).then(() => swap(img)).catch(() => swap(img));
}, fail);
}
// Buffered SOLO-video render (feat/transition-engine): wipe the outgoing frame INTO a video —
// image→video and video→video. Only fullscreen solo videos reach here (the renderContent gate
// excludes wall/zone/group/widget/youtube), so none of the wall/follower/sync logic applies.
//
// A just-'loadeddata' but never-played <video> does NOT reliably paint via drawImage (some engines
// present no frame until playback starts) — so we warm-play the incoming clip MUTED offscreen until
// its first frame is actually PRESENTED (requestVideoFrameCallback), pause it there, snapshot that
// frame as the wipe's `to`, run the GL wipe, then mount + resume the real <video> FROM that same
// frame (zero jump). Every failure path (no from-frame, un-decodable, no runtime, context loss, or
// the decode watchdog) hard-cuts straight to mount+play — never a blank.
// renderSeq (declared with the top-level state) is bumped on every renderContent dispatch. A buffered
// render captures the value at start and bails if it changes — the warm-play is async (first-frame wait
// + the wipe), and a playlist push mid-window must not let a stale clip tear down the newer content that
// already took over.
function renderVideoBuffered(item) {
const src = item.remote_url || `${config.serverUrl}/uploads/content/${item.filepath}`;
const from = currentTexturableFrame(); // capture the outgoing frame NOW, before any teardown
const t = item.transition;
const mySeq = renderSeq;
const stale = () => renderSeq !== mySeq; // a newer renderContent superseded this one
const abandon = () => { try { video.pause(); video.removeAttribute('src'); video.load(); } catch (e) {} };
const video = document.createElement('video');
video.crossOrigin = 'anonymous'; // texturable (CORS-clean) + matches the legacy branch
video.playsInline = true;
video.preload = 'auto';
// #216: attach a WebVTT subtitle track when the content carries one (uploaded videos
// only; remote/proxied clips are skipped). Served same-origin from /uploads/content,
// so it's CORS-clean like the video. `default` + mode='showing' makes it visible with
// no player controls. Old players ignore an absent subtitle_url.
if (item.subtitle_url && !item.remote_url) {
const track = document.createElement('track');
track.kind = 'subtitles';
track.srclang = item.subtitle_lang || 'en';
track.label = item.subtitle_lang || 'Subtitles';
track.default = true;
track.src = `${config.serverUrl}/uploads/content/${item.subtitle_url}`;
video.appendChild(track);
// The `default` attribute alone doesn't always engage without controls; force it on
// once the track has loaded its cues.
track.addEventListener('load', () => { try { track.track.mode = 'showing'; } catch (e) {} });
}
video.muted = true; // warm-play MUST be muted (autoplay policy); real mute set at mount
video.loop = (playlist.length === 1); // single-item playlist holds by looping
video.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000';
let done = false, watchdog = null;
// Full teardown + append + resume. The incoming <video> is detached until now, so teardown's
// container-wide video cleanup can't touch it; we claim currentVideoEl only AFTER teardown (which
// unconditionally releases the old currentVideoEl). Sets the REAL mute state here (warm-play was
// muted) and resumes from the snapshot frame, so there's no forward jump on reveal.
const mountVideo = () => {
if (stale()) { abandon(); return; } // a newer item took over during the wipe — don't clobber it
const c = document.getElementById('playerContainer');
teardownCurrentMedia(); // drop the outgoing frame (new video still detached)
c.style.display = 'block';
c.appendChild(video);
currentVideoEl = video;
video.muted = (!userHasInteracted || !!item.muted); // autoplay policy + per-item mute (#129)
if (!video.muted) video.volume = 1.0;
video.onended = () => { if (!video.loop) nextItem(); };
video.onerror = (e) => { console.error('Video error:', src, e); advanceTimer = setTimeout(nextItem, 3000); };
video.play().catch(() => { video.muted = true; video.play().catch(() => {}); }); // autoplay-policy fallback
setTimeout(() => { if (video.paused) { video.muted = true; video.play().catch(() => {}); } }, 2000); // last-resort kick
};
// First PRESENTED frame reached (or watchdog): freeze it, snapshot, wipe if we have from+to+runtime.
const onFirstFrame = () => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
if (stale()) { abandon(); return; } // superseded before the wipe even started
try { video.pause(); } catch (e) {} // hold at the snapshot frame; mountVideo resumes from here
const canTexture = video.readyState >= 2 && video.videoWidth > 0 && isMediaReadable(video);
let to = null;
if (canTexture) {
try {
const r = document.getElementById('playerContainer').getBoundingClientRect();
to = fitToCanvas(video, Math.max(2, Math.round(r.width)), Math.max(2, Math.round(r.height)));
} catch (e) { to = null; } // snapshot tainted/threw -> hard cut below
}
if (from && to && t && Array.isArray(t.effects) && t.effects.length && transitionRuntimeReady()) {
runGlWipe(from, to, t, t.durationMs + 200, // video has no image-dwell; bound only keeps the full duration
() => {}, // onStart: video advance is driven by 'ended', not a dwell timer
mountVideo, mountVideo); // mount AND hardCut both mount+resume the real video
} else {
mountVideo(); // no from-frame / un-texturable / no runtime -> hard cut
}
};
// Warm-play until the first frame is PRESENTED. rVFC is the precise "a frame just painted" signal;
// without it, fall back to a short beat after playback starts. If play() is somehow rejected we still
// arm the frame wait (the watchdog is the final backstop).
const armFrame = () => {
if ('requestVideoFrameCallback' in video) video.requestVideoFrameCallback(() => onFirstFrame());
else setTimeout(onFirstFrame, 150);
};
video.addEventListener('loadeddata', () => { video.play().then(armFrame).catch(armFrame); }, { once: true });
// A decode failure or a hung load must never stall the playlist.
video.addEventListener('error', () => {
if (done) return; done = true;
if (watchdog) clearTimeout(watchdog);
console.error('Video error:', src); advanceTimer = setTimeout(nextItem, 3000); // skip broken clip; hold prior frame
});
// Watchdog caps the wait so a cold/slow clip hard-cuts (mount+play) instead of the boundary hanging
// (mirrors renderImageBuffered's watchdog).
watchdog = setTimeout(() => { if (done) return; done = true; if (stale()) { abandon(); return; } mountVideo(); }, 800);
video.src = src;
video.load();
}
function renderContent(item) {
// New dispatch supersedes any in-flight buffered (async warm-play) render — see renderSeq.
renderSeq++;
// Cancel any pending advance/refresh timer up front so a prior item's timer (incl. a
// self-rescheduling widget refresh) can't fire against the new content.
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
// Defense in depth: a transition widget is normalized out server-side and must never render as
// content. If a stale/legacy payload still carries one, skip it instead of mounting a blank iframe.
if (item && item.widget_type === 'transition') { advanceTimer = setTimeout(nextItem, 0); return; }
// Fullscreen (non-wall) widget: buffered swap — never blank on reload. Runs BEFORE the
// generic teardown (which would black the screen), and owns its own refresh/advance
// timer below. Multi-zone widgets go through renderZones; wall+widget keeps the legacy
// path.
const isZones = !!(layout && layout.zones && layout.zones.length > 1 && !wallConfig);
if (item && item.widget_id && !isZones && !wallConfig) {
renderWidgetBuffered(item);
// Group members run no local timer (their schedule tick drives the index).
if (!groupSync) {
// A solo/held widget (e.g. a directory board) self-refreshes its own data IN PLACE,
// so we must NOT reload its iframe on a timer — that would reset its scroll. Instead,
// on the slow cadence just re-check the SCHEDULE and only transition if the active
// selection changed (daypart opened/closed). A rotating playlist advances normally on
// its duration; the first mount + every genuine transition still go through the
// buffered swap.
const held = nextActiveIndex(currentIndex) === currentIndex;
if (held) advanceTimer = setTimeout(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS);
else advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
}
return;
}
// feat/player-image-preload: decode-gated buffered swap for the common fullscreen image case.
// Runs BEFORE the generic teardown so the outgoing frame survives until the incoming image is
// DECODED (no blank/half-painted flash on slow panels). Wall/group/zone images fall through to
// the legacy path below (they carry their own sync/preload handling).
const isImageBufferable = item && typeof item.mime_type === 'string' && item.mime_type.startsWith('image/')
&& !item.widget_id && !wallConfig && !isZones && !groupSync;
if (isImageBufferable) {
renderImageBuffered(item);
return;
}
// Same buffered path for a SOLO video that has a transition: wipe the outgoing frame into it
// (image→video / video→video). Only when a runtime + transition are actually present — a plain
// solo video falls through to the legacy branch, which keeps its preload/mute/loop handling. Wall/
// zone/group/widget are excluded (they carry their own sync); YouTube (video/youtube) too.
const isVideoBufferable = item && typeof item.mime_type === 'string'
&& item.mime_type.startsWith('video/') && item.mime_type !== 'video/youtube'
&& !item.widget_id && !wallConfig && !isZones && !groupSync
&& item.transition && Array.isArray(item.transition.effects) && item.transition.effects.length
&& transitionRuntimeReady();
if (isVideoBufferable) {
renderVideoBuffered(item);
return;
}
teardownCurrentMedia();
const container = document.getElementById('playerContainer');
container.style.display = 'block';
// Multi-zone: each zone pulls + rotates its own assignments by zone_id,
// independent of the "current item". Render zones here (before the single-item
// bail) so an empty/placeholder current item can't blank the whole screen.
if (layout && layout.zones && layout.zones.length > 1 && !wallConfig) {
renderZones(container, item);
return;
}
// Defense in depth: bail to waiting state on missing/malformed item rather
// than fall through every branch and leave a blank container.
const hasRenderableType = item && (
item.widget_id ||
item.mime_type === 'video/youtube' ||
(typeof item.mime_type === 'string' && (item.mime_type.startsWith('video/') || item.mime_type.startsWith('image/')))
);
if (!hasRenderableType) {
showStatus('Waiting for content...');
isPlaying = false;
return;
}
// In wall mode, mount content into a stage that maps the player_rect
// into this device's viewport. playerContainer's overflow:hidden clips
// the parts of the stage outside this device's viewport, so each
// device shows exactly its slice of the wall.
let mount = container;
if (wallConfig) {
const stage = document.createElement('div');
stage.className = 'wall-stage';
styleWallStage(stage);
container.appendChild(stage);
mount = stage;
}
// Two independent concerns, previously conflated as "isFollower":
// - forceMuted: wall followers stay silent (N flanged copies across an adjacent wall).
// Group members honor per-item mute instead (displays are spread out).
// - scheduleDriven: who does NOT run a local advance timer. Wall followers (leader drives the
// index) AND all group members (the clock/schedule tick drives the index).
const isWallFollower_ = (!!wallConfig && !wallConfig.is_leader);
const scheduleDriven = isWallFollower_ || !!groupSync;
const forceMuted = isWallFollower_;
const isFollower = scheduleDriven; // keep the old name for the branches below (advance gating)
const isYoutube = item.mime_type === 'video/youtube';
const isVideo = !isYoutube && item.mime_type?.startsWith('video/');
const isImage = item.mime_type?.startsWith('image/');
const remoteUrl = item.remote_url;
const serverUrl = config.serverUrl;
const src = remoteUrl || `${serverUrl}/uploads/content/${item.filepath}`;
if (layout && layout.zones && layout.zones.length > 1 && !wallConfig) {
renderZones(container, item);
} else {
// Fullscreen / wall-tile
if (isYoutube) {
createYoutubeEmbed(src, item, mount);
} else if (isVideo) {
// Double buffer: reuse the pre-buffered element for this index if we warmed it (no black
// hold). Its src is already set + buffered; a fresh element otherwise.
const preloaded = scheduleDriven ? takeGroupPreload(currentIndex) : null;
const video = preloaded || document.createElement('video');
if (!preloaded) video.src = src;
video.autoplay = true;
// Followers stay muted unconditionally (leader-only audio); leaders
// start muted only if the user hasn't gestured yet (autoplay policy).
// #129: a per-item mute (set in the admin console) also forces muted.
video.muted = forceMuted ? true : (!userHasInteracted || !!item.muted);
// Explicit max volume when audio is allowed so it's at full level when
// unmute happens (default is 1.0 but make it visible in logs).
if (!forceMuted) video.volume = 1.0;
video.playsInline = true;
video.crossOrigin = 'anonymous';
// Wall mode uses object-fit:fill so the source stretches to the
// stage exactly. Cover would re-crop based on each device's stage
// aspect (different innerWidths produce different cover scales),
// which is the original vertical-misalignment bug. Fill keeps the
// vertical mapping uniform across devices that share a viewport
// height. Solo (non-wall) keeps contain to preserve aspect.
video.style.cssText = wallConfig
? 'width:100%;height:100%;object-fit:fill;background:#000'
: 'width:100%;height:100%;object-fit:contain;background:#000';
// Group members loop so a clip shorter than its schedule slot holds until the schedule
// advances the index (and the tick seeks position % duration to stay aligned).
video.loop = (playlist.length === 1) || !!groupSync;
video.onended = () => { if (!video.loop && !isFollower) nextItem(); };
video.onerror = (e) => {
console.error('Video error:', src, e);
if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
};
video.onloadeddata = () => {
console.log('[wall/audio] video loaded file=' + item.filename + ' role=' + (wallConfig ? (wallConfig.is_leader ? 'leader' : 'follower') : 'solo') + ' muted=' + video.muted + ' volume=' + video.volume);
};
// If anything (browser, scripts, the user) tries to unmute a WALL
// follower, snap it back. This is the safety net for the audio
// bug — without it, a single stray unmute call causes echo. Group
// members are NOT force-muted (per-item mute governs them).
if (forceMuted) {
video.addEventListener('volumechange', () => {
if (!video.muted) { video.muted = true; }
});
}
mount.appendChild(video);
currentVideoEl = video;
// Try playing as we set muted above. If the browser blocks
// unmuted autoplay (e.g. no user gesture yet), retry muted.
video.play().then(() => {
console.log('[wall/audio] play() ok muted=' + video.muted + ' volume=' + video.volume);
}).catch((err) => {
console.warn('[wall/audio] play() rejected, falling back to muted: ' + (err?.name || err?.message || err));
video.muted = true;
video.play()
.then(() => console.log('[wall/audio] muted-fallback play() ok'))
.catch((e2) => console.error('[wall/audio] muted-fallback play() also failed: ' + (e2?.name || e2?.message || e2)));
});
// Fallback: force play if not started after 2s
setTimeout(() => { if (video.paused) { video.muted = true; video.play().catch(() => {}); } }, 2000);
} else if (isImage) {
const img = document.createElement('img');
img.src = src;
img.style.cssText = wallConfig
? 'width:100%;height:100%;object-fit:fill'
: 'width:100%;height:100%;object-fit:contain';
img.onerror = () => {
console.error('Image error');
if (!isFollower) advanceTimer = setTimeout(nextItem, 3000);
};
mount.appendChild(img);
// Leader / single screen drives image advance; follower waits for sync
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
} else if (item.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000';
iframe.allow = 'autoplay; fullscreen';
// Sandbox into a unique origin so widget scripts can't read window.parent
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts');
mount.appendChild(iframe);
if (PREVIEW_MODE && item.widget_type === 'webpage') addWebpageNote(mount); // #104
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000);
}
}
}
// #74/#75 zone-level schedule helpers.
function zoneNextActive(items, from) {
for (let i = 0; i < items.length; i++) {
const idx = (from + i) % items.length;
if (scheduleAllows(items[idx])) return idx;
}
return -1;
}
function showZoneEmpty(zone, div, items) {
div.querySelectorAll('video').forEach(v => { try { v.pause(); } catch (e) {} });
div.innerHTML = '';
zoneTimers[zone.id] = setTimeout(() => showZoneItem(zone, div, items, 0), 30000);
}
function renderZones(container, defaultItem) {
clearZoneTimers();
// Group assignments by zone, ordered by sort_order so each zone rotates its
// OWN list independently (images/widgets on a duration timer, videos on end)
// rather than every zone re-rendering on a single global tick.
// Zone-orphan fallback: an item whose zone_id is NOT a zone in the active layout
// (assigned under a different layout, or the layout was duplicated/switched — the new
// zones get fresh ids) would otherwise be SILENTLY DROPPED, because its bucket never
// matches a rendered zone. Re-bucket it into the LARGEST-area zone's rotation so it
// shares screen time there (one item at a time -> never overlays/stacks on existing
// content) and emit telemetry so the stale assignment is diagnosable. (See the
// fallback-rule rationale in the change notes.)
const validZoneIds = new Set(layout.zones.map(z => z.id));
const fallbackZone = layout.zones.reduce(
(a, b) => (((b.width_percent || 0) * (b.height_percent || 0)) > ((a.width_percent || 0) * (a.height_percent || 0)) ? b : a),
layout.zones[0]);
const byZone = {};
const orphanNames = [];
for (const a of playlist) {
let zid = a.zone_id || '__none__';
if (a.zone_id && !validZoneIds.has(a.zone_id) && fallbackZone) {
zoneReport('warn', 'orphan zone_id=' + a.zone_id + ' item=' + (a.filename || a.content_id || a.widget_id || '?') +
' device=' + (config.deviceId || 'preview') + ' -> fallback zone "' + (fallbackZone.name || fallbackZone.id) + '"');
orphanNames.push(a.filename || a.content_id || a.widget_id || '?');
zid = fallbackZone.id;
}
(byZone[zid] = byZone[zid] || []).push(a);
}
for (const k in byZone) byZone[k].sort((x, y) => (x.sort_order || 0) - (y.sort_order || 0));
// #zone-orphan: operator-only preview note naming the stale items (NOT on a live
// player — zoneReport already streams those to the dashboard device-log instead).
if (PREVIEW_MODE) {
previewBannerParts.orphans = orphanNames.length
? orphanNames.length + ' item(s) assigned to a different layout: ' +
orphanNames.slice(0, 6).join(', ') + (orphanNames.length > 6 ? '…' : '') +
' — showing in "' + ((fallbackZone && (fallbackZone.name || fallbackZone.id)) || '—') + '"'
: null;
renderPreviewBanner();
}
let unassignedUsed = false;
layout.zones.forEach(zone => {
let items = byZone[zone.id];
if ((!items || !items.length) && !unassignedUsed && byZone['__none__']) {
unassignedUsed = true; items = byZone['__none__'];
}
if ((!items || !items.length) && defaultItem) items = [defaultItem];
if (!items || !items.length) return;
const div = document.createElement('div');
div.className = 'zone';
div.style.cssText = `left:${zone.x_percent}%;top:${zone.y_percent}%;width:${zone.width_percent}%;height:${zone.height_percent}%;z-index:${zone.z_index || 0}`;
container.appendChild(div);
showZoneItem(zone, div, items, 0);
});
}
// Render items[index] in a zone and schedule the next item on the zone's OWN
// timer (images/widgets/youtube: duration; videos: on end). Single-item zones
// loop / don't advance.
function showZoneItem(zone, div, items, index) {
if (zoneTimers[zone.id]) { clearTimeout(zoneTimers[zone.id]); delete zoneTimers[zone.id]; }
// #74/#75: skip items whose schedule excludes them now; idle the zone if none.
const activeIdx = zoneNextActive(items, index);
if (activeIdx === -1) { showZoneEmpty(zone, div, items); return; }
index = activeIdx;
const a = items[index % items.length];
// Scheduled zones must cycle (even a lone active item) so windows re-evaluate
// at each transition rather than a loop ignoring the window end.
const multi = items.length > 1 || items.some(it => it.schedules && it.schedules.length);
const advance = () => showZoneItem(zone, div, items, index + 1);
// Tear down any prior media in this zone before swapping.
div.querySelectorAll('video').forEach(v => { try { v.onended = null; v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} });
div.innerHTML = '';
const isYoutube = a.mime_type === 'video/youtube';
const isVideo = !isYoutube && a.mime_type?.startsWith('video/');
const src = a.remote_url || `${config.serverUrl}/uploads/content/${a.filepath}`;
const dur = (a.duration_sec || 10) * 1000;
// Render based on what the ASSIGNMENT is (widget_id), not the zone's type:
// a widget can be placed in a 'content' zone, and gating on zone_type==='widget'
// left those zones blank (mime_type is null -> no video/image match). Matches the
// Android player, which keys off the assignment's widget_type.
if (a.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
// Sandbox into a unique origin so widget scripts can't read window.parent
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts');
div.appendChild(iframe);
if (PREVIEW_MODE && a.widget_type === 'webpage') addWebpageNote(div); // #104
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
} else if (isYoutube) {
createYoutubeEmbed(src, a, div);
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
} else if (isVideo) {
const video = document.createElement('video');
video.src = src;
video.autoplay = true;
video.muted = (zone.sort_order > 0); // Only first zone has audio
video.loop = !multi; // single-item zone loops; multi advances on end
video.playsInline = true;
video.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
if (multi) video.onended = advance;
div.appendChild(video);
} else {
const img = document.createElement('img');
img.src = src;
img.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
div.appendChild(img);
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
}
}
// ==================== Screenshots ====================
// Draw a media element into a destination rect honouring its object-fit, so the capture
// matches what's on screen instead of stretching the source to a fixed size (the old
// bug). 'cover' crops the source; 'contain' letterboxes; 'fill' stretches.
function drawMediaFit(ctx, el, ew, eh, dx, dy, dw, dh, fit) {
if (!ew || !eh) { ctx.drawImage(el, dx, dy, dw, dh); return; }
if (fit === 'fill') { ctx.drawImage(el, dx, dy, dw, dh); return; }
const er = ew / eh, dr = dw / dh;
if (fit === 'contain') {
let w = dw, h = dh;
if (er > dr) h = dw / er; else w = dh * er;
ctx.drawImage(el, dx + (dw - w) / 2, dy + (dh - h) / 2, w, h);
} else { // 'cover' (zone default) and anything unexpected -> crop to fill, no distortion
let sw = ew, sh = eh, sx = 0, sy = 0;
if (er > dr) { sw = eh * dr; sx = (ew - sw) / 2; }
else { sh = ew / dr; sy = (eh - sh) / 2; }
ctx.drawImage(el, sx, sy, sw, sh, dx, dy, dw, dh);
}
}
// A cross-origin <img>/<video> drawn onto the canvas without CORS taints the WHOLE
// canvas, making toDataURL() throw and killing the entire capture. Only same-origin
// media (served by us) or media explicitly loaded with crossOrigin is safe to read back.
function isMediaReadable(el) {
const url = el.currentSrc || el.src || '';
if (!url) return false;
if (el.crossOrigin) return true;
try { return new URL(url, location.href).origin === location.origin; }
catch (e) { return false; }
}
function zonePlaceholderLabel(el) {
if (!el) return 'Live';
if (el.tagName === 'IFRAME') {
const s = el.src || '';
if (/youtube|ytimg|youtu\.be/i.test(s)) return 'YouTube';
if (/\/widgets\//i.test(s)) return 'Widget';
return 'Web';
}
if (el.tagName === 'VIDEO') return 'Video';
return 'Live';
}
// Deliberate, labelled placeholder for a zone we can't read back (cross-origin iframe
// like YouTube/widgets, or cross-origin media). The shot still shows the layout
// structure with this zone clearly marked — never a transparent hole.
function drawZonePlaceholder(ctx, dx, dy, dw, dh, label) {
ctx.save();
ctx.fillStyle = '#1f2937';
ctx.fillRect(dx, dy, dw, dh);
ctx.strokeStyle = 'rgba(148,163,184,0.35)';
ctx.lineWidth = 1;
ctx.strokeRect(dx + 0.5, dy + 0.5, Math.max(0, dw - 1), Math.max(0, dh - 1));
if (label) {
ctx.fillStyle = '#cbd5e1';
const fs = Math.max(11, Math.min(22, Math.round(dh * 0.16)));
ctx.font = `600 ${fs}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, dx + dw / 2, dy + dh / 2);
}
ctx.restore();
}
// Composite a multi-zone layout zone-by-zone. Each zone's destination rect is derived
// from its REAL rendered geometry (getBoundingClientRect relative to the container) and
// scaled proportionally onto the canvas — so positions/sizes stay true to the layout
// rather than one element stretched across the frame.
function drawZoneComposite(ctx, container, cr, W, H) {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
const zones = container.querySelectorAll('.zone');
if (!zones.length) return false;
zones.forEach((zd) => {
const r = zd.getBoundingClientRect();
const dx = ((r.left - cr.left) / cr.width) * W;
const dy = ((r.top - cr.top) / cr.height) * H;
const dw = (r.width / cr.width) * W;
const dh = (r.height / cr.height) * H;
const el = zd.querySelector('video, img, iframe');
let drawn = false;
if (el && el.tagName === 'IMG' && el.complete && el.naturalWidth > 0 && isMediaReadable(el)) {
try { drawMediaFit(ctx, el, el.naturalWidth, el.naturalHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {}
} else if (el && el.tagName === 'VIDEO' && el.readyState >= 2 && el.videoWidth > 0 && isMediaReadable(el)) {
try { drawMediaFit(ctx, el, el.videoWidth, el.videoHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {}
}
if (!drawn) drawZonePlaceholder(ctx, dx, dy, dw, dh, zonePlaceholderLabel(el));
});
return true;
}
// Build the screenshot/stream canvas and return it (caller encodes + sends). Exposed as
// a plain function so a headless render pass can verify the composite without a socket.
function renderCaptureCanvas() {
const container = document.getElementById('playerContainer');
const cr = container ? container.getBoundingClientRect() : null;
// ~960 on the long edge; height from the REAL container aspect, not a hardcoded 540,
// so non-16:9 layouts compose without distortion.
const aspect = (cr && cr.width > 0 && cr.height > 0) ? (cr.width / cr.height) : (16 / 9);
const canvas = document.createElement('canvas');
canvas.width = 960;
canvas.height = Math.max(1, Math.round(960 / aspect));
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let captured = false;
try {
const multiZone = !!(layout && Array.isArray(layout.zones) && layout.zones.length > 1 && !wallConfig);
if (multiZone && container) {
captured = drawZoneComposite(ctx, container, cr, W, H);
} else if (container) {
// Single-zone / fullscreen fast path: one element, drawn with its own object-fit
// (the old code stretched it to a fixed 960x540).
const video = container.querySelector('video');
const img = container.querySelector('img');
if (video && video.readyState >= 2 && video.videoWidth > 0 && isMediaReadable(video)) {
try { drawMediaFit(ctx, video, video.videoWidth, video.videoHeight, 0, 0, W, H, getComputedStyle(video).objectFit); captured = true; } catch (e) { console.warn('Video capture failed (CORS?):', e.message); }
}
if (!captured && img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) {
try { drawMediaFit(ctx, img, img.naturalWidth, img.naturalHeight, 0, 0, W, H, getComputedStyle(img).objectFit); captured = true; } catch (e) { console.warn('Image capture failed:', e.message); }
}
}
// Fallback: draw status info
if (!captured) {
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#3b82f6';
ctx.font = 'bold 28px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('ScreenTinker Web Player', W / 2, H / 2 - 40);
ctx.fillStyle = '#94a3b8';
ctx.font = '16px sans-serif';
const item = playlist[currentIndex];
ctx.fillText(item ? `Playing: ${item.filename}` : 'No content', W / 2, H / 2);
ctx.fillText(`${config.deviceName || 'Web Player'} | ${new Date().toLocaleTimeString()}`, W / 2, H / 2 + 40);
}
} catch (e) {
// Even on error, draw something
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#ef4444';
ctx.font = '16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Screenshot error: ' + e.message, W / 2, H / 2);
}
return canvas;
}
function captureAndSend() {
if (!socket?.connected) return;
// Also drives the 1fps remote stream (startStreaming). The composite is just a handful
// of drawImage calls over already-decoded media, so one full-quality path serves both
// the on-demand screenshot and the 1fps stream — no separate low-quality stream path.
let canvas;
try { canvas = renderCaptureCanvas(); }
catch (e) { console.error('Screenshot render failed:', e); return; }
try {
const dataUrl = canvas.toDataURL('image/jpeg', 0.4);
const base64 = dataUrl.split(',')[1];
if (base64 && base64.length > 100) {
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
console.log('Screenshot sent:', base64.length, 'chars');
}
} catch (e) {
console.error('Screenshot encode/send failed:', e);
}
}
function startStreaming() {
stopStreaming();
streamTimer = setInterval(captureAndSend, 1000);
}
function stopStreaming() {
if (streamTimer) { clearInterval(streamTimer); streamTimer = null; }
}
// ==================== UI Helpers ====================
function showStatus(msg) {
document.getElementById('statusOverlay').style.display = 'flex';
document.getElementById('statusText').textContent = msg;
}
function hideStatus() {
document.getElementById('statusOverlay').style.display = 'none';
}
function toggleScreenOff() {
let overlay = document.getElementById('screenOffOverlay');
if (overlay) { overlay.remove(); return; }
overlay = document.createElement('div');
overlay.id = 'screenOffOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999;cursor:pointer';
overlay.onclick = () => overlay.remove();
document.body.appendChild(overlay);
}
// Create info overlay (toggled by Back button)
const infoDiv = document.createElement('div');
infoDiv.id = 'infoOverlay';
infoDiv.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:800;display:none;flex-direction:column;align-items:center;justify-content:center;color:#f1f5f9;font-family:-apple-system,sans-serif';
infoDiv.innerHTML = `
<h2 style="color:#3b82f6;margin-bottom:16px">${_t('info_title')}</h2>
<div style="font-size:14px;line-height:2;text-align:center;color:#94a3b8" id="infoContent"></div>
<p style="margin-top:24px;font-size:12px;color:#64748b">${_t('info_close_hint')}</p>
`;
infoDiv.onclick = () => { infoDiv.style.display = 'none'; };
document.body.appendChild(infoDiv);
// Escape user-controllable values before injecting into innerHTML — filenames,
// device names, and server URLs are stored on the server and could contain HTML.
const escHtml = (s) => s == null ? '' : String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
// Update info overlay content periodically
setInterval(() => {
const el = document.getElementById('infoContent');
if (!el) return;
const item = playlist[currentIndex];
el.innerHTML = `
${_t('info_device_id')}: ${escHtml(config.deviceId?.slice(0, 8) || _t('info_na'))}...<br>
${_t('info_device_name')}: ${escHtml(config.deviceName || _t('info_na'))}<br>
${_t('info_server')}: ${escHtml(config.serverUrl || _t('info_na'))}<br>
${_t('info_status')}: ${socket?.connected ? `<span style="color:#22c55e">${_t('info_connected')}</span>` : `<span style="color:#ef4444">${_t('info_disconnected')}</span>`}<br>
${_t('info_now_playing')}: ${escHtml(item?.filename || _t('info_nothing'))} (${currentIndex + 1}/${playlist.length})<br>
${_t('info_resolution')}: ${screen.width}x${screen.height}<br>
${_t('info_uptime')}: ${Math.floor(performance.now() / 60000)}m<br>
${_t('info_platform')}: ${escHtml(navigator.platform)}<br>
${_t('info_cache')}: ${_t('info_sw')} ${navigator.serviceWorker?.controller ? `<span style="color:#22c55e">${_t('info_active')}</span>` : _t('info_inactive')}
`;
}, 2000);
// ==================== Fullscreen ====================
// Only attempt fullscreen on genuine user clicks. Synthetic clicks dispatched
// by the remote-control feature (touch forwarding from the dashboard) are not
// trusted by the browser and requestFullscreen() rejects with a "Permissions
// check failed" / "API can only be initiated by a user gesture" error every
// time, spamming the console.
document.addEventListener('click', (e) => {
if (!e.isTrusted) return;
if (!document.fullscreenElement && config.paired) {
document.documentElement.requestFullscreen?.() ||
document.documentElement.webkitRequestFullscreen?.();
}
});
// Prevent sleep/screen saver
let wakeLock = null;
async function requestWakeLock() {
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => { setTimeout(requestWakeLock, 1000); });
}
} catch {}
}
requestWakeLock();
// v4 browser half-open triggers: tab foreground / bfcache resume / network change all drive the
// SAME liveness check + #148 teardown-first reconnect (checkLiveness -> connect() only if the
// socket is armed+connected+silent-past-window). checkLiveness no-ops on a healthy socket, so a
// visibility change does NOT spawn a duplicate socket (the classic browser bug).
document.addEventListener('visibilitychange', () => {
// On becoming visible after a (possibly throttled/frozen) background stint, do the fresh
// liveness check — reset the grace and reconnect ONLY if genuinely dead; never spuriously
// tear down a live socket on the hidden->visible gap.
if (document.visibilityState === 'visible') { requestWakeLock(); verifyLivenessSoon(); }
else if (glTxAbort) { glTxAbort(); } // tab hidden mid-wipe: hard-cut NOW (before rAF freezes) so nothing sticks
});
// pairing-race fix: pageshow fires on EVERY load (persisted=false), not only bfcache restores.
// Unfiltered it ran verifyLivenessSoon() on the initial cold load, which opened the socket EARLY
// (if !socket -> connect) and registered a pairing code — then the intended boot path (tap-overlay
// / connectBtnFunc) called connect() again, tearing that socket down and recreating it (the
// connect->register->disconnect->reconnect flap that collided with the server's offline grace and
// produced the UNIQUE pairing_code collision). Only act on a REAL bfcache restore (persisted=true),
// mirroring the pagehide guard above; the cold-boot connect is owned solely by the boot path.
window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) verifyLivenessSoon(); }); // sleep/resume via bfcache restore
window.addEventListener('online', verifyLivenessSoon); // network switch (wifi<->cellular)
// feat/offline-cause-log: display sleep / backgrounding proxy — screen off/on on a TV.
document.addEventListener('visibilitychange', () => {
emitDeviceEvent(document.hidden ? 'display_off' : 'display_on');
});
// feat/offline-cause-log: browser-side offline detection feeds link_lost on the next reconnect —
// if navigator goes offline during a disconnect gap, the drop was the local link (not upstream).
window.addEventListener('offline', () => { if (disconnectedAt) linkLostDuringGap = true; });
// Register service worker for offline content caching
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/player/sw.js').then(reg => {
console.log('Service Worker registered');
// When a new SW activates, reload so the fresh code takes effect immediately
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated' && navigator.serviceWorker.controller) {
console.log('New Service Worker activated — reloading for fresh code');
location.reload();
}
});
}
});
}, (err) => console.warn('SW registration failed:', err));
}
// ==================== Keyboard shortcuts ====================
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Reset config and go back to setup
if (confirm('Reset player and return to setup?')) {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(PLAYLIST_CACHE_KEY);
localStorage.removeItem(LAYOUT_CACHE_KEY);
location.reload();
}
}
if (e.key === 'f' || e.key === 'F11') {
e.preventDefault();
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen();
}
});
</script>
</body>
</html>