screentinker/server/player/index.html
ScreenTinker 3d9ef039e5 Make the volume slider real, and stop an empty playlist wiping the cache
Three faults in the web player, each found by driving the shipped code in a
browser against the real server rather than by reading it.

set_volume did nothing at all. The dashboard sends `{ level: 0..1 }`
(device-detail.js: slider/100) and the Android player reads exactly that; this
player read `payload.value` and divided it by 100. Nothing in the product sends
`value`, so every browser panel acked the command and ignored it — the quietest
possible failure. Correcting only the key would have been worse than leaving it
broken: `level: 0.5` would have become 0.5%, which is inaudible and looks fixed.
The fraction is now canonical, `value` is still read as a percentage for
anything written against the old handler, and the scale is chosen by WHICH KEY
arrived rather than by the size of the number — 1 is legal in both conventions,
so a magnitude guess is guaranteed to be wrong for somebody. Parsing moved into
volumeLevelFromCommand() so it can be asserted without a socket.

setMediaVolume() also wrote `el.muted = (v === 0)`, so any non-zero volume
un-muted whatever was playing. An item an operator had deliberately silenced
started making noise the moment anyone touched the slider — reproduced live:
item flagged muted, one set_volume, muted went false. Mute has four inputs and
a fixed order (lib/media-mute.js), it is resolved when the element is mounted,
and a level is not entitled to overrule it — least of all the autoplay rule,
where unmuting without a gesture costs the video rather than winning the audio.
Volume 0 is silence on its own.

And the service worker pruned its content cache to an EMPTY keep-set.
`assignments: []` is what the server sends for a device between playlists, for a
playlist never published, and inside the `catch` when a published_snapshot fails
to parse — none of which mean "delete the media". Reproduced: three cached
assets, one empty payload, cache emptied. That is only survivable while the
uplink is up, which is precisely when the offline cache is worthless. A cache
kept too long costs disk the quota reclaims anyway.

Verified in Chrome against a live server: volume 0.42/0.8/0/0.25 land on the
element and survive an item change, a muted item stays muted through a volume
command, and three cached assets survive an empty push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:18:25 -05:00

4312 lines
240 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<!--
Player debug error trap. MUST stay first inside <head> after the two
mandatory <meta> tags (charset must come first per HTML spec). Runs
before any other script so we capture errors even from parse-time
failures in scripts that come after. Vanilla ES5 syntax (var, function,
no arrow/const/template) so it loads on ancient WebKit forks (Tizen 4,
older WebOS, Fire TV stick Gen 1, embedded signage browsers). Wrapped
in defensive try/catch so this script can never be the reason the
player won't boot.
-->
<script>
(function () {
try {
if (!window.__debugLog) window.__debugLog = [];
var MAX_LOG = 200;
var INIT_T = (function () { try { return Date.now(); } catch (e) { return new Date().getTime(); } })();
function nowMs() {
try { return Date.now(); } catch (e) { return new Date().getTime(); }
}
function pushLog(entry) {
try {
entry.t = nowMs();
window.__debugLog.push(entry);
if (window.__debugLog.length > MAX_LOG) {
window.__debugLog.splice(0, window.__debugLog.length - MAX_LOG);
}
} catch (e) { /* we are the safety net; do not crash */ }
}
window.__debugLog_push = pushLog; // shared pusher for debug-overlay.js
pushLog({
type: 'init',
ua: (navigator && navigator.userAgent) || '',
url: location.href,
sw: typeof screen !== 'undefined' ? screen.width : null,
sh: typeof screen !== 'undefined' ? screen.height : null
});
// Uncaught script errors. Capture phase so we also see errors from
// <img>/<script>/<link> resource failures (ev.target.src on those).
window.addEventListener('error', function (ev) {
try {
var src = ev.filename || (ev.target && ev.target.src) || '';
pushLog({
type: 'error',
message: ev.message || (ev.error && String(ev.error)) || 'error',
source: src,
line: ev.lineno || 0,
col: ev.colno || 0,
stack: (ev.error && ev.error.stack) || ''
});
} catch (e) {}
}, true);
window.addEventListener('unhandledrejection', function (ev) {
try {
var reason = ev.reason;
pushLog({
type: 'rejection',
message: (reason && reason.message) || String(reason || 'rejection'),
stack: (reason && reason.stack) || ''
});
} catch (e) {}
});
// Wrap console methods. Original behavior preserved so devtools still
// sees the real call; we additionally append a sanitized text record.
var methods = ['log', 'warn', 'error'];
for (var i = 0; i < methods.length; i++) {
(function (m) {
var orig = console[m];
console[m] = function () {
try {
var args = Array.prototype.slice.call(arguments);
var msg = '';
for (var j = 0; j < args.length; j++) {
var part;
try {
part = typeof args[j] === 'string' ? args[j] : JSON.stringify(args[j]);
} catch (e) {
part = String(args[j]);
}
msg += (j > 0 ? ' ' : '') + part;
}
if (msg.length > 1000) msg = msg.slice(0, 1000) + '...[trunc]';
pushLog({ type: 'console.' + m, message: msg });
} catch (e) {}
try { return orig.apply(console, arguments); } catch (e) {}
};
})(methods[i]);
}
// Page-load timing relative to this script's execution.
try {
document.addEventListener('DOMContentLoaded', function () {
pushLog({ type: 'timing', event: 'DOMContentLoaded', sinceInit: nowMs() - INIT_T });
});
window.addEventListener('load', function () {
pushLog({ type: 'timing', event: 'load', sinceInit: nowMs() - INIT_T });
});
} catch (e) {}
} catch (outerErr) {
// If even init failed, the player must still boot. Do nothing.
}
})();
</script>
<!-- Debug overlay module (section 2). Defers so HTML parse doesn't block
on the fetch. Auto-activation check fires after DOMContentLoaded;
few-ms delay vs the inline trap is fine since errors before this
loads are already captured in __debugLog by the inline trap above. -->
<script src="/player/debug-overlay.js" defer></script>
<!-- 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/media-mute.js"></script>
<script src="/player/orientation-style.js"></script>
<script src="/player/wall-geometry.js"></script>
<script src="/player/player-media-health.js"></script>
<!-- feat/transition-engine: WebGL transition runtime (renderer + shaders). Optional; if it fails to
load the player just hard-cuts. Not deferred so it's ready before the first content swap. -->
<script src="/player/transitions.js"></script>
<script>
// ==================== i18n ====================
// Lightweight inline i18n for the player. The player is a standalone page
// on display devices — it doesn't import the dashboard's i18n module.
// Keep keys in sync with the player overlay strings; falls back to en.
const PLAYER_I18N = {
en: {
web_player: 'Web Player',
server_url: 'Server URL',
server_url_placeholder: 'https://sign.yourdomain.com',
connect: 'Connect',
pairing_code: 'Pairing Code',
pairing_hint: 'Enter this code in the dashboard to pair this display',
connecting: 'Connecting...',
connecting_muted: 'Connecting (audio muted)...',
info_title: 'ScreenTinker Web Player',
info_close_hint: 'Press Back again or click to close',
info_device_id: 'Device ID',
info_device_name: 'Device Name',
info_server: 'Server',
info_status: 'Status',
info_now_playing: 'Now Playing',
info_resolution: 'Resolution',
info_uptime: 'Uptime',
info_platform: 'Platform',
info_cache: 'Cache',
info_connected: 'Connected',
info_disconnected: 'Disconnected',
info_active: 'Active',
info_inactive: 'Inactive',
info_nothing: 'Nothing',
info_na: 'N/A',
info_sw: 'Service Worker',
nothing_scheduled: 'Nothing scheduled right now',
preview_webpage_blocked: 'If this area is blank, the site blocks being embedded and wont display on the device either. Try a page that allows embedding.',
},
es: {
web_player: 'Reproductor web', server_url: 'URL del servidor', server_url_placeholder: 'https://signage.tudominio.com', connect: 'Conectar', pairing_code: 'Código de vinculación', pairing_hint: 'Ingresa este código en el panel para vincular esta pantalla', connecting: 'Conectando...', connecting_muted: 'Conectando (audio silenciado)...', info_title: 'Reproductor web ScreenTinker', info_close_hint: 'Presiona Atrás de nuevo o haz clic para cerrar', info_device_id: 'ID del dispositivo', info_device_name: 'Nombre del dispositivo', info_server: 'Servidor', info_status: 'Estado', info_now_playing: 'Reproduciendo', info_resolution: 'Resolución', info_uptime: 'Tiempo activo', info_platform: 'Plataforma', info_cache: 'Caché', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Activo', info_inactive: 'Inactivo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'No hay nada programado en este momento', preview_webpage_blocked: 'Si esta área está en blanco, el sitio bloquea la inserción y tampoco se mostrará en el dispositivo. Prueba con una página que permita la inserción.',
},
fr: {
web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code dappairage', pairing_hint: 'Saisissez ce code dans le tableau de bord pour apparier cet écran', connecting: 'Connexion...', connecting_muted: 'Connexion (audio coupé)...', info_title: 'Lecteur web ScreenTinker', info_close_hint: 'Appuyez à nouveau sur Retour ou cliquez pour fermer', info_device_id: 'ID de lappareil', info_device_name: 'Nom de lappareil', info_server: 'Serveur', info_status: 'État', info_now_playing: 'En lecture', info_resolution: 'Résolution', info_uptime: 'Disponibilité', info_platform: 'Plateforme', info_cache: 'Cache', info_connected: 'Connecté', info_disconnected: 'Déconnecté', info_active: 'Actif', info_inactive: 'Inactif', info_nothing: 'Rien', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Rien de programmé pour le moment', preview_webpage_blocked: 'Si cette zone est vide, le site bloque lintégration et ne saffichera pas non plus sur lappareil. Essayez une page qui lautorise.',
},
de: {
web_player: 'Web-Player', server_url: 'Server-URL', server_url_placeholder: 'https://signage.ihredomain.com', connect: 'Verbinden', pairing_code: 'Kopplungscode', pairing_hint: 'Geben Sie diesen Code im Dashboard ein, um diesen Bildschirm zu koppeln', connecting: 'Verbindung wird hergestellt...', connecting_muted: 'Verbindung (Audio stummgeschaltet)...', info_title: 'ScreenTinker Web-Player', info_close_hint: 'Erneut Zurück drücken oder klicken zum Schließen', info_device_id: 'Geräte-ID', info_device_name: 'Gerätename', info_server: 'Server', info_status: 'Status', info_now_playing: 'Aktuelle Wiedergabe', info_resolution: 'Auflösung', info_uptime: 'Betriebszeit', info_platform: 'Plattform', info_cache: 'Cache', info_connected: 'Verbunden', info_disconnected: 'Getrennt', info_active: 'Aktiv', info_inactive: 'Inaktiv', info_nothing: 'Nichts', info_na: 'N/V', info_sw: 'Service Worker', nothing_scheduled: 'Derzeit ist nichts geplant', preview_webpage_blocked: 'Wenn dieser Bereich leer ist, blockiert die Website die Einbettung und wird auch auf dem Gerät nicht angezeigt. Verwenden Sie eine Seite, die dies erlaubt.',
},
pt: {
web_player: 'Player web', server_url: 'URL do servidor', server_url_placeholder: 'https://sign.seudominio.com', connect: 'Conectar', pairing_code: 'Código de pareamento', pairing_hint: 'Digite este código no painel para parear esta tela', connecting: 'Conectando...', connecting_muted: 'Conectando (áudio mudo)...', info_title: 'Player web ScreenTinker', info_close_hint: 'Pressione Voltar novamente ou clique para fechar', info_device_id: 'ID do dispositivo', info_device_name: 'Nome do dispositivo', info_server: 'Servidor', info_status: 'Status', info_now_playing: 'Reproduzindo', info_resolution: 'Resolução', info_uptime: 'Tempo ativo', info_platform: 'Plataforma', info_cache: 'Cache', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Ativo', info_inactive: 'Inativo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Nada programado no momento', preview_webpage_blocked: 'Se esta área estiver em branco, o site bloqueia a incorporação e também não será exibido no dispositivo. Tente uma página que a permita.',
},
};
const PLAYER_LANG = (() => {
const stored = localStorage.getItem('rd_lang');
const detected = (stored || navigator.language || 'en').split('-')[0];
return PLAYER_I18N[detected] ? detected : 'en';
})();
const _t = (k) => (PLAYER_I18N[PLAYER_LANG] && PLAYER_I18N[PLAYER_LANG][k]) || PLAYER_I18N.en[k] || k;
// Apply translations to the static setup screen markup
document.addEventListener('DOMContentLoaded', () => {
const setSubtitle = document.querySelector('#setupScreen .subtitle');
if (setSubtitle) setSubtitle.textContent = _t('web_player');
const lblServer = document.querySelector('#urlForm label');
if (lblServer) lblServer.textContent = _t('server_url');
const inputServer = document.getElementById('serverUrl');
if (inputServer) inputServer.placeholder = _t('server_url_placeholder');
const connectBtn = document.getElementById('connectBtn');
if (connectBtn) connectBtn.textContent = _t('connect');
const pairingP = document.querySelector('#pairingSection p:first-child');
if (pairingP) pairingP.textContent = _t('pairing_code');
const pairingHint = document.querySelector('#pairingSection .pairing-hint');
if (pairingHint) pairingHint.textContent = _t('pairing_hint');
// Translate any element with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const k = el.getAttribute('data-i18n');
if (k) el.textContent = _t(k);
});
});
// ==================== Config ====================
// 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.
/*
* Where an item's bytes live.
*
* The revision is in the URL on purpose. Media is cached for offline playback keyed by URL, and
* PUT /api/content/:id/replace changes an asset's BYTES without changing its id — so without a
* revision a replaced asset would keep playing the old copy on every panel that already held
* it, with no way for the player to find out. The revision changes exactly when the bytes do,
* which makes a replace a cache miss and an untouched asset a hit.
*/
function mediaUrl(it) {
if (!it) return '';
if (it.remote_url) return it.remote_url;
const base = `${config.serverUrl}/uploads/content/${it.filepath}`;
return it.content_rev ? `${base}?rev=${encodeURIComponent(it.content_rev)}` : base;
}
/*
* Ask the service worker to hold this playlist's media for offline use.
*
* The worker stores whatever a playback fetch happens to complete, which on a healthy link is
* everything and on a marginal one is nothing: a large asset never finishes in one go, so the
* panel is left with an empty cache exactly where it needs a full one. Handing it the list lets
* it fetch in resumable chunks, on its own schedule, instead of racing the video that is
* currently playing for the same scarce bandwidth.
*
* Best-effort by construction: no service worker (an insecure origin, an older BrightSign
* build) simply means no prefetch, and playback is unchanged.
*/
function requestOfflineCache(items) {
try {
const sw = navigator.serviceWorker && navigator.serviceWorker.controller;
if (!sw) return;
const urls = (items || [])
.filter((it) => it && it.filepath && !it.remote_url)
.map((it) => mediaUrl(it));
// `urls` is the COMPLETE set for this display, so the worker may treat anything else in
// the content cache as superseded. That is what reclaims a replaced asset: a replace writes
// a new randomly-named file, so the old copy is not merely a different revision of the same
// URL — it is a different URL entirely, and nothing keyed on the asset path can find it.
sw.postMessage({ type: 'st-cache-playlist', urls, prune: true });
} catch (e) { /* never let caching break playback */ }
}
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
// Set when register() is REFUSED (not merely pending). A runtime can expose navigator
// .serviceWorker and still decline to run one — a real BrightSign widget does exactly that —
// and the display must stop claiming an offline capability it cannot honour.
let swRegistrationFailed = false;
// 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).
/*
* BrightSign host diagnostics, forwarded as if the page had produced them.
*
* The host sees what the page cannot — the uptime, the wired IP, which volume it booted from,
* whether a staged package applied — and everything it knew used to go to a serial console.
* On a panel on a wall that is the same as knowing nothing: a script that failed to compile
* looked, from here, exactly like a player that never started.
*
* Wired once, guarded, and a no-op on every other platform: the hooks only exist on the
* BrightSign bridge, so a browser skips this entirely.
*/
function wireHostDiagnostics() {
try {
if (!BS || typeof BS.onHostLog !== 'function') return;
BS.onHostLog((line) => {
try {
if (socket?.connected && config.deviceId) {
socket.emit('device:log', {
device_id: config.deviceId,
tag: line.tag, level: line.level, message: line.message
});
}
console.log(`[host/${line.tag}] ${line.message}`);
} catch (e) { /* diagnostics must never break playback */ }
});
BS.onHostEvent((ev) => emitDeviceEvent(ev.event, ev.reason, ev.detail));
} catch (e) { /* a bridge that throws here must not stop the player starting */ }
}
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;
// The item currently mounted, so a live mute toggle can re-resolve without waiting for the
// next advance, and #129's per-item flag survives a remote override being cleared.
let activeMuteItem = null;
// Live operator override: null means "not set", which defers to the item. Distinguishing
// null from false is the difference between "operator wants sound" and "nobody has said".
let remoteMuteOverride = null;
function muteState(item) {
return {
wallFollower: (typeof isWallFollower === 'function') ? !!isWallFollower() : false,
remoteMuted: remoteMuteOverride,
itemMuted: !!(item && item.muted),
userGesture: !!userHasInteracted,
};
}
// Resolve once, apply to whatever surface is actually playing. A YouTube item is a
// cross-origin iframe: `el.muted` reaches nothing, which is why a muted item used to play
// with sound. The IFrame API is the only handle on it.
// The decision alone, touching no surface — needed when BUILDING a player, where the answer
// is a construction parameter rather than something applied afterwards.
function applyMuteWanted(item) {
return window.MediaMute ? window.MediaMute.resolveMuted(muteState(item)) : true;
}
function applyMute(item) {
if (item !== undefined) activeMuteItem = item;
const want = window.MediaMute
? window.MediaMute.resolveMuted(muteState(activeMuteItem))
: true; // no resolver loaded => stay silent rather than blare
document.querySelectorAll('video, audio').forEach((el) => {
try { el.muted = want; } catch (e) { /* torn down */ }
});
if (activeYtPlayer) {
try {
if (want) activeYtPlayer.mute();
else { activeYtPlayer.unMute(); activeYtPlayer.setVolume(mediaVolume != null ? Math.round(mediaVolume * 100) : 100); }
} catch (e) { /* player not ready yet; onReady applies it again */ }
}
return want;
}
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">&#128263;</span><span>Tap to enable audio</span>';
ov.addEventListener('click', () => {
unlockAudioContext();
tryUnmuteLeader();
});
document.body.appendChild(ov);
}
function hideEnableAudioPrompt() {
document.getElementById('enableAudioPrompt')?.remove();
}
// Track user interaction for autoplay policy
['click', 'touchstart', 'keydown'].forEach(evt => {
document.addEventListener(evt, () => {
const wasFirst = !userHasInteracted;
userHasInteracted = true;
// First gesture: prime the AudioContext. This signals "site activated"
// to Firefox and unlocks subsequent <video> unmute attempts.
if (wasFirst) unlockAudioContext();
// Followers in a video wall must stay muted forever — even after a
// user gesture. Otherwise tapping a follower screen would unmute it
// and cause echo with the leader.
if (isWallFollower()) return;
if (wasFirst) console.log('[wall/audio] first user gesture detected — attempting unmute');
tryUnmuteLeader();
// Unmute YouTube player if active
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
try { activeYtPlayer.unMute(); activeYtPlayer.setVolume(100); console.log('Unmuted YouTube player'); } catch {}
}
}, { once: false });
});
// ==================== Browser Fingerprint ====================
// 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;
installPreviewControlChannel(); // #239: only a preview instance can ever be steered
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);
postPreviewState(); // #239: first count, in case the payload landed before the first item did
} 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);
}
// ==================== #239 Preview transport control ====================
// Where a next/previous press lands. Kept as a pure function (no DOM, no globals) because the
// failure it prevents is arithmetic: a negative modulo or an out-of-range index mounts
// `undefined` and blanks the frame, and that is only catchable by testing the maths directly.
//
// `allows` is the per-item schedule gate. Scanning in the DIRECTION OF TRAVEL matters: skipping
// a dayparted item by falling forward would make "previous" walk forwards, which reads as the
// button being broken. Returns -1 when nothing is playable (empty list, or every item outside
// its window) so the caller can idle instead of mounting nothing.
function previewStepIndex(current, delta, length, allows) {
const n = Math.trunc(length);
if (!Number.isFinite(n) || n <= 0) return -1;
const dir = delta < 0 ? -1 : 1;
// A player that has not started yet (currentIndex === -1) or a corrupt index still has to
// produce a valid landing spot rather than propagate NaN into playlist[].
const from = Number.isFinite(current) ? ((Math.trunc(current) % n) + n) % n : 0;
for (let i = 1; i <= n; i++) {
const idx = (((from + dir * i) % n) + n) % n;
if (!allows || allows(idx)) return idx;
}
return -1;
}
// Jump the preview by one item. PREVIEW_MODE is the hard gate: a live player never installs the
// message listener below AND would refuse here anyway, so a page that frames the player can
// never steer content on a real screen.
function previewNavigate(delta) {
if (!PREVIEW_MODE) return;
const idx = previewStepIndex(currentIndex, delta, playlist.length, (i) => scheduleAllows(playlist[i]));
if (idx === -1) return;
clearTimeout(scheduleRetryTimer); // we are leaving the idle screen by hand
currentIndex = idx;
isPlaying = true;
playCurrentItem(); // re-renders, which clears the pending advance timer
}
// Tell the dashboard which item is on screen so it can show "3 of 7". targetOrigin is our own
// origin, so a third-party page that iframes the player learns nothing about the workspace's
// content from this channel.
function postPreviewState() {
if (!PREVIEW_MODE || window.parent === window) return;
const item = playlist[currentIndex];
// A multi-zone playlist plays every zone at once on independent timers — there is no single
// "current item" to step through, so say so and let the dashboard hide the controls rather
// than offer a button that does nothing visible.
const zoned = !!(layout && layout.zones && layout.zones.length > 1);
try {
window.parent.postMessage({
source: 'screentinker-player',
type: 'preview:state',
index: currentIndex,
total: playlist.length,
zoned,
name: (item && (item.filename || item.widget_name || item.title)) || null,
}, window.location.origin);
} catch (e) { /* parent went away mid-preview */ }
}
function installPreviewControlChannel() {
window.addEventListener('message', (ev) => {
if (!PREVIEW_MODE) return; // belt and braces: see previewNavigate
if (ev.origin !== window.location.origin) return; // only our own dashboard may drive us
const d = ev.data;
if (!d || d.source !== 'screentinker-preview') return;
if (d.action === 'next') previewNavigate(1);
else if (d.action === 'prev') previewNavigate(-1);
else if (d.action === 'sync') postPreviewState(); // parent (re)attached and wants the count
});
}
// #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();
// After the socket is up, because these forward to the SERVER — wiring them earlier would
// drop the host's boot report on the floor rather than delivering it late.
wireHostDiagnostics();
});
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. Applies to whatever is playing now and is remembered for items mounted
// later (see setMediaVolume). The wire parsing is volumeLevelFromCommand().
if (data.type === 'set_volume') {
const v = volumeLevelFromCommand(data);
if (v === null) console.warn('[volume] set_volume with no usable level/value:', JSON.stringify(data));
else setMediaVolume(v);
}
});
// #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) {
// Was `<video>`-only, so a YouTube item ignored the toggle entirely.
remoteMuteOverride = !!data.muted;
applyMute();
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 : ''));
}
/*
* What THIS instance can actually do, decided at registration time.
*
* The dashboard used to offer every control to every display, so buttons that could not
* possibly work were shown and did nothing — a browser tab cannot reboot its host. The server
* falls back to a per-platform baseline when a player declares nothing, so this list only has
* to be RIGHT, not complete.
*
* It is computed rather than constant because the same index.html is BOTH the browser player
* and the BrightSign player: identical code, wildly different powers depending on whether a
* host bridge answered. Declaring a static set here would either lie for the browser or
* under-sell BrightSign.
*
* Everything conditional is probed, never assumed. A capability we cannot verify is left out:
* an absent control is a smaller failure than one that reports success and changes nothing.
*/
function declaredCapabilities() {
const caps = [
// Render paths that exist in this file unconditionally.
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.pip',
// #129 per-item mute plus the operator volume added alongside it.
'audio.mute', 'audio.volume',
// CSS transform in a browser; the host rotates the output on BrightSign. Both real.
'display.rotation',
'remote.input',
'remote.stream',
// restartPlayer() always resolves to something: the host rebuilds the widget, or the page
// reloads. It is the one lifecycle action a browser tab can genuinely perform.
'system.restart_player',
'sync.clock',
];
// Transitions are a progressive enhancement — the bundle failing to load means hard cuts,
// not a broken player — so this reflects whether the runtime actually loaded.
//
// Deliberately the player's OWN predicate rather than a second guess at the global names.
// An earlier draft here tested window.STTransitions, which does not exist: the capability
// would simply never have been declared, hiding the transitions UI for every display, and
// nothing would have failed loudly enough to notice.
try {
if (typeof transitionRuntimeReady === 'function' && transitionRuntimeReady()) {
caps.push('playback.transitions');
}
} catch (e) { /* bundle absent: hard cuts, and we do not claim the capability */ }
// Offline caching is the service worker — and the API EXISTING is not the same as it
// working. A real BrightSign XT245 on alpha has `serviceWorker` in navigator, passes this
// check, and then never even fetches sw.js: registration is refused by its widget runtime.
// It declared offline.cache to the fleet and could not cache a single byte.
//
// So the claim is made on a worker that is actually IN CONTROL. The cost is that the very
// first load under-reports (registration has happened but the worker has not claimed the
// page yet) — which is the right direction to be wrong in, and self-corrects: activation
// triggers a reload, and the next register sends the true set. `swRegistrationFailed` makes
// the negative stick on a runtime where it will never succeed, rather than waiting on a
// controller that is never coming.
try {
if (!swRegistrationFailed && navigator.serviceWorker && navigator.serviceWorker.controller) {
caps.push('offline.cache');
}
} catch (e) { /* locked-down browser */ }
// Screenshots need somewhere to draw. Same-origin content and a 2d context are the real
// requirements; a host bridge is a second, better path (it captures the framebuffer
// including the hardware video plane, which a canvas cannot read at all).
try {
const probe = document.createElement('canvas');
probe.width = 2; probe.height = 2;
if (probe.getContext('2d') && typeof probe.toDataURL === 'function') {
caps.push('remote.screenshot');
}
} catch (e) { /* no canvas => no screenshots, and we do not claim them */ }
// --- everything below exists ONLY behind a live host bridge ---
// hasHost() is specifically NOT isBrightSign(): the UA check alone is true for a widget
// built without node integration, which can reach none of this. Claiming reboot on one of
// those is exactly the dead button this whole exercise removes.
let host = false;
try { host = !!(BS && BS.hasHost()); } catch (e) { host = false; }
if (host) {
caps.push('system.reboot'); // RebootSystem() in autorun.brs
caps.push('display.power'); // media teardown + CEC; blanking works even where CEC does not
caps.push('display.resolution'); // roVideoMode via the bridge
caps.push('system.self_update'); // the autorun.zip package path
}
// Native frame-accurate sync needs the SyncManager module to have actually resolved, which
// is a firmware-version question (BOS 8.2.10+), not a platform one.
try {
if (window.ScreenTinkerBSSync && window.ScreenTinkerBSSync.available()) caps.push('sync.native');
} catch (e) { /* not on BrightSign */ }
return caps;
}
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 */ }
}
// What this instance can actually do. The server persists it and the dashboard hides the
// controls it does not contain, so a display never shows a button that cannot work.
data.capabilities = declaredCapabilities();
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.
// typeof, not truthiness: the bridge and this page are halves of one contract but are
// fetched separately, so a player can run a NEW page against a CACHED older bridge.
// Calling a method that version lacks threw here every 15s and took the whole heartbeat
// with it — the display kept playing while silently reporting nothing at all.
...(BS && typeof BS.telemetrySnapshot === 'function' ? 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 = mediaUrl(item);
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).
// #236: the geometry (including per-panel mounting rotation) comes from the shared rule in
// server/lib/wall-geometry.js. Kept out of here because Tizen, Android and this player must
// agree to the pixel across a seam, and because the rotated cases are only checkable by test.
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 st = window.WallGeometry
? window.WallGeometry.wallStageStyle(s, p, wallConfig.rotation)
// If the shared script failed to load, fall back to the unrotated rule rather than leaving
// the stage unstyled — a wrong-but-full-frame panel beats a blank one on a live wall.
: {
left: (((p.x - s.x) / s.w) * 100) + 'vw',
top: (((p.y - s.y) / s.h) * 100) + 'vh',
width: ((p.w / s.w) * 100) + 'vw',
height: ((p.h / s.h) * 100) + 'vh',
transform: '', transformOrigin: '',
};
if (!st) return;
const dev = (config.deviceId || '?').slice(0, 8);
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p) + ' rotation=' + (wallConfig.rotation || 0));
console.log('[wall/render ' + dev + '] viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' DPR=' + window.devicePixelRatio);
console.log('[wall/render ' + dev + '] stage: left=' + st.left + ' top=' + st.top + ' width=' + st.width + ' height=' + st.height + ' transform=' + (st.transform || 'none'));
stageEl.style.left = st.left;
stageEl.style.top = st.top;
stageEl.style.width = st.width;
stageEl.style.height = st.height;
stageEl.style.transform = st.transform;
stageEl.style.transformOrigin = st.transformOrigin;
}
// No-op kept for callers that bind a resize listener (kept around in case
// future zoom/orientation tweaks need it). vw/vh stage updates with the
// viewport automatically, so explicit re-style isn't needed today.
function bindWallResizeOnce() {}
// ==================== Playlist ====================
function handlePlaylistUpdate(data) {
// Check if device is suspended (trial expired / over limit)
if (data.suspended) {
isPlaying = false;
playlist = [];
document.getElementById('playerContainer').style.display = 'none';
const overlay = document.getElementById('statusOverlay');
overlay.style.display = 'flex';
overlay.innerHTML = `
<div style="text-align:center;max-width:500px">
<div style="font-size:64px;margin-bottom:16px">&#9888;</div>
<h2 style="color:#f59e0b;margin-bottom:8px">${data.message || 'Account Suspended'}</h2>
<p style="color:#94a3b8;font-size:16px;margin-bottom:24px">${data.detail || 'Please upgrade your plan.'}</p>
<p style="color:#64748b;font-size:13px">Visit your dashboard to manage your subscription</p>
</div>
`;
return;
}
const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change.
// #74/#75: include schedules so a schedule edit (same content) is detected too.
// transition-engine: include the per-item transition too, or a transition config change (effects,
// duration, scope, or presence) keeps the SAME fingerprint -> "unchanged" -> the player keeps a
// stale cached playlist and never applies the new transitions. This bug hid every transition edit.
// STRUCTURAL fingerprint only (identity + order + schedules + transition). duration_sec is
// deliberately EXCLUDED so a duration-only edit is applied IN PLACE (not a full change/restart).
// 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.
// #236: on a wall panel that carries its own mounting rotation, the two settings describe the
// SAME physical fact, so honouring both turns the content twice and throws it off-screen. The
// wall rotation wins there; when it is 0 (every wall in the field today) nothing changes.
const effectiveOrientation = window.WallGeometry
? window.WallGeometry.orientationForWallMember(data.orientation, data.wall_config?.rotation)
: data.orientation;
if (data.orientation) {
// On BrightSign, rotate the OUTPUT first. Video decodes onto a hardware plane the DOM
// cannot transform, so the CSS rotation below turns the images and widgets and leaves the
// video unrotated — a portrait panel playing sideways video. Tizen hit the same wall and
// routes portrait video through AVPlay for exactly this reason.
//
// If the host rotates the screen, EVERY layer turns with it and the CSS transform must be
// cleared or the graphics rotate twice. If it cannot, we fall through to CSS, which rotates
// most of the content rather than none of it.
if (BS && typeof BS.setOrientation === 'function' && BS.hasHost()) {
BS.setOrientation(effectiveOrientation).then((rotatedByHost) => {
if (!rotatedByHost) return; // CSS path below already ran
[document.getElementById('playerContainer'), document.getElementById('pipContainer')]
.forEach((el) => {
if (!el) return;
el.style.transform = '';
el.style.width = '';
el.style.height = '';
});
console.log('[bs] output rotated by host; CSS transform cleared');
});
}
// Geometry from the shared rule (server/lib/orientation-style.js). The previous inline
// version rotated the container about its own centre while it stayed pinned top-left, so
// portrait content landed 420px off-screen on a 1920x1080 panel — rotated correctly and
// placed wrongly. Tizen and Android both centre the box first; the web player did not.
const st = window.OrientationStyle
? window.OrientationStyle.orientationStyle(effectiveOrientation)
: null;
if (st) {
[document.getElementById('playerContainer'), document.getElementById('pipContainer')]
.forEach((el) => {
if (!el) return;
el.style.transform = st.transform;
el.style.transformOrigin = st.transformOrigin;
el.style.width = st.width;
el.style.height = st.height;
el.style.top = st.top;
el.style.left = st.left;
});
}
}
// 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 || {};
// #236: rotation is part of the key. Left out, re-hanging a panel and changing only its
// rotation in the editor looks like "no change" and the panel keeps the old transform until
// it is rebooted — which reads as the editor silently ignoring you.
return `${c.wall_id}:${c.is_leader}:r${c.rotation || 0}: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);
// The RAW assignments, not the split `playlist`: multi-zone items live in their own lists,
// and a keep-set that omitted them would have the worker prune assets a zone is still
// playing. This is also what makes the prune safe — it is the whole truth about what this
// display needs.
requestOfflineCache(Array.isArray(data.assignments) ? data.assignments : 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);
// #239: the dashboard's "3 of 7" follows the player's own advance, not just operator presses,
// so it stays honest when an item ends on its own duration.
if (PREVIEW_MODE) postPreviewState();
// 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
// Only when a gesture is the ONLY thing in the way: prompting on an item an operator
// deliberately muted trains viewers to click a button that undoes that.
if (window.MediaMute ? window.MediaMute.shouldOfferUnmute(muteState(item)) : !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();
applyMute(item); // resolves to unmuted here — the gesture was the only blocker
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,
// Was `userHasInteracted ? 0 : 1`, which consulted autoplay policy and NOTHING else — so an
// item an operator muted in the admin console played with sound, and a wall follower blared
// alongside its leader.
mute: applyMuteWanted(item) ? 1 : 0,
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();
// Re-resolve here too: playerVars are read once at construction, and a mute toggle can
// land while the IFrame API is still loading.
if (applyMuteWanted(item)) {
event.target.mute();
} else {
event.target.unMute();
event.target.setVolume(mediaVolume != null ? Math.round(mediaVolume * 100) : 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 mediaUrl(it);
}
// 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 = mediaUrl(item);
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 = mediaUrl(item);
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 = mediaUrl(a);
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; }
}
// Did drawImage actually put pixels on the canvas, or did it silently draw nothing?
//
// isMediaReadable() above answers "are we ALLOWED to read this" (same-origin / CORS). It does
// not answer "did any pixels arrive", and those are different questions. On BrightSign with
// hwz enabled, video decodes onto a HARDWARE PLANE outside the browser compositor — per
// BrightSign's own docs the HTML/JS layer "doesn't see the pixels" — so drawImage(video)
// yields a fully TRANSPARENT image and throws nothing. Chromium 87 fails the same way.
//
// The old code set captured/drawn = true purely because drawMediaFit() had not thrown, so a
// BrightSign emitted a BLANK screenshot and logged success: the dashboard showed a dead screen
// while the panel was playing perfectly. That is the "reports success and changes nothing"
// shape, and it is why this probes instead of assuming.
//
// ALPHA is the discriminator, not colour. A scratch canvas starts fully transparent, and a real
// decoded frame writes alpha=255 even when the frame is pure black — so a legitimate
// fade-to-black still reads as captured, while "nothing arrived" reads as not captured.
function videoFrameIsCapturable(video) {
if (!video) return false;
let pctx;
try {
const probe = document.createElement('canvas');
probe.width = 16; probe.height = 16; // a 16x16 probe at 1fps is free; no full-size draw
pctx = probe.getContext('2d');
if (!pctx) return false;
pctx.drawImage(video, 0, 0, 16, 16);
} catch (e) {
return false; // the draw itself failed - nothing landed
}
try {
const data = pctx.getImageData(0, 0, 16, 16).data;
for (let i = 3; i < data.length; i += 4) if (data[i] !== 0) return true;
return false;
} catch (e) {
// SecurityError means the canvas was TAINTED, and tainting only happens once cross-origin
// pixels have actually been drawn. So this is evidence of success, not failure.
return true;
}
}
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)) {
// Probe FIRST. On a hardware video plane the draw succeeds and paints nothing, which
// would leave a black rectangle here instead of the labelled placeholder this function
// was written to guarantee ("never a transparent hole").
if (videoFrameIsCapturable(el)) {
try { drawMediaFit(ctx, el, el.videoWidth, el.videoHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {}
} else {
drawZonePlaceholder(ctx, dx, dy, dw, dh, 'Video (not capturable)');
drawn = true; // handled - don't also draw the generic placeholder below
}
}
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;
// Set when a video IS on screen and playing but its pixels are unreachable (hardware plane).
// Deliberately distinct from "no content": the operator needs to know the panel is healthy
// and it is the CAPTURE that is limited.
let videoUncapturable = 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)) {
if (videoFrameIsCapturable(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); }
} else {
// Playing fine, simply not readable from the DOM. Fall through to the status card
// and SAY so, rather than emitting a black frame that reads as a dead screen.
videoUncapturable = true;
}
}
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);
if (videoUncapturable) {
// A limitation of the CAPTURE must never read as a fault on the SCREEN.
ctx.fillStyle = '#fbbf24';
ctx.fillText('Video is playing on the hardware plane and cannot be captured', W / 2, H / 2 + 72);
}
}
} 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;
// On BrightSign, prefer the HOST's framebuffer capture. An in-page canvas cannot read the
// hardware plane, so a composite here is a screenshot with the video missing — the exact
// failure the alpha probe detects. The host's DWS capture includes video.
//
// Falls through to the canvas path on ANY failure (no host, no primary storage, timeout):
// a partial screenshot showing images and widgets beats none, and the probe makes the
// video's absence explicit rather than silent.
if (BS && typeof BS.requestSnapshot === 'function' && BS.hasHost()) {
BS.requestSnapshot({ width: 960, height: 540 })
.then((dataUrl) => {
const base64 = String(dataUrl).split(',')[1];
if (base64 && base64.length > 100) {
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
console.log('[bs] framebuffer screenshot sent:', base64.length, 'chars');
} else { captureAndSendCanvas(); }
})
.catch((err) => {
console.warn('[bs] framebuffer capture unavailable (' + err.message + ') — using canvas');
captureAndSendCanvas();
});
return;
}
captureAndSendCanvas();
}
function captureAndSendCanvas() {
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; }
}
/*
* The 0..1 volume a set_volume command is asking for, or null when it does not carry one.
*
* The wire form is `payload.level`, a FRACTION. That is what the dashboard sends
* (device-detail.js: `{ level: slider / 100 }`), what a group command relays unchanged, and what
* the Android player reads (`optDouble("level")`) — so the fraction is canonical and this player
* now matches it instead of inventing a third convention.
*
* What was here read `payload.value` and treated it as a percentage, which matched nothing the
* product sends: the slider was a no-op on every browser panel. Correcting only the KEY would
* have been worse than leaving it broken — 0.5 clamped as a percentage is 0.5%, inaudible, and
* it would have looked fixed to anyone who checked only that the handler ran.
*
* `value` is still accepted as a 0..100 percentage for any caller written against the old
* handler. The scale is chosen by WHICH KEY ARRIVED, never by the magnitude of the number: 1 is
* legal in both scales (1% and full volume), so a magnitude test is guaranteed to be wrong for
* somebody, silently, in whichever direction hurts more.
*
* Extracted from the socket handler so the parsing can be asserted without a socket — the bug
* it replaces was invisible precisely because nothing ever ran it against a real payload.
*/
function volumeLevelFromCommand(data) {
if (!data || typeof data !== 'object') return null;
const p = (data.payload && typeof data.payload === 'object') ? data.payload : {};
const pick = (k) => {
const raw = (p[k] !== undefined && p[k] !== null) ? p[k] : data[k];
if (raw === undefined || raw === null || raw === '' || typeof raw === 'boolean') return null;
const n = Number(raw);
return isFinite(n) ? n : null;
};
const level = pick('level'); // canonical: 0..1
const pct = level === null ? pick('value') : null; // legacy: 0..100
const v = level !== null ? level : (pct !== null ? pct / 100 : null);
return v === null ? null : Math.max(0, Math.min(1, v));
}
// 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; } catch (e) { /* element torn down mid-call */ }
});
// Volume is a LEVEL. Mute is a separate decision with four inputs and a fixed order
// (lib/media-mute.js), already resolved when the element was mounted, and this function does
// not get to overrule it. It used to write `el.muted = (v === 0)`, which un-muted whatever was
// playing on any non-zero volume: an item an operator had deliberately silenced started making
// noise the moment somebody touched the volume slider — reproduced live (item flagged muted,
// one set_volume, muted went false). The same write contradicts the resolver's autoplay rule,
// where unmuting without a user gesture costs the VIDEO rather than winning the audio.
//
// Nothing is lost by leaving mute alone: volume 0 is silence on every media element we run on.
}
// 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 */ }
// Level only — see setMediaVolume: the mount path has already resolved this element's mute
// from media-mute.js, and re-deciding it here from the volume number alone would undo a
// per-item mute (and, without a user gesture, the playback itself).
try { el.volume = mediaVolume; } 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
// Update info overlay content periodically
setInterval(() => {
const el = document.getElementById('infoContent');
if (!el) return;
const item = playlist[currentIndex];
el.innerHTML = `
${_t('info_device_id')}: ${escHtml(config.deviceId?.slice(0, 8) || _t('info_na'))}...<br>
${_t('info_device_name')}: ${escHtml(config.deviceName || _t('info_na'))}<br>
${_t('info_server')}: ${escHtml(config.serverUrl || _t('info_na'))}<br>
${_t('info_status')}: ${socket?.connected ? `<span style="color:#22c55e">${_t('info_connected')}</span>` : `<span style="color:#ef4444">${_t('info_disconnected')}</span>`}<br>
${_t('info_now_playing')}: ${escHtml(item?.filename || _t('info_nothing'))} (${currentIndex + 1}/${playlist.length})<br>
${_t('info_resolution')}: ${screen.width}x${screen.height}<br>
${_t('info_uptime')}: ${Math.floor(performance.now() / 60000)}m<br>
${_t('info_platform')}: ${escHtml(navigator.platform)}<br>
${_t('info_cache')}: ${_t('info_sw')} ${navigator.serviceWorker?.controller ? `<span style="color:#22c55e">${_t('info_active')}</span>` : _t('info_inactive')}
`;
}, 2000);
// ==================== Fullscreen ====================
// Only attempt fullscreen on genuine user clicks. Synthetic clicks dispatched
// by the remote-control feature (touch forwarding from the dashboard) are not
// trusted by the browser and requestFullscreen() rejects with a "Permissions
// check failed" / "API can only be initiated by a user gesture" error every
// time, spamming the console.
document.addEventListener('click', (e) => {
if (!e.isTrusted) return;
if (!document.fullscreenElement && config.paired) {
document.documentElement.requestFullscreen?.() ||
document.documentElement.webkitRequestFullscreen?.();
}
});
// Prevent sleep/screen saver
let wakeLock = null;
async function requestWakeLock() {
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => { setTimeout(requestWakeLock, 1000); });
}
} catch {}
}
requestWakeLock();
// v4 browser half-open triggers: tab foreground / bfcache resume / network change all drive the
// SAME liveness check + #148 teardown-first reconnect (checkLiveness -> connect() only if the
// socket is armed+connected+silent-past-window). checkLiveness no-ops on a healthy socket, so a
// visibility change does NOT spawn a duplicate socket (the classic browser bug).
document.addEventListener('visibilitychange', () => {
// On becoming visible after a (possibly throttled/frozen) background stint, do the fresh
// liveness check — reset the grace and reconnect ONLY if genuinely dead; never spuriously
// tear down a live socket on the hidden->visible gap.
if (document.visibilityState === 'visible') { requestWakeLock(); verifyLivenessSoon(); }
else if (glTxAbort) { glTxAbort(); } // tab hidden mid-wipe: hard-cut NOW (before rAF freezes) so nothing sticks
});
// pairing-race fix: pageshow fires on EVERY load (persisted=false), not only bfcache restores.
// Unfiltered it ran verifyLivenessSoon() on the initial cold load, which opened the socket EARLY
// (if !socket -> connect) and registered a pairing code — then the intended boot path (tap-overlay
// / connectBtnFunc) called connect() again, tearing that socket down and recreating it (the
// connect->register->disconnect->reconnect flap that collided with the server's offline grace and
// produced the UNIQUE pairing_code collision). Only act on a REAL bfcache restore (persisted=true),
// mirroring the pagehide guard above; the cold-boot connect is owned solely by the boot path.
window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) verifyLivenessSoon(); }); // sleep/resume via bfcache restore
window.addEventListener('online', verifyLivenessSoon); // network switch (wifi<->cellular)
// feat/offline-cause-log: display sleep / backgrounding proxy — screen off/on on a TV.
document.addEventListener('visibilitychange', () => {
emitDeviceEvent(document.hidden ? 'display_off' : 'display_on');
});
// feat/offline-cause-log: browser-side offline detection feeds link_lost on the next reconnect —
// if navigator goes offline during a disconnect gap, the drop was the local link (not upstream).
window.addEventListener('offline', () => { if (disconnectedAt) linkLostDuringGap = true; });
// Register service worker for offline content caching
if ('serviceWorker' in navigator) {
// Explicit scope. The default would be the worker's own directory (/player/), which does NOT
// include /player — the URL the dashboard shows and the one panels are actually configured
// with. Registration succeeded there and then controlled nothing: no shell cache, no content
// cache, no offline playback, and no error to notice. The server sends
// Service-Worker-Allowed so this wider scope is permitted.
// Registered from the ROOT, where the default scope already covers the whole origin. The
// previous form asked for a wider-than-default scope and relied on a Service-Worker-Allowed
// header reaching the browser — which Cloudflare withheld from a cached response across a
// deploy, so registration failed and the player ran with no worker at all. A rejected
// registration is worse than a narrow one, and nothing about it is visible from here.
navigator.serviceWorker.register('/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) => {
// A registration that fails has to be VISIBLE. This one went to console.warn on a display
// nobody has a console for, so a panel that could not cache anything looked identical to
// one that could — for as long as nobody thought to compare nginx logs against the
// capability it was advertising.
swRegistrationFailed = true;
console.warn('SW registration failed:', err);
try {
// 'app_error' rather than a new type: the server allow-lists event types, and a type it
// does not know is dropped silently — which would have made this report as invisible as
// the console.warn it replaces.
emitDeviceEvent('app_error', 'sw_unavailable', String((err && err.message) || err).slice(0, 200));
} catch (e) { /* reporting must never break the player */ }
});
}
// ==================== 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>