mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
3785 lines
209 KiB
HTML
3785 lines
209 KiB
HTML
<!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>
|
||
<!-- BrightSign bridge. NOT deferred: the player asks it who this device is and whether a
|
||
restart can be delegated to the host, and both questions are asked during boot. Off
|
||
BrightSign every method is a no-op, so this is inert in a browser. -->
|
||
<script src="/player/st-bridge.js"></script>
|
||
<!-- BrightSign native sync (SyncManager). Inert without the platform module, so the player
|
||
falls back to its own clock-derived group sync. -->
|
||
<script src="/player/st-sync.js"></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 won’t 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 d’appairage', 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 l’appareil', info_device_name: 'Nom de l’appareil', 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 l’intégration et ne s’affichera pas non plus sur l’appareil. Essayez une page qui l’autorise.',
|
||
},
|
||
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 ====================
|
||
// The BrightSign bridge, or null. Resolved before the storage keys because on a dual-output
|
||
// player it decides what those keys are called.
|
||
const BS = (typeof window !== 'undefined' && window.ScreenTinkerBS) || null;
|
||
const ON_BRIGHTSIGN = !!(BS && BS.isBrightSign());
|
||
|
||
// Per-output storage namespace. A dual-output BrightSign runs TWO widgets against one origin
|
||
// and one SD storage_path, so they share localStorage: without this the second output reads
|
||
// the first one's config and device id and the two collapse into a single device row. Empty
|
||
// for screen 1 and for every other platform, so nothing existing moves.
|
||
const SCREEN_SUFFIX = (() => {
|
||
try { return BS ? (BS.storageSuffix() || '') : ''; } catch (e) { return ''; }
|
||
})();
|
||
|
||
const STORAGE_KEY = 'rd_web_player' + SCREEN_SUFFIX;
|
||
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;
|
||
|
||
// Restarting the player — the only way any call site should do it.
|
||
//
|
||
// On BrightSign a page-initiated location.reload() does not reliably bring the roHtmlWidget
|
||
// back: a deploy on 2026-07-28 reloaded every connected player, and the BrightSign was the
|
||
// only one that never returned, while a browser on the same deploy was heartbeating minutes
|
||
// later. autorun.brs rebuilds the widget instead, which is a restart the OS actually
|
||
// performs.
|
||
//
|
||
// Falls through to reload() whenever the host isn't there to take the request — a browser, or
|
||
// a widget built without nodejs_enabled — so a caller is never left with nothing done.
|
||
function restartPlayer(reason) {
|
||
try {
|
||
if (BS && BS.hasHost() && BS.restart(reason)) {
|
||
console.log('[bs] restart delegated to host:', reason);
|
||
return;
|
||
}
|
||
} catch (e) { /* a broken bridge must not strand the player */ }
|
||
location.reload();
|
||
}
|
||
|
||
function getConfig() {
|
||
let cfg = {};
|
||
try { cfg = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { cfg = {}; }
|
||
// On BrightSign localStorage is bound to the page origin and its quota; the registry is
|
||
// not. When local storage comes back empty but the registry still knows who we are, adopt
|
||
// that identity instead of re-pairing — otherwise a provisioned panel spawns a second
|
||
// device row, which is the same duplicate-row failure the hardware-only fingerprint caused.
|
||
if (BS && !cfg.deviceId) {
|
||
try {
|
||
const known = BS.deviceId();
|
||
if (known) {
|
||
cfg.deviceId = known;
|
||
// Without the token the server cannot verify the claim and issues a NEW display,
|
||
// so the id alone is not an identity.
|
||
const tok = BS.deviceToken();
|
||
if (tok) cfg.deviceToken = tok;
|
||
cfg.paired = true;
|
||
}
|
||
} catch (e) { /* registry unavailable — carry on unpaired */ }
|
||
}
|
||
return cfg;
|
||
}
|
||
function saveConfig(cfg) {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
|
||
// Mirror identity into the registry so it survives a reboot, a content update or a storage
|
||
// wipe. Best-effort by design: failing to persist here must never break pairing itself.
|
||
if (BS && cfg && cfg.deviceId) {
|
||
try { BS.setIdentity(cfg.deviceId, cfg.serverUrl || null, cfg.deviceToken || null); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
const PLAYLIST_CACHE_KEY = 'rd_playlist_cache' + SCREEN_SUFFIX;
|
||
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' + SCREEN_SUFFIX;
|
||
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; }
|
||
}
|
||
|
||
// ==================== Identity reset (?reset=<token>) ====================
|
||
// A screen-only panel has no keyboard, no pointer and often no way to clear site data — but
|
||
// the URL it loads IS configurable from whatever manages it (the UniFi display UI, an MDM, a
|
||
// kiosk profile). This is the escape hatch for that: loading the player with ?reset=<token>
|
||
// discards this install's identity so it comes back as a brand-new device with a fresh
|
||
// pairing code. That is the recovery path when a panel is holding an identity that belongs to
|
||
// a different screen, and the ordinary one for redeploying a panel to another site.
|
||
//
|
||
// ONCE PER TOKEN, and that is the whole design. The configured URL is permanent — nobody
|
||
// goes back and removes the parameter — so a reset that fired on every load would re-pair the
|
||
// screen on every reboot and look exactly like a device that cannot hold its pairing. The
|
||
// applied token is remembered, so ?reset=1 left in the URL forever resets exactly once; to
|
||
// reset again, change it to any other value (?reset=2).
|
||
//
|
||
// serverUrl is deliberately preserved: we are being served BY that server, so it is
|
||
// known-good, and clearing it would strand a panel that cannot be typed into.
|
||
(function applyIdentityReset() {
|
||
let token;
|
||
try { token = new URLSearchParams(location.search).get('reset'); } catch (e) { return; }
|
||
if (!token) return;
|
||
try {
|
||
if (localStorage.getItem('st_reset_applied') === token) return; // already honoured
|
||
const cfg = getConfig();
|
||
delete cfg.deviceId; delete cfg.deviceToken; delete cfg.pairingCode;
|
||
cfg.paired = false;
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
|
||
localStorage.removeItem('st_install_id' + SCREEN_SUFFIX); // mint a NEW identity, not the old one
|
||
localStorage.removeItem(PLAYLIST_CACHE_KEY);
|
||
localStorage.removeItem(LAYOUT_CACHE_KEY);
|
||
localStorage.removeItem('st_group_sync');
|
||
localStorage.setItem('st_reset_applied', token);
|
||
console.warn('[reset] identity cleared by ?reset=' + token + ' — this panel will pair as a new device');
|
||
} catch (e) { /* storage unavailable: nothing to clear, and nothing to break */ }
|
||
})();
|
||
|
||
// ==================== 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 deferredRotationDeadline = null; // a deferral must not wait forever — see the #157 block
|
||
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 */ }
|
||
}
|
||
// A crash message on its own is not actionable. Three players died with
|
||
// "Cannot set properties of null (setting 'textContent')" and it could not be traced: the
|
||
// message names no file, and every candidate line in the CURRENT player was ruled out by
|
||
// inspection, which points at an older cached build still being served by the service
|
||
// worker — precisely the case where guessing from source is worthless. The error event
|
||
// already carries filename/lineno/colno; it was simply being discarded. Keep it.
|
||
//
|
||
// Deliberately compact: the server stores 200 chars (liveness.sanitizeExitReason), so this
|
||
// sends the message plus ONE location rather than a whole stack that would be truncated
|
||
// mid-frame. Only the basename is sent — the origin is already known from the device.
|
||
function crashDetail(message, file, line, col) {
|
||
let out = String(message || 'error');
|
||
try {
|
||
if (file) {
|
||
const base = String(file).split('/').pop().split('?')[0] || String(file);
|
||
out += ` @ ${base}:${line || 0}:${col || 0}`;
|
||
}
|
||
} catch (e) { /* a dying page must never throw */ }
|
||
return out.slice(0, 200);
|
||
}
|
||
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;
|
||
// A cross-origin script reports a bare "Script error." with no filename or line. Say so
|
||
// explicitly rather than emitting a location of :0:0 that reads like a real answer.
|
||
const msg = (ev.error && ev.error.message) || ev.message || 'error';
|
||
sendExitBeacon('crashed', ev.filename
|
||
? crashDetail(msg, ev.filename, ev.lineno, ev.colno)
|
||
: crashDetail(msg + ' (no location — cross-origin script)'));
|
||
});
|
||
window.addEventListener('unhandledrejection', (ev) => {
|
||
const r = ev && ev.reason;
|
||
const msg = (r && (r.message || String(r))) || 'unhandledrejection';
|
||
// A rejection carries no filename/lineno, so take the first stack frame instead.
|
||
let frame = null;
|
||
try {
|
||
if (r && typeof r.stack === 'string') {
|
||
const l = r.stack.split('\n').find(x => /:\d+:\d+/.test(x));
|
||
if (l) frame = l.trim().replace(/^at\s+/, '').slice(0, 120);
|
||
}
|
||
} catch (e) { /* never throw here */ }
|
||
sendExitBeacon('crashed', (frame ? `${msg} @ ${frame}` : String(msg)).slice(0, 200));
|
||
});
|
||
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)
|
||
// BrightSign native sync (SyncManager). An ALTERNATIVE to the clock-derived correction above,
|
||
// never an addition: when it is running, the seek/nudge maths is skipped entirely because the
|
||
// video element aligns itself once setSyncParams has been applied. Item SELECTION stays
|
||
// clock-derived either way — that is what keeps images and widgets, which have no
|
||
// setSyncParams, advancing together with the videos.
|
||
let nativeSync = null; // the ScreenTinkerBSSync instance while active
|
||
let nativeSyncEvent = null; // latest sync event awaiting a video to bind
|
||
let nativeSyncBoundId = null; // sync id already bound, so we attach once per session
|
||
let nativeSyncAnnounced = -1; // last index the LEADER announced, to announce once per advance
|
||
// 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;
|
||
// Honour an operator-set level if there is one; otherwise full, as before.
|
||
video.volume = (mediaVolume != null) ? mediaVolume : 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">🔇</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 ====================
|
||
// Hardware-only identity. Every input below is a property of the MODEL, not the unit: two
|
||
// identical panels report the same user agent, the same screen geometry, the same core count
|
||
// and the same canvas raster. So this value is shared by every display of that model
|
||
// ANYWHERE — it was never an identity, and treating it as one meant a second identical panel
|
||
// collided with the first (observed live: two UniFi Pro Displays at DIFFERENT sites both
|
||
// producing web-m73u8w-5f). It is kept because it is still a useful HINT for reuniting a
|
||
// wiped panel with its own row, but it is no longer proof of which unit is calling.
|
||
function generateHardwareFingerprint() {
|
||
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);
|
||
}
|
||
|
||
// The identity the server matches on: hardware PLUS a random per-INSTALL salt, so two
|
||
// identical panels are distinguishable from the first connection — which hardware alone can
|
||
// never be. The salt is minted once and kept in localStorage. Clearing storage mints a new
|
||
// one, which is correct: that IS a new install, and the hw_fingerprint sent alongside is what
|
||
// lets the server offer the old row back when it can do so unambiguously.
|
||
//
|
||
// Falls back to the bare hardware value when storage is unavailable (private mode, a locked
|
||
// down kiosk). That reintroduces the collision for those clients alone rather than leaving
|
||
// them with no identity at all, and the server treats an ambiguous hardware value as an
|
||
// unknown device and provisions a fresh one — the safe outcome either way.
|
||
function generateBrowserFingerprint() {
|
||
const hw = generateHardwareFingerprint();
|
||
let salt = null;
|
||
try {
|
||
salt = localStorage.getItem('st_install_id' + SCREEN_SUFFIX);
|
||
if (!salt) {
|
||
const buf = new Uint8Array(16);
|
||
(window.crypto || window.msCrypto).getRandomValues(buf);
|
||
salt = Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
|
||
localStorage.setItem('st_install_id' + SCREEN_SUFFIX, salt);
|
||
}
|
||
} catch (e) { salt = null; }
|
||
return salt ? hw + '-' + salt.slice(0, 16) : hw;
|
||
}
|
||
|
||
// ==================== Boot ====================
|
||
|
||
// Function used by connect button and auto-connect
|
||
let autoContinueTimer;
|
||
|
||
// Shared "editable URL + short countdown" behaviour. Used on first boot AND on
|
||
// recovery (server unpaired / rejected us). A display panel usually has no keyboard
|
||
// or pointer, so anything that WAITS for a click is a dead end there — the countdown
|
||
// is what lets an input-less screen heal itself. The field stays editable the whole
|
||
// time for the case where someone IS standing there with a remote and needs to point
|
||
// the player somewhere else; typing cancels the countdown so it can't yank the form
|
||
// out from under them mid-edit.
|
||
function cancelAutoContinue() {
|
||
if (!autoContinueTimer) return;
|
||
clearInterval(autoContinueTimer);
|
||
autoContinueTimer = null;
|
||
document.getElementById('connectBtn').textContent = _t('connect');
|
||
}
|
||
function startAutoContinue(seconds) {
|
||
cancelAutoContinue();
|
||
let countdown = seconds;
|
||
const connectBtn = document.getElementById('connectBtn');
|
||
connectBtn.disabled = false;
|
||
connectBtn.textContent = `${_t('connect')} (${countdown})`;
|
||
autoContinueTimer = setInterval(() => {
|
||
countdown--;
|
||
if (countdown > 0) {
|
||
connectBtn.textContent = `${_t('connect')} (${countdown})`;
|
||
} else {
|
||
cancelAutoContinue();
|
||
connectBtnFunc();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function connectBtnFunc() {
|
||
cancelAutoContinue();
|
||
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.
|
||
startAutoContinue(5);
|
||
}
|
||
} // #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;
|
||
// Bound once, not per countdown: startAutoContinue() can run more than once in a
|
||
// session (first boot, then again if the server unpairs us), and re-binding here
|
||
// would stack a listener each time.
|
||
document.getElementById('serverUrl').addEventListener('input', cancelAutoContinue);
|
||
|
||
// ==================== #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); return; } // never connected / torn down -> establish
|
||
// socket connected -> grace reset above; the watchdog verifies over the next window.
|
||
// socket present-but-DISCONNECTED used to be left to "socket.io's own reconnection", which is
|
||
// wrong for the disconnects socket.io does not retry ('io server disconnect'). A resume is
|
||
// exactly when a stranded panel should get another go, so hand it to the supervisor.
|
||
if (!socket.connected) {
|
||
if (!disconnectedSinceMs) disconnectedSinceMs = Date.now();
|
||
startReconnectSupervisor();
|
||
}
|
||
}
|
||
function startWatchdog() { stopWatchdog(); watchdogTimer = setInterval(checkLiveness, 10000); }
|
||
function stopWatchdog() { if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; } }
|
||
|
||
// ---- Reconnect supervisor -------------------------------------------------------------
|
||
// The watchdog above only covers a HALF-OPEN socket — one that still claims to be connected
|
||
// while the server has gone quiet. It cannot help once a socket is known to be down, because
|
||
// the disconnect handler stops it. That left a gap: socket.io retries most disconnects, but on
|
||
// 'io server disconnect' it deliberately stands down, and on an explicit client teardown it
|
||
// must not retry. So a server-closed socket had NOTHING watching it and the player stayed
|
||
// down until a human reloaded the page — which is what stranded a live panel.
|
||
//
|
||
// This supervisor is the backstop for exactly that. It only ever re-establishes a socket that
|
||
// is genuinely not connected, and it waits out a grace period first so socket.io's own
|
||
// reconnection (1s backing off to 30s) gets to do the job on the disconnects it does own.
|
||
let reconnectSupervisorTimer = null;
|
||
let disconnectedSinceMs = 0;
|
||
const RECONNECT_SUPERVISOR_TICK_MS = 15000;
|
||
const RECONNECT_GRACE_MS = 45000; // > socket.io's 30s max backoff, so we never race it
|
||
|
||
// 'io client disconnect' is OUR OWN teardown (connect() closes the previous socket before
|
||
// opening the next). Supervising that would fight the reconnect already in progress.
|
||
function shouldSuperviseReconnect(reason) { return reason !== 'io client disconnect'; }
|
||
|
||
// Pure decision, mirroring watchdogShouldReconnect: re-establish only a socket that is really
|
||
// down and has stayed down past the grace period.
|
||
function shouldForceReconnect(connected, sinceMs, nowMs, graceMs) {
|
||
if (connected) return false;
|
||
if (!sinceMs) return false;
|
||
return (nowMs - sinceMs) >= graceMs;
|
||
}
|
||
|
||
function startReconnectSupervisor() {
|
||
if (reconnectSupervisorTimer) return;
|
||
reconnectSupervisorTimer = setInterval(() => {
|
||
if (PREVIEW_MODE) return;
|
||
if (socket && socket.connected) { stopReconnectSupervisor(); return; }
|
||
if (!shouldForceReconnect(!!(socket && socket.connected), disconnectedSinceMs, Date.now(), RECONNECT_GRACE_MS)) return;
|
||
console.warn('[reconnect] socket still down after ' + Math.round((Date.now() - disconnectedSinceMs) / 1000) + 's — re-establishing');
|
||
disconnectedSinceMs = Date.now(); // restart the grace so we retry on a cadence, not a spin
|
||
connect(config.serverUrl); // #148 teardown-before-reopen
|
||
}, RECONNECT_SUPERVISOR_TICK_MS);
|
||
}
|
||
function stopReconnectSupervisor() {
|
||
if (reconnectSupervisorTimer) { clearInterval(reconnectSupervisorTimer); reconnectSupervisorTimer = null; }
|
||
disconnectedSinceMs = 0;
|
||
}
|
||
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) {
|
||
// BrightSign's registry is ASYNCHRONOUS (registry.read returns a Promise), so on a cold
|
||
// boot the identity is not in hand yet when this first runs. Registering before it lands
|
||
// would pair the panel as a NEW display and strand its real row — so wait once, then adopt
|
||
// whatever the registry knows and carry on. onReady always fires (success, failure, or a
|
||
// 5s cap inside the bridge), so this can defer boot but never block it.
|
||
if (BS && !BS.isReady()) {
|
||
BS.onReady(() => {
|
||
try {
|
||
const known = BS.deviceId();
|
||
if (known && !config.deviceId) {
|
||
config.deviceId = known;
|
||
const tok = BS.deviceToken();
|
||
if (tok) config.deviceToken = tok;
|
||
config.paired = true;
|
||
console.log('[bs] adopted identity from registry:', known, tok ? '(with token)' : '(NO TOKEN — will re-pair)');
|
||
}
|
||
} catch (e) { /* carry on unpaired rather than not at all */ }
|
||
connect(serverUrl);
|
||
});
|
||
return;
|
||
}
|
||
|
||
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');
|
||
stopReconnectSupervisor(); // back up; stand the backstop down
|
||
register();
|
||
});
|
||
|
||
socket.on('disconnect', (reason) => {
|
||
console.log('Disconnected', reason || '');
|
||
stopHeartbeat();
|
||
stopWatchdog(); // the watchdog is for HALF-OPEN only; a known-down socket is the supervisor's job
|
||
// 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.io does NOT retry every disconnect. On 'io server disconnect' it deliberately
|
||
// stands down and waits to be told to reconnect — so a server that closes a socket (a
|
||
// handler throwing, a deploy, an eviction) left this player down FOREVER, with the
|
||
// watchdog stopped above and nothing else watching. That happened to a real panel: it
|
||
// sat dark until someone reloaded it by hand. Supervise every disconnect we did not
|
||
// ourselves initiate.
|
||
if (shouldSuperviseReconnect(reason)) {
|
||
disconnectedSinceMs = Date.now();
|
||
startReconnectSupervisor();
|
||
}
|
||
});
|
||
|
||
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...');
|
||
});
|
||
|
||
// Server no longer accepts our identity (row deleted, or token rejected). Drop the
|
||
// stale credentials and get a NEW pairing code on screen without anyone touching the
|
||
// panel: config.serverUrl is known-good — we are talking to that server right now —
|
||
// so there is nothing for a human to re-enter. The old code showed the URL form and
|
||
// HID the pairing section, which on a screen-only display is a dead end: it asks for
|
||
// typing that cannot happen, and hides the one thing that would rescue it. Recovery
|
||
// then needed a physical reload. The Android player already does this correctly
|
||
// (ProvisioningActivity "repair mode"); this brings the web player in line.
|
||
function enterRepairMode(message) {
|
||
delete config.deviceId;
|
||
delete config.deviceToken;
|
||
config.paired = false;
|
||
saveConfig(config);
|
||
document.getElementById('setupScreen').style.display = 'flex';
|
||
document.getElementById('urlForm').style.display = 'block'; // still editable
|
||
document.getElementById('pairingSection').style.display = 'none'; // until a code arrives
|
||
document.getElementById('setupStatus').textContent = message;
|
||
// Reconnecting re-registers with no device_id, so the server issues a fresh pairing
|
||
// code and the device:registered handler reveals pairingSection.
|
||
startAutoContinue(10);
|
||
}
|
||
|
||
socket.on('device:unpaired', () => {
|
||
console.warn('Device not found on server — clearing credentials');
|
||
savePlaylistCache([]);
|
||
enterRepairMode('Device was removed from the server. Re-pairing…');
|
||
});
|
||
|
||
socket.on('device:auth-error', (data) => {
|
||
console.warn('Device auth rejected:', data?.error || 'unknown');
|
||
enterRepairMode('This device needs to be re-paired. Getting a new code…');
|
||
});
|
||
|
||
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') restartPlayer('operator refresh');
|
||
if (data.type === 'launch') { document.getElementById('screenOffOverlay')?.remove(); screenIsOff = false; suppressMedia(false); setDisplayPower(true); }
|
||
if (data.type === 'screen_off') toggleScreenOff();
|
||
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); screenIsOff = false; suppressMedia(false); setDisplayPower(true); }
|
||
// A browser tab cannot reboot its host, so the web player has always ignored this and the
|
||
// dashboard button did nothing on it. A BrightSign can, through the host script.
|
||
if (data.type === 'reboot') {
|
||
if (BS && BS.reboot()) console.log('[bs] reboot requested via host');
|
||
else console.log('reboot: not supported on this player');
|
||
}
|
||
// Media volume, 0-100 from the dashboard. Applies to whatever is playing now and is
|
||
// remembered for items mounted later.
|
||
if (data.type === 'set_volume') {
|
||
const pct = Number(data.payload?.value ?? data.value);
|
||
if (isFinite(pct)) setMediaVolume(Math.max(0, Math.min(100, pct)) / 100);
|
||
}
|
||
});
|
||
|
||
// #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;
|
||
// A BrightSign must say so rather than registering as "Chrome 120", because the sync
|
||
// resolver (server/lib/sync-backend.js) decides native-vs-ours from exactly this field —
|
||
// and a wall that reports itself as a browser can never be offered BrightWall.
|
||
data.platform = ON_BRIGHTSIGN ? 'brightsign' : browserPlatform();
|
||
if (ON_BRIGHTSIGN) {
|
||
try {
|
||
data.bs_model = BS.model() || null;
|
||
data.bs_os_version = BS.osVersion() || null;
|
||
data.bs_serial = BS.serial() || null;
|
||
// Which physical output this widget paints. autorun.brs gives the second output its
|
||
// own widget with &screen=2, so two rows from one player stay distinguishable.
|
||
data.bs_screen = BS.screen();
|
||
data.sync_backend = BS.syncBackend();
|
||
} catch (e) { /* identity extras are additive — never block registration */ }
|
||
}
|
||
data.contract_version = 'v4';
|
||
// Device identity. `fingerprint` is per-INSTALL and is what the server matches on.
|
||
// `hw_fingerprint` is the old hardware-only value, sent alongside so a panel whose storage
|
||
// was wiped can still be rematched to its row — but only when that value is unambiguous.
|
||
data.fingerprint = generateBrowserFingerprint();
|
||
data.hw_fingerprint = generateHardwareFingerprint();
|
||
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,
|
||
// 'Web Player' is a placeholder standing in a WiFi column. On a BrightSign it is
|
||
// actively wrong — the one we have is PoE over Ethernet — and an operator reading it
|
||
// as an SSID has been told something false. Null means "not on WiFi", which is true,
|
||
// and the dashboard shows a real hardware block for this family instead.
|
||
wifi_ssid: ON_BRIGHTSIGN ? null : '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(),
|
||
// Real values where the platform has them (temperature, storage quota). Spread LAST so
|
||
// it overrides the nulls above, and empty off-platform so nothing else changes.
|
||
...(BS ? BS.telemetrySnapshot() : {}),
|
||
}
|
||
});
|
||
}, 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);
|
||
// THE deploy path — this is the exact line that darkened a BrightSign panel on
|
||
// 2026-07-28, when every connected player self-reloaded and that one never returned.
|
||
restartPlayer('server code updated');
|
||
}
|
||
}).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
|
||
// Video and YouTube were excluded here on the grounds that they "self-advance via their own
|
||
// end handlers" — but the handler that is live right now was built for the mode we have just
|
||
// LEFT. A group-rendered video was created with `loop = !!groupSync` and a wall-follower
|
||
// video with `isFollower` true, and both are captured in the closure at render time. So on
|
||
// leaving a sync group or a wall, that element loops forever and nothing re-renders: the
|
||
// screen sits on one clip permanently, and later refreshes take the "unchanged" branch
|
||
// because the <video> is attached, playing and un-errored, i.e. healthy.
|
||
//
|
||
// Re-render whatever is on screen instead of guessing which types can look after themselves.
|
||
const mediaEl = document.querySelector('#playerContainer video');
|
||
const staleLoop = !!(mediaEl && mediaEl.loop);
|
||
const needsSoloTimer = !!item.widget_id
|
||
|| (typeof item.mime_type === 'string' && item.mime_type.startsWith('image/'))
|
||
|| staleLoop
|
||
|| item.mime_type === 'video/youtube';
|
||
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) {
|
||
// period === 0 means every item is currently outside its daypart. Solo playback routes the
|
||
// same condition into showNothingScheduled(); group playback just returned, so nothing was
|
||
// watching — group members are scheduleDriven, so renderContent arms no advanceTimer, and
|
||
// a group-rendered video is created with loop = !!groupSync. Out of hours the whole group
|
||
// therefore kept displaying (or looping) whatever had been in-window last, while an
|
||
// identical ungrouped screen correctly showed the idle card.
|
||
if (isPlaying) {
|
||
teardownCurrentMedia();
|
||
showNothingScheduled();
|
||
}
|
||
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;
|
||
// LEADER ONLY: open a new sync session for the item we just moved to. The id must change
|
||
// on every advance or the followers' 1Hz dedupe swallows it and the group sits on the
|
||
// previous item forever.
|
||
if (nativeSync && groupSync.is_leader && nativeSyncAnnounced !== t.index) {
|
||
nativeSyncAnnounced = t.index;
|
||
const key = (playlist[t.index]?.content_id || playlist[t.index]?.id || t.index);
|
||
nativeSync.announce(key, Date.now());
|
||
}
|
||
} else if (nativeSync) {
|
||
// Native sync owns alignment: setSyncParams has the element keeping itself in step, so the
|
||
// seek/nudge maths below would fight it — every correction we applied would be a frame the
|
||
// player then had to undo. Just make sure the current item is bound.
|
||
bindNativeSyncVideo();
|
||
action = 'native';
|
||
} 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).
|
||
// Enter/leave BrightSign native sync for this group. Returns true if it is running afterwards.
|
||
// Deliberately fails CLOSED: if the module is missing, the platform is not BrightSign, or the
|
||
// session will not start, we return false and the caller keeps the clock-derived path — a group
|
||
// that silently ran neither protocol would drift with no indication of why.
|
||
function applyNativeSync(cfg) {
|
||
const want = cfg && cfg.backend === 'brightsign';
|
||
if (!want || !global_ScreenTinkerBSSync() || !global_ScreenTinkerBSSync().available()) {
|
||
if (nativeSync) { try { nativeSync.stop(); } catch (e) {} }
|
||
nativeSync = null; nativeSyncEvent = null; nativeSyncBoundId = null; nativeSyncAnnounced = -1;
|
||
if (want) groupReport('warn', 'native sync requested but SyncManager is unavailable — using clock sync');
|
||
return false;
|
||
}
|
||
if (nativeSync) { try { nativeSync.stop(); } catch (e) {} nativeSync = null; }
|
||
nativeSyncEvent = null; nativeSyncBoundId = null; nativeSyncAnnounced = -1;
|
||
|
||
const s = global_ScreenTinkerBSSync().create({ domain: 'ST-' + String(cfg.group_id).slice(0, 8) });
|
||
// The leader receives its OWN broadcast and starts from that, like every follower — starting
|
||
// at announce() time instead would put it ahead of the group by the width of the network.
|
||
s.onItem = function (ev) { nativeSyncEvent = ev; bindNativeSyncVideo(); };
|
||
if (!s.start(!!cfg.is_leader)) {
|
||
groupReport('warn', 'native sync failed to start — using clock sync');
|
||
return false;
|
||
}
|
||
nativeSync = s;
|
||
console.log('[native-sync] started as ' + (cfg.is_leader ? 'LEADER' : 'follower') + ' group=' + cfg.group_id);
|
||
groupReport('info', 'native sync started (' + (cfg.is_leader ? 'leader' : 'follower') + ')');
|
||
return true;
|
||
}
|
||
function global_ScreenTinkerBSSync() {
|
||
return (typeof window !== 'undefined' && window.ScreenTinkerBSSync) || null;
|
||
}
|
||
// Bind the mounted video to the current session, exactly once per sync id. The event can arrive
|
||
// before the element exists (the leader announces as it advances), so this is called both from
|
||
// the event and from the tick — whichever wins, the guard makes it idempotent.
|
||
function bindNativeSyncVideo() {
|
||
if (!nativeSync || !nativeSyncEvent) return;
|
||
if (nativeSyncEvent.id === nativeSyncBoundId) return;
|
||
if (!currentVideoEl) return; // images/widgets have no setSyncParams to bind
|
||
if (nativeSync.attachVideo(currentVideoEl, nativeSyncEvent)) {
|
||
nativeSyncBoundId = nativeSyncEvent.id;
|
||
groupReport('info', 'native sync bound id=' + String(nativeSyncEvent.id).slice(0, 24));
|
||
}
|
||
}
|
||
|
||
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) {
|
||
applyNativeSync(null);
|
||
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
|
||
// Tell the host which protocol won, so a cold boot with no network starts in the right mode.
|
||
try { if (BS && cfg.backend) BS.setSyncBackend(cfg.backend); } catch (e) { /* not on BrightSign */ }
|
||
if (cfg.sync_downgraded && cfg.sync_reason) {
|
||
groupReport('warn', 'sync downgraded to ' + cfg.backend + ': ' + cfg.sync_reason);
|
||
}
|
||
applyNativeSync(cfg);
|
||
console.log('[group-sync] group=' + cfg.group_id + ' backend=' + (cfg.backend || 'screentinker')
|
||
+ ' (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">⚠</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).
|
||
// widget_rev is in here for the same reason as schedules and transition: a widget's IDENTITY
|
||
// does not change when it is EDITED, so a content edit produced an identical fingerprint, the
|
||
// update was treated as "unchanged", and the screen kept the old render until a reload.
|
||
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.widget_rev || ''}|${a.zone_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). A plain refresh re-aligns the schedule
|
||
// locally (no server round-trip needed).
|
||
//
|
||
// The clock-derived protocol has no leader and one mode, so the group id alone used to be
|
||
// enough. Native sync has both: switching protocol, or leadership moving because the old
|
||
// leader went offline, changes what THIS player must do — and neither changes the group id.
|
||
// Keying on the id alone would leave a player running the old protocol, or leave a promoted
|
||
// leader silently not announcing, until something unrelated forced a re-enter.
|
||
const groupKey = (g) => (g ? [g.group_id, g.backend || '', g.is_leader ? 'L' : 'f'].join('|') : '');
|
||
const groupChanged = groupKey(groupSync) !== groupKey(data.group_sync);
|
||
if (groupChanged) applyGroupSync(data.group_sync || null);
|
||
else if (groupSync) groupScheduleTick();
|
||
|
||
// The layout is not part of the item list, so a change to it can never show up in the item
|
||
// fingerprint — and in multi-zone mode nothing else re-renders: each zone runs its own timers
|
||
// and renderContent is not called again. So editing zones, moving an item between zones,
|
||
// switching layout or clearing it did nothing at all on a screen already in a layout, for as
|
||
// long as the item list happened to stay the same. Tizen's ZoneRenderer has always compared a
|
||
// zone signature; this is the web equivalent.
|
||
const layoutSig = (l) => !l ? '' : [
|
||
l.id,
|
||
...(l.zones || []).map(z => [z.id, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
|
||
z.z_index, z.zone_type, z.fit_mode].join(':')).sort(),
|
||
].join('|');
|
||
const layoutChanged = layoutSig(layout) !== layoutSig(data.layout || null);
|
||
layout = data.layout || null;
|
||
saveLayoutCache(layout);
|
||
playerTimezone = data.timezone || null; // #74/#75: effective tz for schedule eval
|
||
|
||
if (newFp === oldFp && playlist.length > 0 && !wallChanged && !layoutChanged) {
|
||
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;
|
||
// ...unless it is a WIDGET whose content was edited. Identity is content/widget id, and
|
||
// editing a widget does not change its id, so an edited widget "survives" and the
|
||
// re-render is skipped — which is exactly why an edit never reached the screen. The new
|
||
// revision sat in the playlist unused, because a solo widget deliberately never
|
||
// re-renders on a timer (that would reset a directory board's scroll). Re-render through
|
||
// the buffered swap, which is flash-free by design, so this costs nothing visually.
|
||
const oldItem = oldPlaylist[oldAnchorIdx];
|
||
const newItem = playlist[stillThereIdx];
|
||
if (newItem && newItem.widget_id && oldItem && (oldItem.widget_rev || 0) !== (newItem.widget_rev || 0)) {
|
||
console.log('Widget edited - re-rendering in place');
|
||
renderContent(newItem);
|
||
}
|
||
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;
|
||
// #157 defers so a live item is not yanked mid-play — but it assumes an advance is coming,
|
||
// and for a ONE-ITEM playlist that is never true. Single-item rendering deliberately never
|
||
// advances: a video gets `loop = (playlist.length === 1)`, a YouTube embed the same, and a
|
||
// solo widget is "held" on a self-re-arming refresh that never calls nextItem (reloading it
|
||
// would reset a directory board's scroll). So replacing the single item of a one-item
|
||
// playlist deferred forever — the old promo, board or clip kept playing while the dashboard
|
||
// showed the new playlist published and the device healthy. Only a reboot or a refresh
|
||
// command cleared it.
|
||
const outgoingNeverAdvances = oldPlaylist.length <= 1;
|
||
if (isPlaying && !scheduleDriven && !outgoingNeverAdvances) {
|
||
deferredRotation = true;
|
||
deferredSuccessorId = itemIdentity(playlist[nextIdx]);
|
||
console.log('#157: current item removed but still live — deferring rotation-out');
|
||
// Safety net for anything else that turns out not to advance: a deferral is a bet that
|
||
// one is coming, and if it never arrives the change must still land rather than strand
|
||
// the screen on content the operator has already replaced.
|
||
if (deferredRotationDeadline) clearTimeout(deferredRotationDeadline);
|
||
deferredRotationDeadline = setTimeout(() => {
|
||
if (!deferredRotation) return;
|
||
console.warn('#157: deferred rotation never got an advance — applying it now');
|
||
deferredRotation = false;
|
||
const di = deferredSuccessorId ? playlist.findIndex(x => itemIdentity(x) === deferredSuccessorId) : -1;
|
||
startPlaybackAt(di === -1 ? 0 : di);
|
||
}, 60000);
|
||
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)) {
|
||
// A widget item carries its id in widget_id and has NO content_id, so sending only
|
||
// content_id logged widget plays with nothing attached — the row existed but named
|
||
// neither what played nor which widget, and Reports read empty for any screen showing
|
||
// one. Send both; the server writes whichever column the id belongs in.
|
||
socket.emit('device:play-event', {
|
||
device_id: config.deviceId,
|
||
event: 'play_start',
|
||
content_id: item.content_id || null,
|
||
widget_id: item.widget_id || null,
|
||
content_name: item.filename || item.widget_name || item.title || null,
|
||
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;
|
||
if (deferredRotationDeadline) { clearTimeout(deferredRotationDeadline); deferredRotationDeadline = null; }
|
||
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 || null,
|
||
widget_id: playlist[currentIndex].widget_id || null,
|
||
content_name: playlist[currentIndex].filename || playlist[currentIndex].widget_name
|
||
|| playlist[currentIndex].title || null,
|
||
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||'')}&rev=${item.widget_rev||0}`;
|
||
// 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||'')}&rev=${item.widget_rev||0}`;
|
||
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||'')}&rev=${a.widget_rev||0}`;
|
||
// 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;
|
||
// A zone video advanced ONLY on `ended`, with no error handler and — alone among the zone
|
||
// branches — no timer either. A 404, an unreachable remote_url, an undecodable clip, or an
|
||
// `ended` that simply never fires left that region black for days while the other zones
|
||
// kept rotating: the screen looks half broken and nothing self-heals. The fullscreen web
|
||
// path and Tizen's ZoneRenderer both already carry these two guards.
|
||
video.onerror = advance;
|
||
zoneTimers[zone.id] = setTimeout(advance, dur + 5000);
|
||
}
|
||
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) {
|
||
const overlay = document.getElementById('statusOverlay');
|
||
if (!overlay) return;
|
||
overlay.style.display = 'flex';
|
||
// #statusText can be GONE: the suspended-account branch replaces the whole overlay with its
|
||
// own markup, which does not contain it. Reading .textContent off null then threw a TypeError
|
||
// out of every later showStatus call — the player reported itself "crashed" on each refresh
|
||
// beat, and worse, showNothingScheduled() throws BEFORE arming its 30s re-check, so a screen
|
||
// whose dayparts had all closed was stranded on the stale suspended card with no retry.
|
||
// Rebuild the element rather than bail, so the message the caller wanted is actually shown.
|
||
let text = document.getElementById('statusText');
|
||
if (!text) {
|
||
overlay.innerHTML = '';
|
||
text = document.createElement('p');
|
||
text.id = 'statusText';
|
||
text.style.cssText = 'color:#94a3b8;font-size:20px;font-family:sans-serif;text-align:center';
|
||
overlay.appendChild(text);
|
||
}
|
||
text.textContent = msg;
|
||
}
|
||
|
||
function hideStatus() {
|
||
document.getElementById('statusOverlay').style.display = 'none';
|
||
}
|
||
|
||
// Real display power, where the platform has it. The overlay only paints the screen black:
|
||
// the panel stays lit, drawing power and at risk of burn-in. On BrightSign we can tell the
|
||
// display itself to sleep over CEC. Best effort — some displays ignore broadcast CEC — so the
|
||
// overlay is applied regardless and something visible always happens.
|
||
function setDisplayPower(on) {
|
||
if (!BS) return false;
|
||
try { return BS.displayPower(on); } catch (e) { return false; }
|
||
}
|
||
|
||
// Volume that survives the next item. Media elements are created per item, so remembering the
|
||
// level is what makes a volume command stick rather than lasting until the playlist advances.
|
||
let mediaVolume = null;
|
||
function setMediaVolume(v) {
|
||
mediaVolume = v;
|
||
// Wall followers stay silent by design; don't override that.
|
||
try { if (typeof isWallFollower === 'function' && isWallFollower()) return; } catch (e) { /* not a wall */ }
|
||
document.querySelectorAll('video, audio').forEach((el) => {
|
||
try { el.volume = v; el.muted = v === 0; } catch (e) { /* element torn down mid-call */ }
|
||
});
|
||
}
|
||
|
||
// Media elements are created per item across several code paths — fullscreen, zones, the
|
||
// preloader — so setting volume once only lasts until the playlist advances. Catching 'play'
|
||
// in the CAPTURE phase applies it to every element that ever starts, from one place. Media
|
||
// events do not bubble, which is why capture is required rather than a plain listener.
|
||
document.addEventListener('play', (e) => {
|
||
const el = e.target;
|
||
if (!el || (el.tagName !== 'VIDEO' && el.tagName !== 'AUDIO')) return;
|
||
// The playlist advances while the screen is off; hold each new item down as it starts,
|
||
// or the next video appears on the hardware plane and the screen lights back up.
|
||
if (screenIsOff) {
|
||
try { el.pause(); el.removeAttribute('src'); el.load(); el.style.visibility = 'hidden'; } catch (err) {}
|
||
return;
|
||
}
|
||
if (mediaVolume == null) return;
|
||
try { if (typeof isWallFollower === 'function' && isWallFollower()) return; } catch (err) { /* not a wall */ }
|
||
try { el.volume = mediaVolume; el.muted = mediaVolume === 0; } catch (err) { /* gone */ }
|
||
}, true);
|
||
|
||
// Screen-off state, tracked because the playlist keeps advancing while the screen is "off"
|
||
// and every newly mounted item has to be held down too.
|
||
let screenIsOff = false;
|
||
|
||
// Stop media rather than cover it. On BrightSign the widget runs with hardware z-order
|
||
// (hwz), so video decodes onto a HARDWARE PLANE and a DOM overlay — which lives in the
|
||
// graphics plane — cannot hide it. The overlay goes up and the video plays straight through
|
||
// it. Pausing and hiding the element is what actually blanks the screen there, and it is
|
||
// harmless everywhere else.
|
||
function suppressMedia(off) {
|
||
if (!off) {
|
||
// Coming back: re-mount current content rather than trying to resurrect a torn-down
|
||
// element. nextItem() rebuilds from scratch, which is the same path a normal advance uses.
|
||
document.querySelectorAll('video, audio').forEach((el) => { try { el.style.visibility = ''; } catch (e) {} });
|
||
try { if (typeof nextItem === 'function') nextItem(); } catch (e) { /* fall back to whatever is on screen */ }
|
||
return;
|
||
}
|
||
document.querySelectorAll('video, audio').forEach((el) => {
|
||
try {
|
||
el.pause();
|
||
// Pausing is not enough on a hardware plane: the last decoded frame STAYS on screen,
|
||
// which is why the panel showed a frozen image rather than going black. Tearing the
|
||
// source down releases the plane. Hiding the element does nothing to it either — the
|
||
// plane is not part of the DOM.
|
||
el.removeAttribute('src');
|
||
el.load();
|
||
el.style.visibility = 'hidden';
|
||
} catch (e) { /* element torn down mid-call */ }
|
||
});
|
||
}
|
||
|
||
function toggleScreenOff() {
|
||
let overlay = document.getElementById('screenOffOverlay');
|
||
if (overlay) { overlay.remove(); screenIsOff = false; suppressMedia(false); setDisplayPower(true); return; }
|
||
screenIsOff = true;
|
||
suppressMedia(true);
|
||
setDisplayPower(false);
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
|
||
// 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');
|
||
restartPlayer('service worker activated');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}, (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);
|
||
// The registry outlives localStorage, and getConfig() re-adopts the identity it finds
|
||
// there. Without this the reset would appear to work and the panel would come straight
|
||
// back paired as the same display — a reset that resets nothing.
|
||
if (BS) { try { BS.clearIdentity(); } catch (e) { /* best effort */ } }
|
||
restartPlayer('operator reset');
|
||
}
|
||
}
|
||
if (e.key === 'f' || e.key === 'F11') {
|
||
e.preventDefault();
|
||
if (document.fullscreenElement) document.exitFullscreen();
|
||
else document.documentElement.requestFullscreen();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|