screentinker/server/player/index.html
screentinker 501ffb11c1
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159.
2026-07-12 19:41:07 -05:00

2606 lines
134 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<!--
Player debug error trap. MUST stay first inside <head> after the two
mandatory <meta> tags (charset must come first per HTML spec). Runs
before any other script so we capture errors even from parse-time
failures in scripts that come after. Vanilla ES5 syntax (var, function,
no arrow/const/template) so it loads on ancient WebKit forks (Tizen 4,
older WebOS, Fire TV stick Gen 1, embedded signage browsers). Wrapped
in defensive try/catch so this script can never be the reason the
player won't boot.
-->
<script>
(function () {
try {
if (!window.__debugLog) window.__debugLog = [];
var MAX_LOG = 200;
var INIT_T = (function () { try { return Date.now(); } catch (e) { return new Date().getTime(); } })();
function nowMs() {
try { return Date.now(); } catch (e) { return new Date().getTime(); }
}
function pushLog(entry) {
try {
entry.t = nowMs();
window.__debugLog.push(entry);
if (window.__debugLog.length > MAX_LOG) {
window.__debugLog.splice(0, window.__debugLog.length - MAX_LOG);
}
} catch (e) { /* we are the safety net; do not crash */ }
}
window.__debugLog_push = pushLog; // shared pusher for debug-overlay.js
pushLog({
type: 'init',
ua: (navigator && navigator.userAgent) || '',
url: location.href,
sw: typeof screen !== 'undefined' ? screen.width : null,
sh: typeof screen !== 'undefined' ? screen.height : null
});
// Uncaught script errors. Capture phase so we also see errors from
// <img>/<script>/<link> resource failures (ev.target.src on those).
window.addEventListener('error', function (ev) {
try {
var src = ev.filename || (ev.target && ev.target.src) || '';
pushLog({
type: 'error',
message: ev.message || (ev.error && String(ev.error)) || 'error',
source: src,
line: ev.lineno || 0,
col: ev.colno || 0,
stack: (ev.error && ev.error.stack) || ''
});
} catch (e) {}
}, true);
window.addEventListener('unhandledrejection', function (ev) {
try {
var reason = ev.reason;
pushLog({
type: 'rejection',
message: (reason && reason.message) || String(reason || 'rejection'),
stack: (reason && reason.stack) || ''
});
} catch (e) {}
});
// Wrap console methods. Original behavior preserved so devtools still
// sees the real call; we additionally append a sanitized text record.
var methods = ['log', 'warn', 'error'];
for (var i = 0; i < methods.length; i++) {
(function (m) {
var orig = console[m];
console[m] = function () {
try {
var args = Array.prototype.slice.call(arguments);
var msg = '';
for (var j = 0; j < args.length; j++) {
var part;
try {
part = typeof args[j] === 'string' ? args[j] : JSON.stringify(args[j]);
} catch (e) {
part = String(args[j]);
}
msg += (j > 0 ? ' ' : '') + part;
}
if (msg.length > 1000) msg = msg.slice(0, 1000) + '...[trunc]';
pushLog({ type: 'console.' + m, message: msg });
} catch (e) {}
try { return orig.apply(console, arguments); } catch (e) {}
};
})(methods[i]);
}
// Page-load timing relative to this script's execution.
try {
document.addEventListener('DOMContentLoaded', function () {
pushLog({ type: 'timing', event: 'DOMContentLoaded', sinceInit: nowMs() - INIT_T });
});
window.addEventListener('load', function () {
pushLog({ type: 'timing', event: 'load', sinceInit: nowMs() - INIT_T });
});
} catch (e) {}
} catch (outerErr) {
// If even init failed, the player must still boot. Do nothing.
}
})();
</script>
<!-- Debug overlay module (section 2). Defers so HTML parse doesn't block
on the fetch. Auto-activation check fires after DOMContentLoaded;
few-ms delay vs the inline trap is fine since errors before this
loads are already captured in __debugLog by the inline trap above. -->
<script src="/player/debug-overlay.js" defer></script>
<title>ScreenTinker Player</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: -apple-system, sans-serif; }
/* Setup Screen */
#setupScreen {
position: fixed; inset: 0; background: #111827; display: flex; flex-direction: column;
align-items: center; justify-content: center; z-index: 1000; color: #f1f5f9;
}
#setupScreen h1 { font-size: 36px; color: #3b82f6; margin-bottom: 8px; }
#setupScreen .subtitle { color: #94a3b8; font-size: 16px; margin-bottom: 48px; }
#setupScreen .form { width: 400px; max-width: 90vw; }
#setupScreen label { display: block; font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
#setupScreen input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #f1f5f9; font-size: 16px; margin-bottom: 24px; outline: none; }
#setupScreen input:focus { border-color: #3b82f6; }
#setupScreen button { width: 100%; padding: 12px; background: #3b82f6; color: white;
border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
#setupScreen button:hover { background: #2563eb; }
#setupScreen button:disabled { opacity: 0.5; cursor: not-allowed; }
.pairing-code { font-size: 72px; font-weight: 700; color: #3b82f6; font-family: monospace;
letter-spacing: 12px; margin: 24px 0; }
.pairing-hint { color: #64748b; font-size: 14px; }
.status-msg { color: #94a3b8; font-size: 14px; margin-top: 16px; }
.spinner { width: 40px; height: 40px; border: 3px solid #334155; border-top-color: #3b82f6;
border-radius: 50%; animation: spin 1s linear infinite; margin: 24px auto; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Player */
#playerContainer { position: fixed; inset: 0; background: #000; }
/* Fullscreen single-zone playback: YouTube's IFrame API measures the placeholder
at construction time. If that happens before layout settles, YT bakes in a
300x150 fallback as inline pixel dimensions on the iframe, which our %-based
rules can't override. Force fullscreen via absolute positioning + !important. */
#playerContainer > iframe,
#playerContainer > div > iframe {
position: absolute !important;
top: 0 !important; left: 0 !important;
width: 100% !important; height: 100% !important;
border: none !important; display: block !important;
}
.zone { position: absolute; overflow: hidden; }
.zone video { width: 100%; height: 100%; object-fit: cover; }
.zone img { width: 100%; height: 100%; object-fit: cover; }
.zone iframe { width: 100%; height: 100%; border: none; }
/* Video wall mode.
wall-stage maps the wall's player_rect into this device's viewport
using vw/vh — so the device fills its full viewport edge-to-edge
(no pillarbox at the seam between adjacent screens).
object-fit:fill is intentional: it stretches the source to the stage,
which keeps vertical position identical between devices that share
a viewport height — without that, cover-cropping on different stage
aspects (different innerWidths) shifts content vertically. */
#playerContainer.wall-mode { overflow: hidden; background: #000; }
.wall-stage { position: absolute; }
.wall-stage > video,
.wall-stage > img { width: 100%; height: 100%; object-fit: fill; display: block; }
.wall-stage > iframe { width: 100%; height: 100%; border: none; display: block; }
.wall-mode #playerContainer > iframe,
.wall-mode #playerContainer > div > iframe { position: static !important; width: 100% !important; height: 100% !important; }
/* #109: PiP overlay layer — a fixed full-viewport layer ABOVE #playerContainer that
the playlist never touches. The same orientation transform is applied to it as to
#playerContainer, so corner positions track the visible content in every orientation.
Pointer-transparent and empty (invisible) until an overlay is shown. */
#pipContainer { position: fixed; inset: 0; pointer-events: none; z-index: 9000; }
#pipContainer > .pip-box { position: absolute; overflow: hidden; box-sizing: border-box; box-shadow: 0 6px 28px rgba(0,0,0,0.55); }
#pipContainer > .pip-box > .pip-title { font: 600 16px sans-serif; padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#pipContainer > .pip-box > img,
#pipContainer > .pip-box > iframe { display: block; width: 100%; border: 0; }
/* Status overlay */
#statusOverlay {
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #94a3b8; z-index: 500;
}
#statusOverlay h2 { color: #3b82f6; font-size: 28px; margin-bottom: 8px; }
#statusOverlay p { font-size: 16px; }
</style>
</head>
<body>
<!-- Setup Screen -->
<div id="setupScreen">
<h1>ScreenTinker</h1>
<div class="subtitle">Web Player</div>
<div class="form" id="urlForm">
<label>Server URL</label>
<input type="url" id="serverUrl" placeholder="https://sign.yourdomain.com" autofocus>
<button id="connectBtn">Connect</button>
</div>
<div id="pairingSection" style="display:none;text-align:center">
<p>Pairing Code</p>
<div class="pairing-code" id="pairingCode">------</div>
<p class="pairing-hint">Enter this code in the dashboard to pair this display</p>
</div>
<div class="spinner" id="setupSpinner" style="display:none"></div>
<div class="status-msg" id="setupStatus"></div>
</div>
<!-- Player Container -->
<div id="playerContainer" style="display:none"></div>
<!-- #109: PiP overlay layer (above the player; never touched by playlist render) -->
<div id="pipContainer"></div>
<!-- Status Overlay -->
<div id="statusOverlay" style="display:none">
<div class="spinner"></div>
<h2>ScreenTinker</h2>
<p id="statusText" data-i18n="connecting">Connecting...</p>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="/player/schedule-eval.js"></script>
<script src="/player/player-media-health.js"></script>
<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 embedding in a browser — it will still display on the device screen.',
},
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 en un navegador; aún se mostrará en la pantalla del dispositivo.',
},
fr: {
web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code dappairage', pairing_hint: 'Saisissez ce code dans le tableau de bord pour apparier cet écran', connecting: 'Connexion...', connecting_muted: 'Connexion (audio coupé)...', info_title: 'Lecteur web ScreenTinker', info_close_hint: 'Appuyez à nouveau sur Retour ou cliquez pour fermer', info_device_id: 'ID de lappareil', info_device_name: 'Nom de lappareil', info_server: 'Serveur', info_status: 'État', info_now_playing: 'En lecture', info_resolution: 'Résolution', info_uptime: 'Disponibilité', info_platform: 'Plateforme', info_cache: 'Cache', info_connected: 'Connecté', info_disconnected: 'Déconnecté', info_active: 'Actif', info_inactive: 'Inactif', info_nothing: 'Rien', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Rien de programmé pour le moment', preview_webpage_blocked: 'Si cette zone est vide, le site bloque lintégration dans un navigateur ; il saffichera quand même sur lécran de lappareil.',
},
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 im Browser auf dem Gerätebildschirm wird sie trotzdem angezeigt.',
},
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 em um navegador; ainda assim será exibido na tela do dispositivo.',
},
};
const PLAYER_LANG = (() => {
const stored = localStorage.getItem('rd_lang');
const detected = (stored || navigator.language || 'en').split('-')[0];
return PLAYER_I18N[detected] ? detected : 'en';
})();
const _t = (k) => (PLAYER_I18N[PLAYER_LANG] && PLAYER_I18N[PLAYER_LANG][k]) || PLAYER_I18N.en[k] || k;
// Apply translations to the static setup screen markup
document.addEventListener('DOMContentLoaded', () => {
const setSubtitle = document.querySelector('#setupScreen .subtitle');
if (setSubtitle) setSubtitle.textContent = _t('web_player');
const lblServer = document.querySelector('#urlForm label');
if (lblServer) lblServer.textContent = _t('server_url');
const inputServer = document.getElementById('serverUrl');
if (inputServer) inputServer.placeholder = _t('server_url_placeholder');
const connectBtn = document.getElementById('connectBtn');
if (connectBtn) connectBtn.textContent = _t('connect');
const pairingP = document.querySelector('#pairingSection p:first-child');
if (pairingP) pairingP.textContent = _t('pairing_code');
const pairingHint = document.querySelector('#pairingSection .pairing-hint');
if (pairingHint) pairingHint.textContent = _t('pairing_hint');
// Translate any element with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const k = el.getAttribute('data-i18n');
if (k) el.textContent = _t(k);
});
});
// ==================== Config ====================
const STORAGE_KEY = 'rd_web_player';
const HEARTBEAT_INTERVAL = 15000;
const PLAYLIST_REFRESH_INTERVAL = 60000;
// #104: device-free dashboard preview mode (set by the ?preview=1 boot branch).
// Gates the webpage-widget honest note and keeps the pairing/socket path off.
let PREVIEW_MODE = false;
function getConfig() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
}
function saveConfig(cfg) { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); }
const PLAYLIST_CACHE_KEY = 'rd_playlist_cache';
function savePlaylistCache(items) {
try { localStorage.setItem(PLAYLIST_CACHE_KEY, JSON.stringify(items)); } catch {}
}
function loadPlaylistCache() {
try { return JSON.parse(localStorage.getItem(PLAYLIST_CACHE_KEY) || '[]'); } catch { return []; }
}
// Cache the layout alongside the playlist so a cold start renders the correct
// zone layout on the FIRST pass, instead of rendering fullscreen and only
// switching to zones once the server payload arrives.
const LAYOUT_CACHE_KEY = 'rd_layout_cache';
function saveLayoutCache(l) {
try { localStorage.setItem(LAYOUT_CACHE_KEY, JSON.stringify(l || null)); } catch {}
}
function loadLayoutCache() {
try { return JSON.parse(localStorage.getItem(LAYOUT_CACHE_KEY) || 'null'); } catch { return null; }
}
// ==================== State ====================
let socket = null;
let config = getConfig();
let playlist = [];
let currentIndex = -1;
// #157: deferred rotation-out. When a playlist update removes the item currently on screen
// (e.g. it just expired) in solo playback, we keep it up and rotate to deferredSuccessorId on
// the next natural advance instead of interrupting/restarting.
let deferredRotation = false;
let deferredSuccessorId = null;
function itemIdentity(x) { return x ? `${x.content_id || ''}|${x.widget_id || ''}|${x.remote_url || ''}|${x.filepath || ''}` : ''; }
// @exit-signal-slice:start — v4-exit-signal-phase3.test.js evals the lines between these markers.
// ==================== Exit-signal contract v1 (best-effort last gasp) ====================
// Announce manner-of-death via navigator.sendBeacon (reliable-on-unload — survives the socket
// teardown). crashed: real uncaught SCRIPT error / unhandled rejection. clean_exit: pagehide with
// persisted=false (a genuine unload — NOT a bfcache suspend, which the liveness watchdog handles,
// and NOT mere visibility-hidden). Honesty: only these two confident categories are ever sent;
// anything uncertain sends nothing -> the server infers 'silent'. Idempotent (first signal wins,
// so a crash is never relabelled clean_exit by the pagehide that follows it).
let __exitSent = false;
function sendExitBeacon(reason, detail) {
try {
if (__exitSent) return;
if (reason !== 'crashed' && reason !== 'clean_exit') return;
if (!config || !config.deviceId || !config.deviceToken) return; // unpaired -> nothing to attribute
__exitSent = true;
const url = (config.serverUrl || window.location.origin) + '/api/device/exit';
const body = JSON.stringify({ device_id: config.deviceId, device_token: config.deviceToken,
reason, detail: (typeof detail === 'string' && detail) ? detail.slice(0, 200) : undefined });
const blob = new Blob([body], { type: 'application/json' }); // Content-Type so express.json parses it
if (navigator.sendBeacon && navigator.sendBeacon(url, blob)) return;
fetch(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(() => {});
} catch (e) { /* a dying page must never throw */ }
}
window.addEventListener('error', (ev) => {
// ONLY a real uncaught script error is a crash — a resource (img/script/link) load failure is NOT.
if (!ev) return;
const isResourceError = ev.target && ev.target !== window && (ev.target.src || ev.target.href);
if (isResourceError) return;
sendExitBeacon('crashed', (ev.error && ev.error.message) || ev.message || 'error');
});
window.addEventListener('unhandledrejection', (ev) => {
const r = ev && ev.reason;
sendExitBeacon('crashed', (r && (r.message || String(r))) || 'unhandledrejection');
});
window.addEventListener('pagehide', (ev) => {
if (ev && ev.persisted) return; // bfcache SUSPEND (may restore) — NOT a death; watchdog owns it
sendExitBeacon('clean_exit', 'pagehide');
});
// @exit-signal-slice:end
let isPlaying = false;
let playerTimezone = null; // #74/#75: device-effective IANA tz for schedule eval
let scheduleRetryTimer = null; // re-check when every item is filtered out
let heartbeatTimer = null;
let refreshTimer = null;
let remoteStreaming = false;
let streamTimer = null;
let layout = null;
let zones = {};
// Tracks whether the user has gestured in *this* page load. Browser autoplay
// policy is per-document — a flag from a previous session does NOT grant
// autoplay rights here, so we always start as false. The cold-load tap overlay
// is the only thing that flips this to true (or its 5s timeout, which keeps
// playback muted).
let userHasInteracted = false;
let advanceTimer = null;
// 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;
// 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;
// AudioContext is created lazily on the first user gesture. Resuming it
// is what convinces stricter browsers (Firefox) that the site is "user-
// activated" for audio. Reused across all later unmute attempts.
let _audioCtx = null;
function unlockAudioContext() {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume().catch(() => {});
// Play a 1-sample silent buffer to fully promote the context to running.
const buf = _audioCtx.createBuffer(1, 1, 22050);
const src = _audioCtx.createBufferSource();
src.buffer = buf;
src.connect(_audioCtx.destination);
src.start(0);
} catch (e) { /* harmless */ }
}
// Try to unmute and play the leader video. MUST be called synchronously
// from inside a real user-gesture handler — any preceding await would
// throw away the gesture's user-activation in stricter browsers (Firefox).
// Returns immediately; the play() promise is resolved/rejected async.
function tryUnmuteLeader() {
const video = document.querySelector('#playerContainer video');
if (!video) return false;
if (!video.muted) return true;
// Capture state, unmute, do a fresh pause+play within the same task.
// Firefox is more permissive when play() is treated as a brand-new
// gesture-driven start rather than the unmute of an autoplaying video.
const t = video.currentTime;
video.muted = false;
video.volume = 1.0;
video.pause();
const p = video.play();
if (p && typeof p.then === 'function') {
p.then(() => {
if (isFinite(t)) { try { video.currentTime = t; } catch {} }
console.log('[wall/audio] unmuted play() ok muted=' + video.muted + ' volume=' + video.volume);
hideEnableAudioPrompt();
}).catch((err) => {
console.warn('[wall/audio] unmuted play() rejected: ' + (err?.name || err?.message || err));
// Remute so playback continues; surface the prompt for explicit consent.
video.muted = true;
video.play().catch((e2) => console.error('[wall/audio] muted-fallback play() failed: ' + (e2?.name || e2?.message || e2)));
showEnableAudioPrompt();
});
}
return true;
}
// Visible "tap to enable audio" prompt for leaders whose unmute failed.
// The user clicking this prompt is itself a fresh gesture, which is the
// most reliable path past Firefox's autoplay restriction.
function showEnableAudioPrompt() {
if (isWallFollower()) return;
if (document.getElementById('enableAudioPrompt')) return;
const ov = document.createElement('div');
ov.id = 'enableAudioPrompt';
ov.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:12px 22px;border-radius:8px;cursor:pointer;z-index:10000;font-size:14px;display:flex;gap:10px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,0.4)';
ov.innerHTML = '<span style="font-size:20px">&#128263;</span><span>Tap to enable audio</span>';
ov.addEventListener('click', () => {
unlockAudioContext();
tryUnmuteLeader();
});
document.body.appendChild(ov);
}
function hideEnableAudioPrompt() {
document.getElementById('enableAudioPrompt')?.remove();
}
// Track user interaction for autoplay policy
['click', 'touchstart', 'keydown'].forEach(evt => {
document.addEventListener(evt, () => {
const wasFirst = !userHasInteracted;
userHasInteracted = true;
// First gesture: prime the AudioContext. This signals "site activated"
// to Firefox and unlocks subsequent <video> unmute attempts.
if (wasFirst) unlockAudioContext();
// Followers in a video wall must stay muted forever — even after a
// user gesture. Otherwise tapping a follower screen would unmute it
// and cause echo with the leader.
if (isWallFollower()) return;
if (wasFirst) console.log('[wall/audio] first user gesture detected — attempting unmute');
tryUnmuteLeader();
// Unmute YouTube player if active
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
try { activeYtPlayer.unMute(); activeYtPlayer.setVolume(100); console.log('Unmuted YouTube player'); } catch {}
}
}, { once: false });
});
// ==================== Browser Fingerprint ====================
function generateBrowserFingerprint() {
const components = [
navigator.userAgent,
navigator.language,
screen.width + 'x' + screen.height,
screen.colorDepth,
new Date().getTimezoneOffset(),
navigator.hardwareConcurrency || 0,
navigator.platform,
// Canvas fingerprint
(() => {
try {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillText('ScreenTinker fingerprint', 2, 2);
return c.toDataURL().slice(-50);
} catch { return ''; }
})(),
];
// Simple hash
const str = components.join('|');
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'web-' + Math.abs(hash).toString(36) + '-' + str.length.toString(36);
}
// ==================== Boot ====================
// Function used by connect button and auto-connect
let autoContinueTimer;
function connectBtnFunc() {
if (autoContinueTimer) {
clearInterval(autoContinueTimer);
autoContinueTimer = null;
document.getElementById('connectBtn').textContent = _t('connect');
}
unlockAudio();
const url = document.getElementById('serverUrl').value.trim().replace(/\/$/, '');
if (!url) return;
config.serverUrl = url;
saveConfig(config);
document.getElementById('connectBtn').disabled = true;
document.getElementById('setupSpinner').style.display = 'block';
document.getElementById('setupStatus').textContent = 'Connecting...';
connect(url);
};
// #104: device-free dashboard preview. Render a draft playlist by id with NO
// pairing and NO socket. Gated here, before the normal boot, so the unpaired
// auto-connect timer below can never fire underneath a preview.
const _previewQS = new URLSearchParams(location.search);
if (_previewQS.get('preview') === '1' && (_previewQS.get('playlist') || _previewQS.get('device'))) {
bootPreview(_previewQS);
} else {
// Auto-detect server URL from origin since player is served from the same server
if (!config.serverUrl) {
config.serverUrl = window.location.origin;
saveConfig(config);
}
if (config.serverUrl && config.deviceId && config.paired) {
// Restore cached playlist immediately so content plays even if offline —
// but ONLY if we also know the layout, else we'd guess fullscreen and flash
// before the payload arrives. Key presence (not value) is the test: an absent
// key means "layout unknown" (e.g. first run after this shipped, or cleared
// cache), while a stored `null` is a real fullscreen device. Both caches are
// written on every payload, so after the first connection this always renders
// immediately; only a genuinely-unknown layout waits (~1s) for the payload.
const cachedPlaylist = loadPlaylistCache();
const layoutKnown = localStorage.getItem(LAYOUT_CACHE_KEY) !== null;
if (cachedPlaylist.length > 0 && layoutKnown) {
console.log('Restored cached playlist:', cachedPlaylist.length, 'items');
playlist = cachedPlaylist;
layout = loadLayoutCache();
document.getElementById('setupScreen').style.display = 'none';
startPlaybackAt(0); // #74/#75: honour schedules from the first frame on cold-start
// #group-sync: if this device was in a sync group, resume the schedule immediately from the
// cached clock offset — so a reboot mid-outage comes back aligned WITHOUT waiting for a server.
try { const cg = localStorage.getItem('st_group_sync'); if (cg) applyGroupSync(JSON.parse(cg)); } catch (e) {}
}
// Always show the tap overlay on cold load. Browser autoplay policy is
// per-document — a localStorage flag from a prior session does not grant
// audio autoplay to a fresh page. The overlay auto-dismisses after 5s and
// connects muted, so unattended kiosks still recover without a human tap.
{
const tapOverlay = document.createElement('div');
tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
tapOverlay.innerHTML = `
<h1 style="color:#3b82f6;font-size:36px;font-family:sans-serif;margin-bottom:12px">ScreenTinker</h1>
<p style="color:#94a3b8;font-size:18px;font-family:sans-serif">Tap anywhere to start</p>
<p style="color:#64748b;font-size:13px;font-family:sans-serif;margin-top:24px">Audio requires user interaction</p>
`;
tapOverlay.onclick = () => {
unlockAudio();
tapOverlay.remove();
if (!isPlaying) showStatus(_t('connecting'));
connect(config.serverUrl);
};
document.body.appendChild(tapOverlay);
// Auto-dismiss after 5 seconds if no interaction (plays muted)
setTimeout(() => {
if (tapOverlay.parentNode) {
tapOverlay.remove();
if (!isPlaying) showStatus(_t('connecting_muted'));
connect(config.serverUrl);
}
}, 5000);
}
} else {
// Auto-Continue after 5s if not configured. If user interacts with form (typing in the box), stop the timer.
let countdown = 5;
const connectBtn = document.getElementById('connectBtn');
connectBtn.textContent = `${_t('connect')} (${countdown})`;
autoContinueTimer = setInterval(() => {
countdown--;
if (countdown > 0) {
connectBtn.textContent = `${_t('connect')} (${countdown})`;
} else {
clearInterval(autoContinueTimer);
connectBtn.textContent = _t('connect');
connectBtnFunc()
}
}, 1000);
document.getElementById('serverUrl').addEventListener('input', () => {
if (countdown > 0) {
clearInterval(autoContinueTimer);
connectBtn.textContent = _t('connect');
}
});
}
} // #104: end preview-mode gate (else branch wrapping the normal boot)
// ==================== Setup UI ====================
const savedUrl = config.serverUrl || window.location.origin;
document.getElementById('serverUrl').value = savedUrl;
// Unlock audio on any user interaction
function unlockAudio() {
userHasInteracted = true;
// Create and resume AudioContext (unlocks audio for the session)
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
ctx.resume().then(() => { console.log('AudioContext unlocked'); });
// Play a silent buffer to fully unlock
const buf = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(0);
} catch(e) { console.warn('Audio unlock failed:', e); }
// Wall followers must stay muted — leader is the only audio source.
if (isWallFollower()) return;
// Unmute any playing HTML5 video
document.querySelectorAll('video').forEach(v => { v.muted = false; });
// Unmute the active YouTube embed (iframe — querySelectorAll('video') misses it)
try {
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
activeYtPlayer.unMute();
activeYtPlayer.setVolume(100);
}
} catch (e) { console.warn('YT unmute failed:', e); }
}
document.getElementById('connectBtn').onclick = connectBtnFunc;
// ==================== #104 Device-free preview ====================
// #104: device-free dashboard preview. Renders EITHER a draft playlist
// (?playlist=ID — layout DERIVED from the playlist's zones, orientation togglable)
// OR a device exactly as it shows now (?device=ID — layout/orientation from the
// DEVICE row). Both produce the same payload shape and feed the UNMODIFIED renderer.
function bootPreview(qs) {
const playlistId = qs.get('playlist');
const deviceId = qs.get('device');
let url;
if (playlistId) {
const orientation = qs.get('orientation');
const q = orientation ? ('?orientation=' + encodeURIComponent(orientation)) : '';
url = '/api/playlists/' + encodeURIComponent(playlistId) + '/preview-payload' + q;
} else {
// Device preview: the device's own layout/orientation come from the server; no
// orientation override (we show what the device actually shows).
url = '/api/devices/' + encodeURIComponent(deviceId) + '/preview-payload';
}
return renderPreviewFromUrl(url);
}
// Shared: fetch a preview payload (same shape the device socket sends) and hand it
// straight to the UNMODIFIED renderer. No socket, no pairing.
async function renderPreviewFromUrl(url) {
PREVIEW_MODE = true;
config.serverUrl = window.location.origin; // same-origin -> /uploads + /api/widgets resolve
const setup = document.getElementById('setupScreen');
if (setup) setup.style.display = 'none';
try {
const token = localStorage.getItem('token'); // same-origin: shares the dashboard's Bearer token
const res = await fetch(url, { headers: token ? { Authorization: 'Bearer ' + token } : {} });
if (!res.ok) return showPreviewError(res.status);
const payload = await res.json();
// playlist-only: items span >1 layout (rare) — server picked the dominant one.
// Device payloads never carry this flag (layout is device-bound, unambiguous).
if (payload.layout && payload.layout._preview_ambiguous) {
previewBannerParts.layout = 'Previewing dominant layout "' + (payload.layout.name || '—') + '" — items span more than one layout';
}
renderPreviewBanner();
handlePlaylistUpdate(payload);
} catch (e) {
console.error('preview fetch failed', e);
showPreviewError(0);
}
}
// #zone-orphan: one operator-only banner, built from previewBannerParts. Created on
// first use, removed when there's nothing to report. PREVIEW_MODE only — the live
// player/wall must stay clean for the audience.
function renderPreviewBanner() {
if (!PREVIEW_MODE) return;
const lines = [previewBannerParts.layout, previewBannerParts.orphans].filter(Boolean);
let el = document.getElementById('previewBanner');
if (!lines.length) { if (el) el.remove(); return; }
if (!el) {
el = document.createElement('div');
el.id = 'previewBanner';
el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.94);color:#000;font:12px sans-serif;padding:5px 10px;text-align:center;line-height:1.4';
document.body.appendChild(el);
}
el.innerHTML = lines.join('<br>');
}
function showPreviewError(status) {
const msg = (status === 401 || status === 403) ? 'Not authorized to preview this playlist'
: status ? ('Preview failed (' + status + ')') : 'Preview failed to load';
const div = document.createElement('div');
div.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#e5e7eb;background:#111827;font:18px sans-serif;z-index:3000;text-align:center;padding:24px';
div.textContent = msg;
document.body.appendChild(div);
}
// #104: the always-visible honest note for webpage widgets. No auto-detection —
// an XFO-refused frame is provably indistinguishable client-side from a working
// one, so we never guess; we just tell the truth. Preview-only (never on device).
function addWebpageNote(container) {
if (!PREVIEW_MODE || !container) return;
try { if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; } catch (e) {}
const note = document.createElement('div');
note.className = 'preview-webpage-note';
note.textContent = _t('preview_webpage_blocked');
note.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:10;background:rgba(17,24,39,.82);color:#e5e7eb;font:13px/1.4 sans-serif;padding:6px 10px;text-align:center;pointer-events:none';
container.appendChild(note);
}
// ==================== v4 liveness watchdog ====================
// Brings /player onto the LOCKED v4 contract, IDENTICAL on the wire to the APK and .wgt.
// Threshold 45s ± up to 10s jitter (canonical); arm ONLY after a device:heartbeat-ack
// (degrade-safe — an ack-less server never arms us); lastServerMessageAt refreshes on ANY
// inbound (the SILENCE check) while ARMING gates on the ack. Backoff (1s→30s ±20%) is on the
// socket.io Manager (io opts below). NO status/health poll — load is read from ack-silence.
// Browser-specific half-open triggers (visibility/resume/online) drive the SAME check + the
// #148 teardown-first reconnect — additional triggers, not a separate mechanism.
const PLAYER_VERSION = '1.1.0-web';
const V4_THRESHOLD_BASE_MS = 45000, V4_THRESHOLD_JITTER_MS = 10000;
function v4ThresholdMs(rand) { return V4_THRESHOLD_BASE_MS + Math.round((rand - 0.5) * 2 * V4_THRESHOLD_JITTER_MS); }
// Wall-clock (Date.now) so silence COUNTS sleep/background time — the browser half-open causes
// (a setInterval tick alone would be throttled/frozen in a hidden tab and undercount).
let lastServerMessageAt = 0;
let livenessConfirmed = false;
let livenessWindowMs = V4_THRESHOLD_BASE_MS;
let watchdogTimer = null;
function markAlive() { lastServerMessageAt = Date.now(); } // ANY inbound refreshes silence (does NOT arm)
// Pure decision (matches the APK/.wgt watchdogShouldReconnect): reconnect only a connected +
// registered socket whose liveness we've ARMED that has gone silent past the jittered window.
function watchdogShouldReconnect(hasSocket, connected, armed, silentMs, windowMs) {
return !!(hasSocket && connected && armed && silentMs > windowMs);
}
function checkLiveness() {
// THROTTLE-AWARE. (1) Silence is computed by TIMESTAMP (now - lastServerMessageAt), never by
// timer-fire-count, so a background-THROTTLED/late timer still measures the ACTUAL elapsed
// silence — it won't miss a real half-open for firing late, nor false-fire for firing late.
// (2) While the tab is HIDDEN, timers are throttled and the gap is EXPECTED — do NOT act on it
// (never reconnect a backgrounded tab). The hidden->visible transition resets the grace via
// verifyLivenessSoon(), so we don't false-fire on resume either.
if (document.visibilityState !== 'visible') return;
const silentMs = lastServerMessageAt ? (Date.now() - lastServerMessageAt) : 0;
if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), livenessConfirmed, silentMs, livenessWindowMs)) {
console.log('[v4] half-open (silent ' + silentMs + 'ms > ' + livenessWindowMs + ') — teardown+reconnect');
connect(config.serverUrl); // #148 teardown-before-reopen: connect() disconnects the old socket first
}
}
// Fresh liveness check for a resume / bfcache-restore / network-change: silence accumulated
// while hidden or throttled is NOT trustworthy, so NEVER tear down a possibly-live socket on it.
// Reset the grace so the socket gets a fresh window to prove itself — a live socket's engine
// ping/ack refreshes silence within the window (no reconnect); a genuinely dead one stays silent
// past the window and the (now-visible) watchdog reconnects it. #148 teardown-first via connect().
function verifyLivenessSoon() {
lastServerMessageAt = Date.now(); // fresh grace — don't false-fire on stale hidden-silence
if (!socket) connect(config.serverUrl); // never connected / torn down -> establish (teardown-first)
// socket connected -> grace reset above; the watchdog verifies over the next window.
// socket present-but-disconnected -> socket.io's own reconnection already owns it.
}
function startWatchdog() { stopWatchdog(); watchdogTimer = setInterval(checkLiveness, 10000); }
function stopWatchdog() { if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; } }
function browserPlatform() {
try {
const m = navigator.userAgent.match(/(Edg|OPR|Chrome|Firefox|Version)\/(\d+)/);
if (m) { const name = { Edg: 'Edge', OPR: 'Opera', Version: 'Safari' }[m[1]] || m[1]; return name + ' ' + m[2]; }
return 'Browser';
} catch (e) { return 'Browser'; }
}
if (typeof window !== 'undefined') { window.__v4ThresholdMs = v4ThresholdMs; window.__v4WatchdogShouldReconnect = watchdogShouldReconnect; }
// ==================== Socket Connection ====================
function connect(serverUrl) {
if (socket) { socket.disconnect(); socket = null; }
socket = io(serverUrl + '/device', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000, // v4 canonical: 1s start (was 2s)
reconnectionDelayMax: 30000, // v4 canonical: 30s cap, within the ~30-60s band (was 10s)
randomizationFactor: 0.2, // v4 canonical: ±20% jitter (was the socket.io 0.5 default)
timeout: 20000,
// Prefer WebSocket but allow polling fallback. Socket.IO default is
// polling-first with an upgrade dance that's fragile on TV WebKits
// (LG webOS especially). Reversing the order opens a WebSocket directly;
// if that fails (rare - blocked by firewall), it falls back to polling
// on the same connect attempt. Tradeoff: WS-blocked networks add a few
// seconds to first connect while WS times out. Worth it for the common
// case where WS is fine but the upgrade dance was hanging the device.
transports: ['websocket', 'polling'],
});
// v4 liveness: a fresh socket is assumed alive; DIS-arm until a heartbeat-ack re-arms; pick a
// fresh jittered window for this connection. markAlive on ANY inbound (app events via onAny +
// the engine ping) refreshes the SILENCE timer only — it does NOT arm.
lastServerMessageAt = Date.now();
livenessConfirmed = false;
livenessWindowMs = v4ThresholdMs(Math.random());
socket.onAny(markAlive);
socket.io.on('ping', markAlive);
// v4 degrade-safe ARM: the watchdog arms ONLY after the first app-level device:heartbeat-ack.
socket.on('device:heartbeat-ack', (data) => { livenessConfirmed = true; if (data) ingestClockSample(data.server_ms, data.client_ms); });
socket.on('connect', () => {
console.log('Connected');
register();
});
socket.on('disconnect', () => {
console.log('Disconnected');
stopHeartbeat();
stopWatchdog(); // socket.io owns the reconnect once it KNOWS it's down; watchdog is for half-open only
});
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);
if (!config.paired) {
// Show pairing code
document.getElementById('urlForm').style.display = 'none';
document.getElementById('setupSpinner').style.display = 'none';
document.getElementById('pairingSection').style.display = 'block';
document.getElementById('pairingCode').textContent = config.pairingCode || '------';
document.getElementById('setupStatus').textContent = '';
}
startHeartbeat();
startWatchdog(); // v4: arm-gated half-open watchdog (no-op until a heartbeat-ack arms it)
startPlaylistRefresh();
startVersionCheck();
});
socket.on('device:paired', (data) => {
config.paired = true;
config.deviceName = data.name;
saveConfig(config);
console.log('Paired as:', data.name);
document.getElementById('setupScreen').style.display = 'none';
// #146 fix: the server re-emits device:paired on EVERY re-register of an already-
// paired device (deviceSocket.js), i.e. on every reconnect — while content is already
// playing. Showing the idle "Waiting for content..." overlay unconditionally covered
// the live video (audio kept playing underneath) and the subsequent "Playlist
// unchanged" left it up. Only fall to idle when nothing is actually playing.
// Guard the METHOD, not just the object: a device running a stale-cached
// player-media-health.js (older module, no shouldShowIdle) would otherwise throw
// "shouldShowIdle is not a function" here and abort the rest of this handler. The
// !isPlaying fallback is equivalent for the playing case (playing -> not idle).
const PMH = window.PlayerMediaHealth;
const showIdle = (PMH && typeof PMH.shouldShowIdle === 'function')
? PMH.shouldShowIdle({ isPlaying: isPlaying, hasContent: playlist.length > 0 })
: !isPlaying;
if (showIdle) showStatus('Waiting for content...');
});
socket.on('device:unpaired', () => {
console.warn('Device not found on server — clearing credentials');
delete config.deviceId;
delete config.deviceToken;
config.paired = false;
saveConfig(config);
savePlaylistCache([]);
document.getElementById('setupScreen').style.display = 'flex';
document.getElementById('urlForm').style.display = 'block';
document.getElementById('pairingSection').style.display = 'none';
document.getElementById('setupStatus').textContent = 'Device was removed from server. Please reconnect.';
});
socket.on('device:auth-error', (data) => {
console.warn('Device auth rejected:', data?.error || 'unknown');
delete config.deviceId;
delete config.deviceToken;
config.paired = false;
saveConfig(config);
document.getElementById('setupScreen').style.display = 'flex';
document.getElementById('urlForm').style.display = 'block';
document.getElementById('pairingSection').style.display = 'none';
document.getElementById('setupStatus').textContent = 'Authentication failed. Please re-pair this device.';
});
socket.on('device:playlist-update', (data) => {
console.log('Playlist update:', data.assignments?.length, 'items');
handlePlaylistUpdate(data);
});
// Video wall sync (leader broadcasts; followers align)
socket.on('wall:sync', (data) => {
if (!wallConfig || wallConfig.is_leader) return;
if (data.wall_id !== wallConfig.wall_id) return;
lastWallSync = data;
// If leader switched item, jump to it
if (typeof data.current_index === 'number' && data.current_index !== currentIndex && playlist.length > 0) {
currentIndex = ((data.current_index % playlist.length) + playlist.length) % playlist.length;
playCurrentItem();
}
// Hold the follower close to the leader's clock. Account for relay
// latency: the leader was at position_sec when sent_at was stamped;
// by now a bit more time has elapsed, so target = position + latency.
if (currentVideoEl && typeof data.position_sec === 'number') {
const now = Date.now();
const latency = data.sent_at ? Math.max(0, (now - data.sent_at) / 1000) : 0;
const target = data.position_sec + latency;
const drift = (currentVideoEl.currentTime || 0) - target;
const absDrift = Math.abs(drift);
if (absDrift > 0.3 && isFinite(currentVideoEl.duration) && target < currentVideoEl.duration) {
// Big drift: hard seek and reset rate.
try { currentVideoEl.currentTime = target; } catch (_) {}
try { currentVideoEl.playbackRate = 1.0; } catch (_) {}
} else if (absDrift > 0.05) {
// Small drift: nudge playbackRate to converge gently. ±3% is
// imperceptible on most content but pulls in 50ms drift in <2s.
try { currentVideoEl.playbackRate = drift > 0 ? 0.97 : 1.03; } catch (_) {}
} else if (currentVideoEl.playbackRate !== 1.0) {
// In-window: ride at normal rate.
try { currentVideoEl.playbackRate = 1.0; } catch (_) {}
}
}
});
// Leader receives a sync-request from a (re)connecting follower and
// immediately broadcasts its position so the requester can align without
// waiting for the next periodic tick.
socket.on('wall:sync-request', (data) => {
if (!wallConfig?.is_leader) return;
if (data?.wall_id && data.wall_id !== wallConfig.wall_id) return;
console.log('[wall] sync-request received from ' + data?.requested_by + ', broadcasting current position');
emitWallSync();
});
// #group-sync: index+position are computed LOCALLY from the disciplined clock + the shared
// schedule (see groupScheduleTick). There is deliberately NO leader and NO server relay of
// positions — every member derives the same tick from the same clock, so sync survives an
// internet outage and there is no leader to go split-brain. The server only (a) disciplines
// the clock via the heartbeat-ack and (b) can nudge an immediate re-align on demand:
socket.on('group:resync', (data) => {
if (!groupSync) return;
if (data?.group_id && data.group_id !== groupSync.group_id) return;
groupReport('info', 'manual resync requested');
groupScheduleTick(); // recompute + snap to the schedule target right now
});
socket.on('device:screenshot-request', () => { console.log('Screenshot requested'); captureAndSend(); });
socket.on('device:remote-start', () => { console.log('Remote start received'); remoteStreaming = true; startStreaming(); });
socket.on('device:remote-stop', () => { console.log('Remote stop received'); remoteStreaming = false; stopStreaming(); });
socket.on('device:remote-touch', (data) => {
// Simulate click at normalized coordinates within the player
const container = document.getElementById('playerContainer');
if (!container) return;
const x = data.x * container.offsetWidth;
const y = data.y * container.offsetHeight;
const el = document.elementFromPoint(x, y);
if (el) el.click();
console.log('Touch:', data.x, data.y, '-> element:', el?.tagName);
});
socket.on('device:remote-key', (data) => {
console.log('Key:', data.keycode);
const video = document.querySelector('#playerContainer video');
switch (data.keycode) {
case 'KEYCODE_DPAD_RIGHT':
// Skip to next content
nextItem();
break;
case 'KEYCODE_DPAD_LEFT':
// Go to previous content
currentIndex = (currentIndex - 2 + playlist.length) % playlist.length;
nextItem();
break;
case 'KEYCODE_DPAD_CENTER':
case 'KEYCODE_ENTER':
// Toggle play/pause
if (video) { video.paused ? video.play() : video.pause(); }
break;
case 'KEYCODE_VOLUME_UP':
// Wall followers ignore volume changes — they stay silent.
if (video && !isWallFollower()) { video.volume = Math.min(1, video.volume + 0.1); video.muted = false; }
break;
case 'KEYCODE_VOLUME_DOWN':
if (video) { video.volume = Math.max(0, video.volume - 0.1); }
break;
case 'KEYCODE_MENU':
// Toggle mute (followers can't unmute)
if (video && !(isWallFollower() && video.muted)) { video.muted = !video.muted; }
break;
case 'KEYCODE_HOME':
// Go back to first item
currentIndex = -1;
nextItem();
break;
case 'KEYCODE_BACK':
// Show/hide status overlay with device info
const overlay = document.getElementById('infoOverlay');
if (overlay) { overlay.style.display = overlay.style.display === 'none' ? 'flex' : 'none'; }
break;
case 'KEYCODE_POWER':
// Toggle screen (show black overlay)
toggleScreenOff();
break;
}
});
socket.on('device:command', (data) => {
console.log('Command:', data.type);
if (data.type === 'refresh') location.reload();
if (data.type === 'launch') { document.getElementById('screenOffOverlay')?.remove(); }
if (data.type === 'screen_off') toggleScreenOff();
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); }
});
// #129: real-time mute. Apply immediately if the toggled item is the one playing now;
// the value is also persisted in the snapshot so it sticks on the next playlist load.
socket.on('device:mute-changed', (data) => {
const item = playlist[currentIndex];
if (data && item && data.content_id && item.content_id === data.content_id && currentVideoEl) {
try { currentVideoEl.muted = !!data.muted; } catch (_) {}
}
});
// #109: PiP overlay — a pushed floating layer above the playlist. The player
// fetches uri itself (same trust model as remote_url content).
socket.on('device:pip-show', (data) => pipShow(data));
socket.on('device:pip-clear', (data) => pipClear(data && data.pip_id));
}
// ==================== PiP overlay (#109) ====================
// Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
// (id-aware) or timer tears down. Renders into #pipContainer, never the player. Mirrors
// the Tizen PipOverlay (tizen/js/pip-overlay.js). Teardown is wrapped so a malformed
// payload can't wedge the layer.
let pipTimer = null, pipCurrent = null;
const PIP_POS = {
'top-left': { top: '4%', left: '4%' }, 'top-right': { top: '4%', right: '4%' },
'bottom-left': { bottom: '4%', left: '4%' }, 'bottom-right': { bottom: '4%', right: '4%' },
'center': { top: '50%', left: '50%', transform: 'translate(-50%,-50%)' },
};
const pipColor = (c) => (typeof c === 'string' && /^#[0-9A-Fa-f]{6}$/.test(c)) ? c : null;
const pipPx = (v, d) => { const n = Number(v); return (isFinite(n) && n > 0 ? n : d) + 'px'; };
function pipReport(level, msg) {
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'pip', level, message: msg }); } catch (e) {}
}
// Zone diagnostics (orphaned zone_id fallback). Mirrors pipReport: stream to the
// dashboard device-log (tag 'zone') AND the in-page debug overlay buffer.
function zoneReport(level, msg) {
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'zone', level, message: msg }); } catch (e) {}
try { window.__debugLog_push && window.__debugLog_push({ type: 'zone', level: level, msg: msg }); } catch (e) {}
try { console.warn('[zone] ' + msg); } catch (e) {}
}
function pipTeardown() {
try { if (pipTimer) clearTimeout(pipTimer); } catch (e) {}
pipTimer = null; pipCurrent = null;
const c = document.getElementById('pipContainer'); if (c) c.innerHTML = '';
}
function pipShow(p) {
const container = document.getElementById('pipContainer');
if (!p || !container) return;
try {
pipTeardown(); // single slot, last-show-wins
const box = document.createElement('div');
box.className = 'pip-box';
box.style.width = pipPx(p.width, 480);
box.style.height = pipPx(p.height, 360);
box.style.background = pipColor(p.background_color) || '#000000';
if (p.opacity != null && isFinite(Number(p.opacity))) box.style.opacity = String(Math.max(0, Math.min(1, Number(p.opacity))));
if (p.border_radius != null && isFinite(Number(p.border_radius))) box.style.borderRadius = pipPx(p.border_radius, 0);
const pos = PIP_POS[p.position] || PIP_POS['top-right'];
Object.keys(pos).forEach((k) => { box.style[k] = pos[k]; });
const hasTitle = p.title != null && p.title !== '';
if (hasTitle) {
const bar = document.createElement('div');
bar.className = 'pip-title';
bar.textContent = String(p.title);
bar.style.color = pipColor(p.title_color) || '#ffffff';
bar.style.background = 'rgba(0,0,0,0.45)';
box.appendChild(bar);
}
let media;
if (p.type === 'web') {
media = document.createElement('iframe');
media.setAttribute('frameborder', '0');
media.setAttribute('scrolling', 'no');
media.setAttribute('allow', ''); // mute web audio by default (deny autoplay)
media.src = p.uri;
} else {
media = document.createElement('img');
media.src = p.uri;
}
media.style.height = hasTitle ? 'calc(100% - 32px)' : '100%';
media.style.objectFit = 'cover';
box.appendChild(media);
container.appendChild(box);
pipCurrent = p.pip_id || '(anon)';
const dur = Number(p.duration);
if (isFinite(dur) && dur > 0) pipTimer = setTimeout(() => pipClear(pipCurrent), dur * 1000);
pipReport('info', `pip show ${p.type || '?'} ${p.pip_id || ''} pos=${p.position || 'top-right'} dur=${isFinite(dur) ? dur : 0}`);
} catch (e) {
pipTeardown();
pipReport('warn', 'pip show failed: ' + (e && e.message ? e.message : e));
}
}
function pipClear(pipId) {
// A clear carrying a pip_id only clears if it matches the showing overlay.
if (pipId && pipCurrent && pipId !== pipCurrent) return;
const had = !!pipCurrent;
pipTeardown();
if (had) pipReport('info', 'pip cleared' + (pipId ? ' ' + pipId : ''));
}
function register() {
const data = {};
// #163: always send device identity when we have it, regardless of paired
// status. Before this fix, a reconnect before pairing would omit device_id
// and device_token, causing the server's fingerprint reclaim guard to fire
// device:auth-error ("Authentication failed. Please re-pair this device.")
// because the same browser fingerprint looked like a reinstall attack.
if (config.deviceId) {
data.device_id = config.deviceId;
if (config.deviceToken) data.device_token = config.deviceToken;
}
if (!config.paired) {
// Reuse the existing pairing code across reconnects so the operator
// doesn't see a new code every time the socket flaps. Only generate a
// fresh one on the very first registration when no code exists yet.
if (!config.pairingCode) {
const code = String(Math.floor(100000 + Math.random() * 900000));
config.pairingCode = code;
saveConfig(config);
}
data.pairing_code = config.pairingCode;
}
data.device_info = {
android_version: 'Web/' + navigator.userAgent.split(' ').pop(),
app_version: '1.1.0-web',
screen_width: screen.width,
screen_height: screen.height,
};
// v4 client identity block — additive, canonical snake_case (same shape as APK/.wgt so the
// server consumes one thing). Backward-compatible: an old server ignores unknown fields.
data.client_type = 'player';
data.client_version = PLAYER_VERSION;
data.platform = browserPlatform();
data.contract_version = 'v4';
// Browser fingerprint (survives localStorage clear)
data.fingerprint = generateBrowserFingerprint();
console.log(`[register] device_id=${data.device_id || 'none'}, has_token=${!!data.device_token}, token_len=${data.device_token?.length || 0}, paired=${config.paired}, pairing_code=${data.pairing_code || 'none'}`);
socket.emit('device:register', data);
}
// ==================== Heartbeat ====================
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (!socket?.connected || !config.deviceId) return;
socket.emit('device:heartbeat', {
device_id: config.deviceId,
client_ms: Date.now(), // #group-sync: t1 for NTP-style clock discipline (echoed in the ack)
telemetry: {
battery_level: null,
battery_charging: false,
storage_free_mb: null,
storage_total_mb: null,
ram_free_mb: null,
ram_total_mb: null,
cpu_usage: null,
wifi_ssid: 'Web Player',
wifi_rssi: null,
uptime_seconds: Math.floor(performance.now() / 1000),
// #74/#75: report OS timezone + UTC clock (effective-tz resolution + skew indicator)
timezone: (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { return null; } })(),
device_utc: Date.now(),
}
});
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
function startPlaylistRefresh() {
// No longer needed — server pushes playlist updates instantly via WebSocket.
// Kept as a fallback with a long interval in case a push is missed.
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
if (socket?.connected && config.deviceId && config.paired) {
const data = { device_id: config.deviceId, device_info: {} };
if (config.deviceToken) data.device_token = config.deviceToken;
console.log(`[refresh-register] device_id=${config.deviceId}, has_token=${!!config.deviceToken}`);
socket.emit('device:register', data);
}
}, 300000); // 5 minutes fallback
}
// ==================== Auto-reload on code update ====================
let knownServerHash = null;
let versionCheckTimer = null;
function startVersionCheck() {
if (versionCheckTimer) clearInterval(versionCheckTimer);
// Initial fetch to learn current hash
fetch(config.serverUrl + '/api/version').then(r => r.json()).then(data => {
knownServerHash = data.hash;
console.log('Server version:', data.version, 'hash:', data.hash);
}).catch(() => {});
// Poll every 30s
versionCheckTimer = setInterval(() => {
fetch(config.serverUrl + '/api/version').then(r => r.json()).then(data => {
if (knownServerHash && data.hash !== knownServerHash) {
console.log('Server code updated, reloading...', knownServerHash, '->', data.hash);
location.reload();
}
}).catch(() => {});
}, 30000);
}
// ==================== Video Wall ====================
// Convert a wall_config payload into CSS that sizes & positions the wall
// stage so this device's tile is the visible portion. Each tile is
// 100vw × 100vh; the stage is the full grid, translated by this tile's
// grid position (plus bezel offsets in px between tiles).
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');
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 });
}
}
}
function emitWallSync() {
if (!wallConfig?.is_leader || !socket?.connected || playlist.length === 0) return;
const item = playlist[currentIndex];
if (!item) return;
const position = currentVideoEl
? (currentVideoEl.currentTime || 0)
: Math.max(0, (Date.now() - currentItemStartedAt) / 1000);
socket.emit('wall:sync', {
wall_id: wallConfig.wall_id,
device_id: config.deviceId,
current_index: currentIndex,
content_id: item.content_id || null,
position_sec: position,
sent_at: Date.now(),
});
}
// #group-sync schedule engine. The shared, deterministic playlist schedule (each item occupies
// duration_sec, in order, skipping dayparted-out items) is laid on the synced clock: phase =
// syncedNow mod totalPeriod. Every same-playlist member computes the identical (index, position)
// — no leader, no relay, no split-brain — and it keeps running with no server at all.
function groupScheduleSlots() {
const slots = []; let acc = 0;
for (let i = 0; i < playlist.length; i++) {
if (!scheduleAllows(playlist[i])) continue; // same daypart filter as solo playback
const dur = Math.max(1, Number(playlist[i].duration_sec) || 10) * 1000;
slots.push({ index: i, start: acc, dur }); acc += dur;
}
return { slots, period: acc };
}
function groupScheduleTarget() {
const { slots, period } = groupScheduleSlots();
if (!slots.length || period <= 0) return null;
const phase = ((syncedNow() % period) + period) % period;
let ci = slots.findIndex(x => phase >= x.start && phase < x.start + x.dur);
if (ci < 0) ci = slots.length - 1;
const s = slots[ci];
const next = slots[(ci + 1) % slots.length];
// nextIndex + secToBoundary drive the double buffer (preload the upcoming clip a few s early).
return { index: s.index, posSec: (phase - s.start) / 1000, nextIndex: next.index, secToBoundary: (s.start + s.dur - phase) / 1000 };
}
// Runs at 4Hz while in a group: snap the index to the schedule, and for video correct drift with
// the same seek/nudge maths the wall uses — but toward the SCHEDULE target, not a leader broadcast.
function groupScheduleTick() {
if (!groupSync || playlist.length === 0) return;
const t = groupScheduleTarget();
if (!t) return;
// Double buffer: warm the next clip ~6s before the boundary (once per boundary).
if (t.nextIndex !== t.index && t.secToBoundary >= 0 && t.secToBoundary <= 6 && groupPreloadIdx !== t.nextIndex) {
groupPreloadNext(t.nextIndex);
}
let action = 'hold';
if (t.index !== currentIndex) {
currentIndex = t.index;
playCurrentItem();
action = 'jump>' + t.index;
} else if (currentVideoEl && isFinite(currentVideoEl.duration) && currentVideoEl.duration > 0) {
const dur = currentVideoEl.duration;
const target = t.posSec % dur; // loop-safe when duration_sec > clip length
const drift = (currentVideoEl.currentTime || 0) - target;
const ad = Math.abs(drift);
if (currentIndex !== groupLastAlignedIndex) groupAlignPending = true;
if (groupAlignPending) {
if (ad > 0.05) { try { currentVideoEl.currentTime = target; } catch (e) {} groupLastSeekAt = Date.now(); }
try { currentVideoEl.playbackRate = 1.0; } catch (e) {}
groupAlignPending = false; groupLastAlignedIndex = currentIndex;
action = 'align ' + drift.toFixed(2);
}
else if (ad > 0.3 && Date.now() - groupLastSeekAt > 1200) { try { currentVideoEl.currentTime = target; } catch (e) {} try { currentVideoEl.playbackRate = 1.0; } catch (e) {} groupLastSeekAt = Date.now(); action = 'seek ' + drift.toFixed(2); }
else if (ad > 0.05) { try { currentVideoEl.playbackRate = drift > 0 ? 0.97 : 1.03; } catch (e) {} action = 'nudge ' + drift.toFixed(2); }
else if (currentVideoEl.playbackRate !== 1.0) { try { currentVideoEl.playbackRate = 1.0; } catch (e) {} }
}
// Log discrete corrections (jump/align/seek) immediately so the transition is visible; only the
// routine steady-state line (hold/nudge) is throttled — else the one-tick "align" on load is
// sampled over by a later "hold"/"nudge" and reads misleadingly.
const now = Date.now();
const discrete = action.indexOf('jump') === 0 || action.indexOf('align') === 0 || action.indexOf('seek') === 0;
if (discrete || now - groupDbgLast > 1000) {
groupDbgLast = now;
groupReport('info', 'idx=' + currentIndex + ' tgt=' + t.index + ' pos=' + t.posSec.toFixed(2) + ' off=' + clockOffsetMs + 'ms rtt=' + clockRttMs + 'ms ' + action);
}
}
// Double buffer: build a hidden, buffering <video> for the next clip so renderContent can mount it
// instantly at the boundary (no black hold). Only for videos; images/widgets/youtube skip it.
function groupPreloadNext(idx) {
const item = playlist[idx];
if (!item) return;
const isVid = item.mime_type && item.mime_type.indexOf('video/') === 0 && item.mime_type !== 'video/youtube';
if (!isVid) { groupPreloadIdx = idx; groupPreloadEl = null; return; } // mark handled, nothing to warm
const url = item.remote_url || (config.serverUrl + '/uploads/content/' + item.filepath);
try {
if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} }
const v = document.createElement('video');
v.src = url; v.muted = true; v.playsInline = true; v.preload = 'auto';
v.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;left:-9999px';
v.load();
document.body.appendChild(v);
groupPreloadEl = v; groupPreloadIdx = idx;
groupReport('info', 'preload>' + idx);
} catch (e) { groupPreloadEl = null; groupPreloadIdx = -1; }
}
// Hand off the preloaded element for `idx` (or null). Caller re-parents + plays it; src is already
// set and buffered, so playback starts near-instantly with no reload.
function takeGroupPreload(idx) {
if (groupPreloadIdx === idx && groupPreloadEl) { const el = groupPreloadEl; groupPreloadEl = null; groupPreloadIdx = -1; return el; }
return null;
}
// Enter/leave group sync. No CSS transform, no forced mute (per-item mute honored), no leader —
// just start the schedule tick. Idempotent across refreshes/role churn (there is no role now).
function applyGroupSync(cfg) {
if (groupSyncTimer) { clearInterval(groupSyncTimer); groupSyncTimer = null; }
try { if (cfg) localStorage.setItem('st_group_sync', JSON.stringify(cfg)); else localStorage.removeItem('st_group_sync'); } catch (e) {}
if (!cfg) {
if (groupSync) groupReport('info', 'group-sync exited'); groupSync = null; console.log('[group-sync] exited');
if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} groupPreloadEl = null; groupPreloadIdx = -1; }
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
}
// Map the player rect into this device's viewport using vw/vh so the
// viewport fills edge-to-edge (no pillarbox at the seam between adjacent
// screens). With object-fit:fill on the video, the source stretches to
// the stage — which keeps the vertical position of every source pixel
// identical across devices that share a viewport height (1vh maps to
// the same physical pixel on each).
function styleWallStage(stageEl) {
if (!wallConfig?.screen_rect || !wallConfig?.player_rect) return;
const s = wallConfig.screen_rect;
const p = wallConfig.player_rect;
if (!s.w || !s.h) return;
const left = ((p.x - s.x) / s.w) * 100;
const top = ((p.y - s.y) / s.h) * 100;
const width = (p.w / s.w) * 100;
const height = (p.h / s.h) * 100;
const dev = (config.deviceId || '?').slice(0, 8);
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p));
console.log('[wall/render ' + dev + '] viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' DPR=' + window.devicePixelRatio);
console.log('[wall/render ' + dev + '] stage: left=' + left.toFixed(4) + 'vw top=' + top.toFixed(4) + 'vh width=' + width.toFixed(4) + 'vw height=' + height.toFixed(4) + 'vh');
stageEl.style.left = left + 'vw';
stageEl.style.top = top + 'vh';
stageEl.style.width = width + 'vw';
stageEl.style.height = height + 'vh';
stageEl.style.transform = '';
}
// No-op kept for callers that bind a resize listener (kept around in case
// future zoom/orientation tweaks need it). vw/vh stage updates with the
// viewport automatically, so explicit re-style isn't needed today.
function bindWallResizeOnce() {}
// ==================== Playlist ====================
function handlePlaylistUpdate(data) {
// Check if device is suspended (trial expired / over limit)
if (data.suspended) {
isPlaying = false;
playlist = [];
document.getElementById('playerContainer').style.display = 'none';
const overlay = document.getElementById('statusOverlay');
overlay.style.display = 'flex';
overlay.innerHTML = `
<div style="text-align:center;max-width:500px">
<div style="font-size:64px;margin-bottom:16px">&#9888;</div>
<h2 style="color:#f59e0b;margin-bottom:8px">${data.message || 'Account Suspended'}</h2>
<p style="color:#94a3b8;font-size:16px;margin-bottom:24px">${data.detail || 'Please upgrade your plan.'}</p>
<p style="color:#64748b;font-size:13px">Visit your dashboard to manage your subscription</p>
</div>
`;
return;
}
const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change.
// #74/#75: include schedules so a schedule edit (same content) is detected too.
// STRUCTURAL fingerprint only (identity + order + schedules). duration_sec is deliberately
// EXCLUDED so a duration-only edit is not treated as a full change/restart — it's applied IN
// PLACE in the unchanged branch below (the schedule tick + advance timer read duration_sec live).
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}`).join(',');
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
// Apply orientation. #109: the PiP layer gets the SAME transform as the player so a
// corner overlay tracks the visible content (not the physical panel) in every orientation.
if (data.orientation) {
const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' };
const portrait = data.orientation.includes('portrait');
[document.getElementById('playerContainer'), document.getElementById('pipContainer')].forEach((el) => {
if (!el) return;
el.style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
if (portrait) {
el.style.transformOrigin = 'center center';
el.style.width = '100vh';
el.style.height = '100vw';
} else {
el.style.width = '';
el.style.height = '';
}
});
}
// Apply (or clear) wall mode. Force re-render when wall config changes
// even if the playlist itself didn't, so leader/follower role transitions
// and tile reassignments take effect immediately.
function wallKey(c) {
if (!c) return '';
const s = c.screen_rect || {}, p = c.player_rect || {};
return `${c.wall_id}:${c.is_leader}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
}
const wallChanged = wallKey(wallConfig) !== wallKey(data.wall_config);
if (wallChanged) applyWallMode(data.wall_config || null);
// A fresh playlist-update on a follower (typical after socket reconnect)
// is a good signal to ask the leader for its current position even when
// the wall config itself didn't change. Cheap, debounced server-side.
if (!wallChanged && wallConfig && !wallConfig.is_leader && socket?.connected) {
socket.emit('wall:sync-request', { wall_id: wallConfig.wall_id });
}
// #group-sync: enter/leave on group membership change (mutually exclusive with wall — the
// server sends group_sync=null for a wall member). There's no leader role, so the key is just
// the group id; a plain refresh re-aligns the schedule locally (no server round-trip needed).
const groupKey = (g) => (g ? String(g.group_id) : '');
const groupChanged = groupKey(groupSync) !== groupKey(data.group_sync);
if (groupChanged) applyGroupSync(data.group_sync || null);
else if (groupSync) groupScheduleTick();
layout = data.layout || null;
saveLayoutCache(layout);
playerTimezone = data.timezone || null; // #74/#75: effective tz for schedule eval
if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
console.log('Playlist unchanged');
// In-place duration refresh: a duration-only edit keeps the structural fingerprint identical,
// so patch duration_sec onto the live items here. The group schedule tick re-anchors on the
// new period next tick; solo advance uses it on the next item. No restart, no reload.
for (let i = 0; i < playlist.length && i < newItems.length; i++) {
if (playlist[i].duration_sec !== newItems[i].duration_sec) playlist[i].duration_sec = newItems[i].duration_sec;
}
// #146 fix: a no-change refresh used to blindly return — so if the <video> surface
// had been lost (detached from the DOM while its element kept decoding audio: video
// gone, audio still playing), the re-attach (which lives ONLY in the content-changed
// branch below) never ran. Re-render the CURRENT item, but ONLY when the surface is
// actually unhealthy, so a healthy poll stays a no-op (no flicker on every refresh).
try {
const item = playlist[currentIndex];
const kind = !item ? 'other'
: item.widget_id ? 'widget'
: item.mime_type === 'video/youtube' ? 'youtube'
: (typeof item.mime_type === 'string' && item.mime_type.startsWith('video/')) ? 'video'
: (typeof item.mime_type === 'string' && item.mime_type.startsWith('image/')) ? 'image' : 'other';
const container = document.getElementById('playerContainer');
const state = {
isPlaying: isPlaying,
hasCurrentItem: !!item,
itemKind: kind,
videoEl: currentVideoEl
? { attached: document.contains(currentVideoEl), ended: !!currentVideoEl.ended, errored: !!currentVideoEl.error }
: null,
surfaceAttached: !!(container && container.querySelector('video,img,iframe,.wall-stage')),
};
if (window.PlayerMediaHealth && typeof PlayerMediaHealth.needsReattach === 'function' && PlayerMediaHealth.needsReattach(state)) {
console.log('[refresh] media surface lost on no-change refresh — re-attaching current item');
playCurrentItem();
} else if (isPlaying) {
// #146 fix: unchanged + already playing must LEAVE playback exactly as-is. Clear
// any stale idle overlay (e.g. the one a reconnect's device:paired put up) so the
// "unchanged" confirmation never leaves "Waiting for content..." over live content.
hideStatus();
}
} catch (e) { /* never let the health check break a refresh */ }
return;
}
console.log('Playlist changed, updating');
// Capture old state BEFORE mutating so continuity logic can find what was playing.
const identityOf = itemIdentity;
const oldPlaylist = playlist;
const oldAnchorIdx = currentIndex;
const oldAnchorId = identityOf(oldPlaylist[oldAnchorIdx]);
playlist = newItems;
savePlaylistCache(playlist);
// #157: a fresh structural update supersedes any pending deferred rotation; the branches
// below re-arm it only if the current item was removed while live in solo playback.
deferredRotation = false;
deferredSuccessorId = null;
if (playlist.length === 0) {
teardownCurrentMedia();
showStatus('Waiting for content...');
isPlaying = false;
return;
}
document.getElementById('setupScreen').style.display = 'none';
// Continuity: if the playing item survives the update, keep playing it.
// Just retarget the index pointer - no re-render, no interrupt. It will
// advance naturally via onended -> nextItem.
if (oldAnchorId && oldAnchorId !== '|||') {
const stillThereIdx = playlist.findIndex(x => identityOf(x) === oldAnchorId);
if (stillThereIdx !== -1) {
currentIndex = stillThereIdx;
isPlaying = true;
return;
}
}
// Anchor is gone. Walk forward from the OLD position through the old playlist,
// pick the first item that still exists in the new one. Preserves "what was
// scheduled to play next, that still exists". Wraps past the end naturally.
let nextIdx = -1;
if (oldPlaylist.length > 0 && Number.isFinite(oldAnchorIdx)) {
for (let i = 1; i <= oldPlaylist.length; i++) {
const probe = oldPlaylist[(oldAnchorIdx + i) % oldPlaylist.length];
const probeId = identityOf(probe);
if (!probeId || probeId === '|||') continue;
const found = playlist.findIndex(x => identityOf(x) === probeId);
if (found !== -1) { nextIdx = found; break; }
}
}
if (nextIdx === -1) nextIdx = 0;
// #157: if the removed item is still live on screen in SOLO playback, don't interrupt it —
// keep it up and rotate to the successor on the next natural advance (its advanceTimer /
// video onended still fires nextItem). Schedule-driven modes (wall follower / group-sync)
// advance via their own tick, so they reconcile immediately as before.
const scheduleDriven = isWallFollower() || !!groupSync;
if (isPlaying && !scheduleDriven) {
deferredRotation = true;
deferredSuccessorId = itemIdentity(playlist[nextIdx]);
console.log('#157: current item removed but still live — deferring rotation-out');
return;
}
startPlaybackAt(nextIdx);
}
// #74/#75: per-item schedule gate. No blocks = always on. Evaluated in the
// device's effective timezone via the shared evaluator. Never let a scheduling
// hiccup stop playback.
function scheduleAllows(item) {
if (!item || !item.schedules || !item.schedules.length) return true;
try { return window.ScheduleEval ? ScheduleEval.isItemActiveNow(item.schedules, Date.now(), playerTimezone) : true; }
catch (e) { return true; }
}
function nextActiveIndex(from) {
if (!playlist.length) return -1;
for (let i = 1; i <= playlist.length; i++) {
const idx = (from + i) % playlist.length;
if (scheduleAllows(playlist[idx])) return idx;
}
return -1;
}
// Every item filtered out: show the idle screen and re-check shortly (a daypart
// may begin). Re-evaluated at item boundaries otherwise, per the locked design.
function showNothingScheduled() {
teardownCurrentMedia();
showStatus(_t('nothing_scheduled'));
isPlaying = false;
clearTimeout(scheduleRetryTimer);
scheduleRetryTimer = setTimeout(() => {
const idx = nextActiveIndex(currentIndex);
if (idx !== -1) { currentIndex = idx; isPlaying = true; playCurrentItem(); }
else showNothingScheduled();
}, 30000);
}
function startPlaybackAt(idx) {
clearTimeout(scheduleRetryTimer);
if (scheduleAllows(playlist[idx])) { currentIndex = idx; isPlaying = true; playCurrentItem(); return; }
const a = nextActiveIndex(idx);
if (a !== -1) { currentIndex = a; isPlaying = true; playCurrentItem(); }
else { currentIndex = idx; showNothingScheduled(); }
}
function playCurrentItem() {
if (!playlist.length || !Number.isFinite(currentIndex)) {
teardownCurrentMedia();
showStatus('Waiting for content...');
isPlaying = false;
return;
}
if (currentIndex < 0 || currentIndex >= playlist.length) currentIndex = 0;
hideStatus();
const item = playlist[currentIndex];
console.log('Playing:', item.filename, `(${currentIndex + 1}/${playlist.length})`);
currentItemStartedAt = Date.now();
// Only the leader (or single, non-walled players) records a play_start —
// followers would just spam duplicate proof-of-play rows for the same item.
if (socket?.connected && (!wallConfig || wallConfig.is_leader)) {
socket.emit('device:play-event', {
device_id: config.deviceId,
event: 'play_start',
content_id: item.content_id,
content_name: item.filename,
duration_sec: item.duration_sec || null,
});
}
renderContent(item);
// Push an immediate sync so followers don't have to wait up to 1s for
// the next periodic tick before snapping to the new item.
if (wallConfig?.is_leader) emitWallSync();
// (group members need no emit — the schedule tick drives them locally)
}
function nextItem() {
// #157: apply a deferred rotation-out — the removed-but-live item has finished, so rotate
// into the (already-swapped) list at the preserved successor instead of interrupting.
if (deferredRotation) {
deferredRotation = false;
const sid = deferredSuccessorId; deferredSuccessorId = null;
let idx = sid ? playlist.findIndex(x => itemIdentity(x) === sid) : -1;
if (idx < 0) idx = 0;
startPlaybackAt(idx);
return;
}
// Send play_end for current
if (playlist[currentIndex] && socket?.connected) {
socket.emit('device:play-event', {
device_id: config.deviceId,
event: 'play_end',
content_id: playlist[currentIndex].content_id,
content_name: playlist[currentIndex].filename,
completed: true,
});
}
// #74/#75: advance to the next item whose schedule allows it now (skip
// filtered items); idle if none are active.
const idx = nextActiveIndex(currentIndex);
if (idx === -1) { showNothingScheduled(); return; }
currentIndex = idx;
playCurrentItem();
}
// ==================== Content Rendering ====================
// Extract YouTube video ID from embed URL
function extractVideoId(url) {
try {
const m = url.match(/\/embed\/([a-zA-Z0-9_-]{11})/);
if (m) return m[1];
const u = new URL(url);
return u.searchParams.get('v') || url.match(/([a-zA-Z0-9_-]{11})/)?.[1] || null;
} catch { return null; }
}
// Load YouTube IFrame API once. (ytApiReady / ytApiCallbacks are declared at the
// top of the script alongside the other player state.)
function loadYoutubeApi(cb) {
if (ytApiReady) { cb(); return; }
ytApiCallbacks.push(cb);
if (!document.getElementById('yt-api-script')) {
const tag = document.createElement('script');
tag.id = 'yt-api-script';
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
window.onYouTubeIframeAPIReady = () => {
ytApiReady = true;
ytApiCallbacks.forEach(fn => fn());
ytApiCallbacks = [];
};
}
}
function createYoutubeEmbed(src, item, container) {
const videoId = extractVideoId(src);
if (!videoId) {
console.error('Could not extract YouTube video ID from:', src);
if (playlist.length > 1) setTimeout(nextItem, 2000);
return null;
}
// Invalidate any previous player's callbacks
const myGeneration = ++ytGeneration;
// Destroy old player without triggering side effects (callbacks check generation)
if (activeYtPlayer) { try { activeYtPlayer.destroy(); } catch {} activeYtPlayer = null; }
// 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';
container.appendChild(playerDiv);
// 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,
},
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);
}
},
onError: (event) => {
if (myGeneration !== ytGeneration) return;
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) {
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() {
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = 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 */ }
});
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;
}
function renderContent(item) {
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`;
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`;
// Sandbox into a unique origin so widget scripts can't read window.parent
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts');
div.appendChild(iframe);
if (PREVIEW_MODE && a.widget_type === 'webpage') addWebpageNote(div); // #104
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
} else if (isYoutube) {
createYoutubeEmbed(src, a, div);
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
} else if (isVideo) {
const video = document.createElement('video');
video.src = src;
video.autoplay = true;
video.muted = (zone.sort_order > 0); // Only first zone has audio
video.loop = !multi; // single-item zone loops; multi advances on end
video.playsInline = true;
video.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
if (multi) video.onended = advance;
div.appendChild(video);
} else {
const img = document.createElement('img');
img.src = src;
img.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
div.appendChild(img);
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
}
}
// ==================== Screenshots ====================
// Draw a media element into a destination rect honouring its object-fit, so the capture
// matches what's on screen instead of stretching the source to a fixed size (the old
// bug). 'cover' crops the source; 'contain' letterboxes; 'fill' stretches.
function drawMediaFit(ctx, el, ew, eh, dx, dy, dw, dh, fit) {
if (!ew || !eh) { ctx.drawImage(el, dx, dy, dw, dh); return; }
if (fit === 'fill') { ctx.drawImage(el, dx, dy, dw, dh); return; }
const er = ew / eh, dr = dw / dh;
if (fit === 'contain') {
let w = dw, h = dh;
if (er > dr) h = dw / er; else w = dh * er;
ctx.drawImage(el, dx + (dw - w) / 2, dy + (dh - h) / 2, w, h);
} else { // 'cover' (zone default) and anything unexpected -> crop to fill, no distortion
let sw = ew, sh = eh, sx = 0, sy = 0;
if (er > dr) { sw = eh * dr; sx = (ew - sw) / 2; }
else { sh = ew / dr; sy = (eh - sh) / 2; }
ctx.drawImage(el, sx, sy, sw, sh, dx, dy, dw, dh);
}
}
// A cross-origin <img>/<video> drawn onto the canvas without CORS taints the WHOLE
// canvas, making toDataURL() throw and killing the entire capture. Only same-origin
// media (served by us) or media explicitly loaded with crossOrigin is safe to read back.
function isMediaReadable(el) {
const url = el.currentSrc || el.src || '';
if (!url) return false;
if (el.crossOrigin) return true;
try { return new URL(url, location.href).origin === location.origin; }
catch (e) { return false; }
}
function zonePlaceholderLabel(el) {
if (!el) return 'Live';
if (el.tagName === 'IFRAME') {
const s = el.src || '';
if (/youtube|ytimg|youtu\.be/i.test(s)) return 'YouTube';
if (/\/widgets\//i.test(s)) return 'Widget';
return 'Web';
}
if (el.tagName === 'VIDEO') return 'Video';
return 'Live';
}
// Deliberate, labelled placeholder for a zone we can't read back (cross-origin iframe
// like YouTube/widgets, or cross-origin media). The shot still shows the layout
// structure with this zone clearly marked — never a transparent hole.
function drawZonePlaceholder(ctx, dx, dy, dw, dh, label) {
ctx.save();
ctx.fillStyle = '#1f2937';
ctx.fillRect(dx, dy, dw, dh);
ctx.strokeStyle = 'rgba(148,163,184,0.35)';
ctx.lineWidth = 1;
ctx.strokeRect(dx + 0.5, dy + 0.5, Math.max(0, dw - 1), Math.max(0, dh - 1));
if (label) {
ctx.fillStyle = '#cbd5e1';
const fs = Math.max(11, Math.min(22, Math.round(dh * 0.16)));
ctx.font = `600 ${fs}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, dx + dw / 2, dy + dh / 2);
}
ctx.restore();
}
// Composite a multi-zone layout zone-by-zone. Each zone's destination rect is derived
// from its REAL rendered geometry (getBoundingClientRect relative to the container) and
// scaled proportionally onto the canvas — so positions/sizes stay true to the layout
// rather than one element stretched across the frame.
function drawZoneComposite(ctx, container, cr, W, H) {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
const zones = container.querySelectorAll('.zone');
if (!zones.length) return false;
zones.forEach((zd) => {
const r = zd.getBoundingClientRect();
const dx = ((r.left - cr.left) / cr.width) * W;
const dy = ((r.top - cr.top) / cr.height) * H;
const dw = (r.width / cr.width) * W;
const dh = (r.height / cr.height) * H;
const el = zd.querySelector('video, img, iframe');
let drawn = false;
if (el && el.tagName === 'IMG' && el.complete && el.naturalWidth > 0 && isMediaReadable(el)) {
try { drawMediaFit(ctx, el, el.naturalWidth, el.naturalHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {}
} else if (el && el.tagName === 'VIDEO' && el.readyState >= 2 && el.videoWidth > 0 && isMediaReadable(el)) {
try { drawMediaFit(ctx, el, el.videoWidth, el.videoHeight, dx, dy, dw, dh, getComputedStyle(el).objectFit); drawn = true; } catch (e) {}
}
if (!drawn) drawZonePlaceholder(ctx, dx, dy, dw, dh, zonePlaceholderLabel(el));
});
return true;
}
// Build the screenshot/stream canvas and return it (caller encodes + sends). Exposed as
// a plain function so a headless render pass can verify the composite without a socket.
function renderCaptureCanvas() {
const container = document.getElementById('playerContainer');
const cr = container ? container.getBoundingClientRect() : null;
// ~960 on the long edge; height from the REAL container aspect, not a hardcoded 540,
// so non-16:9 layouts compose without distortion.
const aspect = (cr && cr.width > 0 && cr.height > 0) ? (cr.width / cr.height) : (16 / 9);
const canvas = document.createElement('canvas');
canvas.width = 960;
canvas.height = Math.max(1, Math.round(960 / aspect));
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let captured = false;
try {
const multiZone = !!(layout && Array.isArray(layout.zones) && layout.zones.length > 1 && !wallConfig);
if (multiZone && container) {
captured = drawZoneComposite(ctx, container, cr, W, H);
} else if (container) {
// Single-zone / fullscreen fast path: one element, drawn with its own object-fit
// (the old code stretched it to a fixed 960x540).
const video = container.querySelector('video');
const img = container.querySelector('img');
if (video && video.readyState >= 2 && video.videoWidth > 0 && isMediaReadable(video)) {
try { drawMediaFit(ctx, video, video.videoWidth, video.videoHeight, 0, 0, W, H, getComputedStyle(video).objectFit); captured = true; } catch (e) { console.warn('Video capture failed (CORS?):', e.message); }
}
if (!captured && img && img.complete && img.naturalWidth > 0 && isMediaReadable(img)) {
try { drawMediaFit(ctx, img, img.naturalWidth, img.naturalHeight, 0, 0, W, H, getComputedStyle(img).objectFit); captured = true; } catch (e) { console.warn('Image capture failed:', e.message); }
}
}
// Fallback: draw status info
if (!captured) {
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#3b82f6';
ctx.font = 'bold 28px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('ScreenTinker Web Player', W / 2, H / 2 - 40);
ctx.fillStyle = '#94a3b8';
ctx.font = '16px sans-serif';
const item = playlist[currentIndex];
ctx.fillText(item ? `Playing: ${item.filename}` : 'No content', W / 2, H / 2);
ctx.fillText(`${config.deviceName || 'Web Player'} | ${new Date().toLocaleTimeString()}`, W / 2, H / 2 + 40);
}
} catch (e) {
// Even on error, draw something
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#ef4444';
ctx.font = '16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Screenshot error: ' + e.message, W / 2, H / 2);
}
return canvas;
}
function captureAndSend() {
if (!socket?.connected) return;
// Also drives the 1fps remote stream (startStreaming). The composite is just a handful
// of drawImage calls over already-decoded media, so one full-quality path serves both
// the on-demand screenshot and the 1fps stream — no separate low-quality stream path.
let canvas;
try { canvas = renderCaptureCanvas(); }
catch (e) { console.error('Screenshot render failed:', e); return; }
try {
const dataUrl = canvas.toDataURL('image/jpeg', 0.4);
const base64 = dataUrl.split(',')[1];
if (base64 && base64.length > 100) {
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
console.log('Screenshot sent:', base64.length, 'chars');
}
} catch (e) {
console.error('Screenshot encode/send failed:', e);
}
}
function startStreaming() {
stopStreaming();
streamTimer = setInterval(captureAndSend, 1000);
}
function stopStreaming() {
if (streamTimer) { clearInterval(streamTimer); streamTimer = null; }
}
// ==================== UI Helpers ====================
function showStatus(msg) {
document.getElementById('statusOverlay').style.display = 'flex';
document.getElementById('statusText').textContent = msg;
}
function hideStatus() {
document.getElementById('statusOverlay').style.display = 'none';
}
function toggleScreenOff() {
let overlay = document.getElementById('screenOffOverlay');
if (overlay) { overlay.remove(); return; }
overlay = document.createElement('div');
overlay.id = 'screenOffOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999;cursor:pointer';
overlay.onclick = () => overlay.remove();
document.body.appendChild(overlay);
}
// Create info overlay (toggled by Back button)
const infoDiv = document.createElement('div');
infoDiv.id = 'infoOverlay';
infoDiv.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:800;display:none;flex-direction:column;align-items:center;justify-content:center;color:#f1f5f9;font-family:-apple-system,sans-serif';
infoDiv.innerHTML = `
<h2 style="color:#3b82f6;margin-bottom:16px">${_t('info_title')}</h2>
<div style="font-size:14px;line-height:2;text-align:center;color:#94a3b8" id="infoContent"></div>
<p style="margin-top:24px;font-size:12px;color:#64748b">${_t('info_close_hint')}</p>
`;
infoDiv.onclick = () => { infoDiv.style.display = 'none'; };
document.body.appendChild(infoDiv);
// Escape user-controllable values before injecting into innerHTML — filenames,
// device names, and server URLs are stored on the server and could contain HTML.
const escHtml = (s) => s == null ? '' : String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
// Update info overlay content periodically
setInterval(() => {
const el = document.getElementById('infoContent');
if (!el) return;
const item = playlist[currentIndex];
el.innerHTML = `
${_t('info_device_id')}: ${escHtml(config.deviceId?.slice(0, 8) || _t('info_na'))}...<br>
${_t('info_device_name')}: ${escHtml(config.deviceName || _t('info_na'))}<br>
${_t('info_server')}: ${escHtml(config.serverUrl || _t('info_na'))}<br>
${_t('info_status')}: ${socket?.connected ? `<span style="color:#22c55e">${_t('info_connected')}</span>` : `<span style="color:#ef4444">${_t('info_disconnected')}</span>`}<br>
${_t('info_now_playing')}: ${escHtml(item?.filename || _t('info_nothing'))} (${currentIndex + 1}/${playlist.length})<br>
${_t('info_resolution')}: ${screen.width}x${screen.height}<br>
${_t('info_uptime')}: ${Math.floor(performance.now() / 60000)}m<br>
${_t('info_platform')}: ${escHtml(navigator.platform)}<br>
${_t('info_cache')}: ${_t('info_sw')} ${navigator.serviceWorker?.controller ? `<span style="color:#22c55e">${_t('info_active')}</span>` : _t('info_inactive')}
`;
}, 2000);
// ==================== Fullscreen ====================
// Only attempt fullscreen on genuine user clicks. Synthetic clicks dispatched
// by the remote-control feature (touch forwarding from the dashboard) are not
// trusted by the browser and requestFullscreen() rejects with a "Permissions
// check failed" / "API can only be initiated by a user gesture" error every
// time, spamming the console.
document.addEventListener('click', (e) => {
if (!e.isTrusted) return;
if (!document.fullscreenElement && config.paired) {
document.documentElement.requestFullscreen?.() ||
document.documentElement.webkitRequestFullscreen?.();
}
});
// Prevent sleep/screen saver
let wakeLock = null;
async function requestWakeLock() {
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => { setTimeout(requestWakeLock, 1000); });
}
} catch {}
}
requestWakeLock();
// v4 browser half-open triggers: tab foreground / bfcache resume / network change all drive the
// SAME liveness check + #148 teardown-first reconnect (checkLiveness -> connect() only if the
// socket is armed+connected+silent-past-window). checkLiveness no-ops on a healthy socket, so a
// visibility change does NOT spawn a duplicate socket (the classic browser bug).
document.addEventListener('visibilitychange', () => {
// On becoming visible after a (possibly throttled/frozen) background stint, do the fresh
// liveness check — reset the grace and reconnect ONLY if genuinely dead; never spuriously
// tear down a live socket on the hidden->visible gap.
if (document.visibilityState === 'visible') { requestWakeLock(); verifyLivenessSoon(); }
});
window.addEventListener('pageshow', verifyLivenessSoon); // sleep/resume via bfcache restore
window.addEventListener('online', verifyLivenessSoon); // network switch (wifi<->cellular)
// 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>