mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
* PiP overlay MVP: push image/web overlays to a device or group (#109) Implements the #109 MVP from docs proposal: a floating overlay PUSHED to a device or group in real time, rendered above the playlist without disturbing it. Scope is the MVP only — video/RTSP, MQTT, offline-queue, and the priority/stacking system are deferred to follow-up PRs as the proposal specifies. Protocol (/device socket, player-agnostic): - device:pip-show { pip_id, type:image|web, uri, position, width, height, duration, title?, title_color?, background_color?, opacity?, border_radius?, close_button? } - device:pip-clear { pip_id? } The player fetches uri itself (same trust model as remote_url content; server never proxies). type:web is full-trust by design, hence the 'full' token scope. Server (server/routes/pip.js, new; mounted in config/api-surface.js PUBLIC_ROUTERS): - POST /api/pip and POST /api/pip/clear + DELETE /api/pip, all requireScope('full'). - Resolves device_id to a device OR a group, expands a group to members, and emits per-device — reusing the group command route's room-size online check and {device_id, name, status: sent|offline} result shape. Generates pip_id. - Validates type/position allowlists, uri http(s), numeric bounds on width/height/duration/opacity/border_radius, colors via the existing VALID_COLOR (#RRGGBB; transparency is the separate opacity field). - Workspace-isolated: every target query is scoped to req.workspaceId, so a token bound to workspace A can't address workspace B (404). Offline devices are reported, never queued (PiP is ephemeral). Player overlay layer (Tizen; tizen/js/pip-overlay.js, new): - A #pip sibling ABOVE #stage that PlaylistPlayer/ZoneRenderer never touch. - applyOrientation now applies the SAME transform to #pip as #stage, so corner positions track the visible CONTENT in all four orientations. - image -> <img>, web -> <iframe> (muted by default: empty allow= denies autoplay), sized/positioned/styled per payload, optional title bar. - Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear (id-aware) or timer tears down; teardown wrapped so a malformed payload can't wedge the layer. Reports show/clear over device:log (tag 'pip'). Dashboard: a minimal "Send overlay" / "Clear overlay" tester on the device-detail controls (device/group via the open device, type, uri, position, duration), calling POST /api/pip through the api helper. Tests (server suite green, 161/161): - api.test.js: PiP tier — authz (read/write 403, full passes), workspace isolation (wsA token -> wsB device 404), payload validation, device + group targeting, clear; plus the PUBLIC_ROUTERS snapshot-firewall updated for /api/pip. - pip-overlay.test.js: loads the real player.js + pip-overlay.js in a vm with a DOM shim; proves the overlay shows, auto-dismisses on the duration timer, and never changes the playlist signature / touches #stage; web->iframe, last-show-wins, id-aware clear, malformed-payload safety. Not in this PR (intentional): - Android player overlay — fast-follow. Protocol + server are player-agnostic; the Android layer (an overlay View above the player, orientation-matched to MainActivity's rootView rotation) is the same shape and lands next. - OpenAPI docs for POST /api/pip — the contract test's scope heuristic only treats 'command' paths as full-scope, so documenting a full-scope non-command route there needs that heuristic extended first; deferred with the docs item (proposal §8.6). - video/rtsp types, MQTT, offline queue-on-reconnect, priority/stacking, arbitrary (x,y)/selector positioning (proposal §6). Refs #109 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PiP overlay: add Android + web players (#109) Extends the #109 PiP MVP to the other two players so the protocol (device:pip-show / device:pip-clear) is honored fleet-wide, not just on Tizen. No server/protocol changes — the route and socket messages are player-agnostic; these are the two missing surfaces. Web player (server/player/index.html): - New #pipContainer layer above #playerContainer, pointer-transparent, that the playlist render never touches. The same orientation transform is applied to it as to #playerContainer (extended to also reset width/height on landscape so a portrait->landscape switch realigns), so corner positions track the visible content. - Inline PiP logic mirroring tizen/js/pip-overlay.js: image -> <img>, web -> <iframe> (muted by default via empty allow=), position/size/bg/opacity/radius/title, single slot last-show-wins, duration timer (0 = until cleared), id-aware clear, wrapped teardown. - device:pip-show/clear handlers; reports show/clear over device:log (tag "pip"). Android player: - activity_main.xml: a pipLayout FrameLayout as the LAST child of rootLayout — it draws above the content AND inherits rootView's orientation rotation/translation, so corner positioning is orientation-matched for free. - PipOverlay.kt (new): builds the overlay box into pipLayout. image -> ImageView (decoded off-thread via ImageLoader, dropped if torn down mid-decode); web -> WebView with mediaPlaybackRequiresUserGesture=true (mute-by-default). Gravity-based corner/center placement with a 4% inset, GradientDrawable bg + corner radius, alpha=opacity, optional title bar. Single slot last-show-wins; duration timer; id-aware clear; teardown wrapped and also run on activity destroy (WebView cleanup). - WebSocketService: onPipShow/onPipClear callbacks + safeOn handlers posted to the main thread (they build Views) + a sendLog(tag, level, message) emitter for device:log. - MainActivity: instantiate PipOverlay (log -> wsService.sendLog("pip", ...)), wire the callbacks, tear down on destroy. Verified: Android assembleDebug builds clean; web player inline JS parses; server suite still 161/161 (no server changes this commit). Not yet validated on real hardware — four-orientation corner positioning mirrors the player container/rootView transform but should be eyeballed on a panel. Refs #109 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2015 lines
96 KiB
HTML
2015 lines
96 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||
<!--
|
||
Player debug error trap. MUST stay first inside <head> after the two
|
||
mandatory <meta> tags (charset must come first per HTML spec). Runs
|
||
before any other script so we capture errors even from parse-time
|
||
failures in scripts that come after. Vanilla ES5 syntax (var, function,
|
||
no arrow/const/template) so it loads on ancient WebKit forks (Tizen 4,
|
||
older WebOS, Fire TV stick Gen 1, embedded signage browsers). Wrapped
|
||
in defensive try/catch so this script can never be the reason the
|
||
player won't boot.
|
||
-->
|
||
<script>
|
||
(function () {
|
||
try {
|
||
if (!window.__debugLog) window.__debugLog = [];
|
||
var MAX_LOG = 200;
|
||
var INIT_T = (function () { try { return Date.now(); } catch (e) { return new Date().getTime(); } })();
|
||
|
||
function nowMs() {
|
||
try { return Date.now(); } catch (e) { return new Date().getTime(); }
|
||
}
|
||
|
||
function pushLog(entry) {
|
||
try {
|
||
entry.t = nowMs();
|
||
window.__debugLog.push(entry);
|
||
if (window.__debugLog.length > MAX_LOG) {
|
||
window.__debugLog.splice(0, window.__debugLog.length - MAX_LOG);
|
||
}
|
||
} catch (e) { /* we are the safety net; do not crash */ }
|
||
}
|
||
window.__debugLog_push = pushLog; // shared pusher for debug-overlay.js
|
||
|
||
pushLog({
|
||
type: 'init',
|
||
ua: (navigator && navigator.userAgent) || '',
|
||
url: location.href,
|
||
sw: typeof screen !== 'undefined' ? screen.width : null,
|
||
sh: typeof screen !== 'undefined' ? screen.height : null
|
||
});
|
||
|
||
// Uncaught script errors. Capture phase so we also see errors from
|
||
// <img>/<script>/<link> resource failures (ev.target.src on those).
|
||
window.addEventListener('error', function (ev) {
|
||
try {
|
||
var src = ev.filename || (ev.target && ev.target.src) || '';
|
||
pushLog({
|
||
type: 'error',
|
||
message: ev.message || (ev.error && String(ev.error)) || 'error',
|
||
source: src,
|
||
line: ev.lineno || 0,
|
||
col: ev.colno || 0,
|
||
stack: (ev.error && ev.error.stack) || ''
|
||
});
|
||
} catch (e) {}
|
||
}, true);
|
||
|
||
window.addEventListener('unhandledrejection', function (ev) {
|
||
try {
|
||
var reason = ev.reason;
|
||
pushLog({
|
||
type: 'rejection',
|
||
message: (reason && reason.message) || String(reason || 'rejection'),
|
||
stack: (reason && reason.stack) || ''
|
||
});
|
||
} catch (e) {}
|
||
});
|
||
|
||
// Wrap console methods. Original behavior preserved so devtools still
|
||
// sees the real call; we additionally append a sanitized text record.
|
||
var methods = ['log', 'warn', 'error'];
|
||
for (var i = 0; i < methods.length; i++) {
|
||
(function (m) {
|
||
var orig = console[m];
|
||
console[m] = function () {
|
||
try {
|
||
var args = Array.prototype.slice.call(arguments);
|
||
var msg = '';
|
||
for (var j = 0; j < args.length; j++) {
|
||
var part;
|
||
try {
|
||
part = typeof args[j] === 'string' ? args[j] : JSON.stringify(args[j]);
|
||
} catch (e) {
|
||
part = String(args[j]);
|
||
}
|
||
msg += (j > 0 ? ' ' : '') + part;
|
||
}
|
||
if (msg.length > 1000) msg = msg.slice(0, 1000) + '...[trunc]';
|
||
pushLog({ type: 'console.' + m, message: msg });
|
||
} catch (e) {}
|
||
try { return orig.apply(console, arguments); } catch (e) {}
|
||
};
|
||
})(methods[i]);
|
||
}
|
||
|
||
// Page-load timing relative to this script's execution.
|
||
try {
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
pushLog({ type: 'timing', event: 'DOMContentLoaded', sinceInit: nowMs() - INIT_T });
|
||
});
|
||
window.addEventListener('load', function () {
|
||
pushLog({ type: 'timing', event: 'load', sinceInit: nowMs() - INIT_T });
|
||
});
|
||
} catch (e) {}
|
||
} catch (outerErr) {
|
||
// If even init failed, the player must still boot. Do nothing.
|
||
}
|
||
})();
|
||
</script>
|
||
<!-- Debug overlay module (section 2). Defers so HTML parse doesn't block
|
||
on the fetch. Auto-activation check fires after DOMContentLoaded;
|
||
few-ms delay vs the inline trap is fine since errors before this
|
||
loads are already captured in __debugLog by the inline trap above. -->
|
||
<script src="/player/debug-overlay.js" defer></script>
|
||
<title>ScreenTinker Player</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: -apple-system, sans-serif; }
|
||
|
||
/* Setup Screen */
|
||
#setupScreen {
|
||
position: fixed; inset: 0; background: #111827; display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center; z-index: 1000; color: #f1f5f9;
|
||
}
|
||
#setupScreen h1 { font-size: 36px; color: #3b82f6; margin-bottom: 8px; }
|
||
#setupScreen .subtitle { color: #94a3b8; font-size: 16px; margin-bottom: 48px; }
|
||
#setupScreen .form { width: 400px; max-width: 90vw; }
|
||
#setupScreen label { display: block; font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
|
||
#setupScreen input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155;
|
||
border-radius: 8px; color: #f1f5f9; font-size: 16px; margin-bottom: 24px; outline: none; }
|
||
#setupScreen input:focus { border-color: #3b82f6; }
|
||
#setupScreen button { width: 100%; padding: 12px; background: #3b82f6; color: white;
|
||
border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
|
||
#setupScreen button:hover { background: #2563eb; }
|
||
#setupScreen button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.pairing-code { font-size: 72px; font-weight: 700; color: #3b82f6; font-family: monospace;
|
||
letter-spacing: 12px; margin: 24px 0; }
|
||
.pairing-hint { color: #64748b; font-size: 14px; }
|
||
.status-msg { color: #94a3b8; font-size: 14px; margin-top: 16px; }
|
||
.spinner { width: 40px; height: 40px; border: 3px solid #334155; border-top-color: #3b82f6;
|
||
border-radius: 50%; animation: spin 1s linear infinite; margin: 24px auto; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* Player */
|
||
#playerContainer { position: fixed; inset: 0; background: #000; }
|
||
/* Fullscreen single-zone playback: YouTube's IFrame API measures the placeholder
|
||
at construction time. If that happens before layout settles, YT bakes in a
|
||
300x150 fallback as inline pixel dimensions on the iframe, which our %-based
|
||
rules can't override. Force fullscreen via absolute positioning + !important. */
|
||
#playerContainer > iframe,
|
||
#playerContainer > div > iframe {
|
||
position: absolute !important;
|
||
top: 0 !important; left: 0 !important;
|
||
width: 100% !important; height: 100% !important;
|
||
border: none !important; display: block !important;
|
||
}
|
||
.zone { position: absolute; overflow: hidden; }
|
||
.zone video { width: 100%; height: 100%; object-fit: cover; }
|
||
.zone img { width: 100%; height: 100%; object-fit: cover; }
|
||
.zone iframe { width: 100%; height: 100%; border: none; }
|
||
|
||
/* Video wall mode.
|
||
wall-stage maps the wall's player_rect into this device's viewport
|
||
using vw/vh — so the device fills its full viewport edge-to-edge
|
||
(no pillarbox at the seam between adjacent screens).
|
||
object-fit:fill is intentional: it stretches the source to the stage,
|
||
which keeps vertical position identical between devices that share
|
||
a viewport height — without that, cover-cropping on different stage
|
||
aspects (different innerWidths) shifts content vertically. */
|
||
#playerContainer.wall-mode { overflow: hidden; background: #000; }
|
||
.wall-stage { position: absolute; }
|
||
.wall-stage > video,
|
||
.wall-stage > img { width: 100%; height: 100%; object-fit: fill; display: block; }
|
||
.wall-stage > iframe { width: 100%; height: 100%; border: none; display: block; }
|
||
.wall-mode #playerContainer > iframe,
|
||
.wall-mode #playerContainer > div > iframe { position: static !important; width: 100% !important; height: 100% !important; }
|
||
|
||
/* #109: PiP overlay layer — a fixed full-viewport layer ABOVE #playerContainer that
|
||
the playlist never touches. The same orientation transform is applied to it as to
|
||
#playerContainer, so corner positions track the visible content in every orientation.
|
||
Pointer-transparent and empty (invisible) until an overlay is shown. */
|
||
#pipContainer { position: fixed; inset: 0; pointer-events: none; z-index: 9000; }
|
||
#pipContainer > .pip-box { position: absolute; overflow: hidden; box-sizing: border-box; box-shadow: 0 6px 28px rgba(0,0,0,0.55); }
|
||
#pipContainer > .pip-box > .pip-title { font: 600 16px sans-serif; padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
#pipContainer > .pip-box > img,
|
||
#pipContainer > .pip-box > iframe { display: block; width: 100%; border: 0; }
|
||
|
||
/* Status overlay */
|
||
#statusOverlay {
|
||
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center; color: #94a3b8; z-index: 500;
|
||
}
|
||
#statusOverlay h2 { color: #3b82f6; font-size: 28px; margin-bottom: 8px; }
|
||
#statusOverlay p { font-size: 16px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- Setup Screen -->
|
||
<div id="setupScreen">
|
||
<h1>ScreenTinker</h1>
|
||
<div class="subtitle">Web Player</div>
|
||
<div class="form" id="urlForm">
|
||
<label>Server URL</label>
|
||
<input type="url" id="serverUrl" placeholder="https://sign.yourdomain.com" autofocus>
|
||
<button id="connectBtn">Connect</button>
|
||
</div>
|
||
<div id="pairingSection" style="display:none;text-align:center">
|
||
<p>Pairing Code</p>
|
||
<div class="pairing-code" id="pairingCode">------</div>
|
||
<p class="pairing-hint">Enter this code in the dashboard to pair this display</p>
|
||
</div>
|
||
<div class="spinner" id="setupSpinner" style="display:none"></div>
|
||
<div class="status-msg" id="setupStatus"></div>
|
||
</div>
|
||
|
||
<!-- Player Container -->
|
||
<div id="playerContainer" style="display:none"></div>
|
||
|
||
<!-- #109: PiP overlay layer (above the player; never touched by playlist render) -->
|
||
<div id="pipContainer"></div>
|
||
|
||
<!-- Status Overlay -->
|
||
<div id="statusOverlay" style="display:none">
|
||
<div class="spinner"></div>
|
||
<h2>ScreenTinker</h2>
|
||
<p id="statusText" data-i18n="connecting">Connecting...</p>
|
||
</div>
|
||
|
||
<script src="/socket.io/socket.io.js"></script>
|
||
<script src="/player/schedule-eval.js"></script>
|
||
<script>
|
||
// ==================== 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 d’appairage', pairing_hint: 'Saisissez ce code dans le tableau de bord pour apparier cet écran', connecting: 'Connexion...', connecting_muted: 'Connexion (audio coupé)...', info_title: 'Lecteur web ScreenTinker', info_close_hint: 'Appuyez à nouveau sur Retour ou cliquez pour fermer', info_device_id: 'ID de l’appareil', info_device_name: 'Nom de l’appareil', info_server: 'Serveur', info_status: 'État', info_now_playing: 'En lecture', info_resolution: 'Résolution', info_uptime: 'Disponibilité', info_platform: 'Plateforme', info_cache: 'Cache', info_connected: 'Connecté', info_disconnected: 'Déconnecté', info_active: 'Actif', info_inactive: 'Inactif', info_nothing: 'Rien', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Rien de programmé pour le moment', preview_webpage_blocked: 'Si cette zone est vide, le site bloque l’intégration dans un navigateur ; il s’affichera quand même sur l’écran de l’appareil.',
|
||
},
|
||
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;
|
||
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 = {};
|
||
// 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;
|
||
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">🔇</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
|
||
}
|
||
|
||
// 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) {
|
||
const b = document.createElement('div');
|
||
b.textContent = 'Previewing layout: ' + (payload.layout.name || '—');
|
||
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.92);color:#000;font:12px sans-serif;padding:4px 10px;text-align:center';
|
||
document.body.appendChild(b);
|
||
}
|
||
handlePlaylistUpdate(payload);
|
||
} catch (e) {
|
||
console.error('preview fetch failed', e);
|
||
showPreviewError(0);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// ==================== Socket Connection ====================
|
||
function connect(serverUrl) {
|
||
if (socket) { socket.disconnect(); socket = null; }
|
||
|
||
socket = io(serverUrl + '/device', {
|
||
reconnection: true,
|
||
reconnectionAttempts: Infinity,
|
||
reconnectionDelay: 2000,
|
||
reconnectionDelayMax: 10000,
|
||
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'],
|
||
});
|
||
|
||
socket.on('connect', () => {
|
||
console.log('Connected');
|
||
register();
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
console.log('Disconnected');
|
||
stopHeartbeat();
|
||
});
|
||
|
||
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();
|
||
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';
|
||
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();
|
||
});
|
||
|
||
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(); }
|
||
});
|
||
|
||
// #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) {}
|
||
}
|
||
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 = {};
|
||
if (config.deviceId && config.paired) {
|
||
data.device_id = config.deviceId;
|
||
if (config.deviceToken) data.device_token = config.deviceToken;
|
||
} else {
|
||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||
config.pairingCode = code;
|
||
saveConfig(config);
|
||
data.pairing_code = code;
|
||
}
|
||
data.device_info = {
|
||
android_version: 'Web/' + navigator.userAgent.split(' ').pop(),
|
||
app_version: '1.1.0-web',
|
||
screen_width: screen.width,
|
||
screen_height: screen.height,
|
||
};
|
||
// 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,
|
||
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(),
|
||
});
|
||
}
|
||
|
||
// Map the player rect into this device's viewport using vw/vh so the
|
||
// viewport fills edge-to-edge (no pillarbox at the seam between adjacent
|
||
// screens). With object-fit:fill on the video, the source stretches to
|
||
// the stage — which keeps the vertical position of every source pixel
|
||
// identical across devices that share a viewport height (1vh maps to
|
||
// the same physical pixel on each).
|
||
function styleWallStage(stageEl) {
|
||
if (!wallConfig?.screen_rect || !wallConfig?.player_rect) return;
|
||
const s = wallConfig.screen_rect;
|
||
const p = wallConfig.player_rect;
|
||
if (!s.w || !s.h) return;
|
||
const left = ((p.x - s.x) / s.w) * 100;
|
||
const top = ((p.y - s.y) / s.h) * 100;
|
||
const width = (p.w / s.w) * 100;
|
||
const height = (p.h / s.h) * 100;
|
||
const dev = (config.deviceId || '?').slice(0, 8);
|
||
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p));
|
||
console.log('[wall/render ' + dev + '] viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' DPR=' + window.devicePixelRatio);
|
||
console.log('[wall/render ' + dev + '] stage: left=' + left.toFixed(4) + 'vw top=' + top.toFixed(4) + 'vh width=' + width.toFixed(4) + 'vw height=' + height.toFixed(4) + 'vh');
|
||
stageEl.style.left = left + 'vw';
|
||
stageEl.style.top = top + 'vh';
|
||
stageEl.style.width = width + 'vw';
|
||
stageEl.style.height = height + 'vh';
|
||
stageEl.style.transform = '';
|
||
}
|
||
|
||
// No-op kept for callers that bind a resize listener (kept around in case
|
||
// future zoom/orientation tweaks need it). vw/vh stage updates with the
|
||
// viewport automatically, so explicit re-style isn't needed today.
|
||
function bindWallResizeOnce() {}
|
||
|
||
// ==================== Playlist ====================
|
||
function handlePlaylistUpdate(data) {
|
||
// Check if device is suspended (trial expired / over limit)
|
||
if (data.suspended) {
|
||
isPlaying = false;
|
||
playlist = [];
|
||
document.getElementById('playerContainer').style.display = 'none';
|
||
const overlay = document.getElementById('statusOverlay');
|
||
overlay.style.display = 'flex';
|
||
overlay.innerHTML = `
|
||
<div style="text-align:center;max-width:500px">
|
||
<div style="font-size:64px;margin-bottom:16px">⚠</div>
|
||
<h2 style="color:#f59e0b;margin-bottom:8px">${data.message || 'Account Suspended'}</h2>
|
||
<p style="color:#94a3b8;font-size:16px;margin-bottom:24px">${data.detail || 'Please upgrade your plan.'}</p>
|
||
<p style="color:#64748b;font-size:13px">Visit your dashboard to manage your subscription</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const newItems = data.assignments || [];
|
||
// Build fingerprint from id + url + filename to detect any content change.
|
||
// #74/#75: include schedules so a schedule edit (same content) is detected too.
|
||
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 });
|
||
}
|
||
|
||
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');
|
||
return;
|
||
}
|
||
|
||
console.log('Playlist changed, updating');
|
||
// Capture old state BEFORE mutating so continuity logic can find what was playing.
|
||
const identityOf = (x) => x ? `${x.content_id || ''}|${x.widget_id || ''}|${x.remote_url || ''}|${x.filepath || ''}` : '';
|
||
const oldPlaylist = playlist;
|
||
const oldAnchorIdx = currentIndex;
|
||
const oldAnchorId = identityOf(oldPlaylist[oldAnchorIdx]);
|
||
|
||
playlist = newItems;
|
||
savePlaylistCache(playlist);
|
||
|
||
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;
|
||
|
||
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();
|
||
}
|
||
|
||
function nextItem() {
|
||
// 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 = '';
|
||
}
|
||
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;
|
||
}
|
||
|
||
// Followers don't run their own advance timers — the leader's wall:sync
|
||
// dictates index transitions. Single-screen and leader behave normally.
|
||
const isFollower = !!wallConfig && !wallConfig.is_leader;
|
||
|
||
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) {
|
||
const video = document.createElement('video');
|
||
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).
|
||
video.muted = isFollower ? true : !userHasInteracted;
|
||
// Explicit max volume on the leader so audio is at full level when
|
||
// unmute happens (default is 1.0 but make it visible in logs).
|
||
if (!isFollower) 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';
|
||
video.loop = (playlist.length === 1);
|
||
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
|
||
// follower, snap it back. This is the safety net for the audio
|
||
// bug — without it, a single stray unmute call causes echo.
|
||
if (isFollower) {
|
||
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.
|
||
const byZone = {};
|
||
for (const a of playlist) {
|
||
const zid = a.zone_id || '__none__';
|
||
(byZone[zid] = byZone[zid] || []).push(a);
|
||
}
|
||
for (const k in byZone) byZone[k].sort((x, y) => (x.sort_order || 0) - (y.sort_order || 0));
|
||
|
||
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 ====================
|
||
function captureAndSend() {
|
||
if (!socket?.connected) return;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 960;
|
||
canvas.height = 540;
|
||
const ctx = canvas.getContext('2d');
|
||
let captured = false;
|
||
|
||
try {
|
||
const container = document.getElementById('playerContainer');
|
||
const video = container?.querySelector('video');
|
||
const img = container?.querySelector('img');
|
||
|
||
// Try video first
|
||
if (video && video.readyState >= 2 && video.videoWidth > 0) {
|
||
try {
|
||
ctx.drawImage(video, 0, 0, 960, 540);
|
||
captured = true;
|
||
} catch (e) {
|
||
console.warn('Video capture failed (CORS?):', e.message);
|
||
}
|
||
}
|
||
|
||
// Try image
|
||
if (!captured && img && img.complete && img.naturalWidth > 0) {
|
||
try {
|
||
ctx.drawImage(img, 0, 0, 960, 540);
|
||
captured = true;
|
||
} catch (e) {
|
||
console.warn('Image capture failed:', e.message);
|
||
}
|
||
}
|
||
|
||
// Fallback: draw status info
|
||
if (!captured) {
|
||
ctx.fillStyle = '#111827';
|
||
ctx.fillRect(0, 0, 960, 540);
|
||
ctx.fillStyle = '#3b82f6';
|
||
ctx.font = 'bold 28px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('ScreenTinker Web Player', 480, 230);
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.font = '16px sans-serif';
|
||
const item = playlist[currentIndex];
|
||
ctx.fillText(item ? `Playing: ${item.filename}` : 'No content', 480, 270);
|
||
ctx.fillText(`${config.deviceName || 'Web Player'} | ${new Date().toLocaleTimeString()}`, 480, 310);
|
||
}
|
||
} catch (e) {
|
||
// Even on error, draw something
|
||
ctx.fillStyle = '#000';
|
||
ctx.fillRect(0, 0, 960, 540);
|
||
ctx.fillStyle = '#ef4444';
|
||
ctx.font = '16px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Screenshot error: ' + e.message, 480, 270);
|
||
}
|
||
|
||
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', captured ? '(content)' : '(fallback)');
|
||
}
|
||
} 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
|
||
// Update info overlay content periodically
|
||
setInterval(() => {
|
||
const el = document.getElementById('infoContent');
|
||
if (!el) return;
|
||
const item = playlist[currentIndex];
|
||
el.innerHTML = `
|
||
${_t('info_device_id')}: ${escHtml(config.deviceId?.slice(0, 8) || _t('info_na'))}...<br>
|
||
${_t('info_device_name')}: ${escHtml(config.deviceName || _t('info_na'))}<br>
|
||
${_t('info_server')}: ${escHtml(config.serverUrl || _t('info_na'))}<br>
|
||
${_t('info_status')}: ${socket?.connected ? `<span style="color:#22c55e">${_t('info_connected')}</span>` : `<span style="color:#ef4444">${_t('info_disconnected')}</span>`}<br>
|
||
${_t('info_now_playing')}: ${escHtml(item?.filename || _t('info_nothing'))} (${currentIndex + 1}/${playlist.length})<br>
|
||
${_t('info_resolution')}: ${screen.width}x${screen.height}<br>
|
||
${_t('info_uptime')}: ${Math.floor(performance.now() / 60000)}m<br>
|
||
${_t('info_platform')}: ${escHtml(navigator.platform)}<br>
|
||
${_t('info_cache')}: ${_t('info_sw')} ${navigator.serviceWorker?.controller ? `<span style="color:#22c55e">${_t('info_active')}</span>` : _t('info_inactive')}
|
||
`;
|
||
}, 2000);
|
||
|
||
// ==================== Fullscreen ====================
|
||
// Only attempt fullscreen on genuine user clicks. Synthetic clicks dispatched
|
||
// by the remote-control feature (touch forwarding from the dashboard) are not
|
||
// trusted by the browser and requestFullscreen() rejects with a "Permissions
|
||
// check failed" / "API can only be initiated by a user gesture" error every
|
||
// time, spamming the console.
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.isTrusted) return;
|
||
if (!document.fullscreenElement && config.paired) {
|
||
document.documentElement.requestFullscreen?.() ||
|
||
document.documentElement.webkitRequestFullscreen?.();
|
||
}
|
||
});
|
||
|
||
// Prevent sleep/screen saver
|
||
let wakeLock = null;
|
||
async function requestWakeLock() {
|
||
try {
|
||
if ('wakeLock' in navigator) {
|
||
wakeLock = await navigator.wakeLock.request('screen');
|
||
wakeLock.addEventListener('release', () => { setTimeout(requestWakeLock, 1000); });
|
||
}
|
||
} catch {}
|
||
}
|
||
requestWakeLock();
|
||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') requestWakeLock(); });
|
||
|
||
// 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>
|