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