mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
Client-only, no server change. Implemented in verified clusters: - H1 (config-proof, no heartbeat-ack): derive the liveness window from the server-negotiated pingInterval (version-robust read) + arm the watchdog only after a real inbound signal, so it degrades safe against any server and a raised PING_INTERVAL can't false-fire it into a storm. - A5: monotonic clock (performance.now) for watchdog/resume deltas — NTP/RTC jumps can't false-fire or blind the watchdog. - H4 (leak was verified ABSENT): timer/teardown hygiene — tracked register-retry + teardownSession() on reset/BACK (stop heartbeat/stream/player-loop/pending-register); all start*() are stop-first. - A1: single-item playlist retries a broken item (was a permanent black screen while heartbeat green). - A6: reconnect randomizationFactor 0.5 (no fleet thundering-herd) + timeout 10s->20s (parity). - A2 (minimal): cache last renderable playlist-update to localStorage, replay on cold-start/offline; cleared on unpair/reset/auth-error. - B3: input hardening (non-array assignments/zones guarded, duration_sec numeric-coerced). - A3: log keep-awake API availability so Bold can VERIFY the flap fix on real hardware. #148 double-connect discipline re-proven after socket-touching changes.
674 lines
34 KiB
JavaScript
674 lines
34 KiB
JavaScript
/* ScreenTinker — Tizen TV web player.
|
||
* Speaks the same /device socket.io protocol as the Android player:
|
||
* emit device:register {pairing_code | device_id+device_token, device_info, fingerprint}
|
||
* recv device:registered {device_id, device_token, status}
|
||
* recv device:paired {name} -> go to playback
|
||
* recv device:unpaired {reason} -> clear creds, re-provision
|
||
* recv device:auth-error {error}
|
||
* recv device:playlist-update {assignments, layout, orientation, suspended?, message?, detail?}
|
||
* emit device:heartbeat {device_id, telemetry} every 15s
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// #119: one source of truth for the player version. Resolve at runtime from the
|
||
// packaged config.xml via the Tizen application API; fall back to a constant that
|
||
// build-wgt.sh stamps from config.xml's version="" so the dashboard always shows the
|
||
// version that is actually installed (never the old hardcoded '1.0.0').
|
||
var APP_VERSION_FALLBACK = '1.9.2'; // st:app-version — stamped by build-wgt.sh
|
||
var APP_VERSION = (function () {
|
||
try {
|
||
var v = tizen.application.getCurrentApplication().appInfo.version;
|
||
if (v) return v;
|
||
} catch (e) {}
|
||
return APP_VERSION_FALLBACK;
|
||
})();
|
||
var HEARTBEAT_MS = 15000;
|
||
var DEFAULT_DURATION = 10;
|
||
var MIN_DURATION = 3;
|
||
|
||
var LS = {
|
||
url: 'st_server_url',
|
||
id: 'st_device_id',
|
||
token: 'st_device_token',
|
||
fp: 'st_fingerprint',
|
||
code: 'st_pairing_code',
|
||
payload: 'st_payload_cache' // A2: last renderable playlist-update, replayed on cold-start/offline
|
||
};
|
||
|
||
// ---- persistent state ----
|
||
function get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
||
function set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
||
function del(k) { try { localStorage.removeItem(k); } catch (e) {} }
|
||
|
||
function uuid() {
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||
var r = (Math.random() * 16) | 0;
|
||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||
});
|
||
}
|
||
function fingerprint() {
|
||
var fp = get(LS.fp);
|
||
if (!fp) { fp = uuid().replace(/-/g, ''); set(LS.fp, fp); }
|
||
return fp;
|
||
}
|
||
function pairingCode() {
|
||
var c = get(LS.code);
|
||
if (!c) { c = String(Math.floor(100000 + Math.random() * 900000)); set(LS.code, c); }
|
||
return c;
|
||
}
|
||
|
||
// ---- DOM ----
|
||
var elSetup = document.getElementById('setup');
|
||
var elPairing = document.getElementById('pairing');
|
||
var elStage = document.getElementById('stage');
|
||
var elPip = document.getElementById('pip'); // #109: PiP overlay layer (above #stage)
|
||
var elUrl = document.getElementById('serverUrl');
|
||
var elConnect = document.getElementById('connectBtn');
|
||
var elSetupStatus = document.getElementById('setupStatus');
|
||
var elPairCode = document.getElementById('pairCode');
|
||
var elPairStatus = document.getElementById('pairStatus');
|
||
var elReset = document.getElementById('resetBtn');
|
||
var elToast = document.getElementById('toast');
|
||
|
||
function show(el) { [elSetup, elPairing, elStage].forEach(function (e) { e.classList.add('hidden'); }); el.classList.remove('hidden'); }
|
||
var toastTimer = null;
|
||
function toast(msg, sticky) {
|
||
elToast.textContent = msg; elToast.classList.remove('hidden');
|
||
if (toastTimer) clearTimeout(toastTimer);
|
||
if (!sticky) toastTimer = setTimeout(function () { elToast.classList.add('hidden'); }, 4000);
|
||
}
|
||
function clearToast() { if (toastTimer) clearTimeout(toastTimer); elToast.classList.add('hidden'); }
|
||
|
||
// Keep the screen awake (best effort across Tizen APIs)
|
||
function keepAwake() {
|
||
try { if (window.tizen && tizen.power) tizen.power.request('SCREEN', 'SCREEN_NORMAL'); } catch (e) {}
|
||
try { if (window.webapis && webapis.appcommon) webapis.appcommon.setScreenSaver(webapis.appcommon.AppCommonScreenSaverState.SCREEN_SAVER_OFF); } catch (e) {}
|
||
}
|
||
|
||
// A5 — MONOTONIC clock for lifecycle time deltas (watchdog silence, resume hidden-duration), so an
|
||
// NTP/RTC wall-clock step on a 24/7 TV can't false-fire (forward jump) or blind (backward jump) the
|
||
// watchdog. Date.now() is kept ONLY where a real wall clock is needed (telemetry, cross-device wall sync).
|
||
var mono = (typeof performance !== 'undefined' && performance.now)
|
||
? function () { return performance.now(); }
|
||
: function () { return Date.now(); };
|
||
|
||
// FIX A — RE-ASSERT keep-awake on an interval. tizen.power.request / the screensaver-off
|
||
// setting can be released when the TV backgrounds/suspends the app, and the player had no
|
||
// way to re-suppress it (keepAwake was only called at boot/connect/command). ~30s is well
|
||
// under any TV screensaver timeout and the calls are cheap best-effort no-ops. Cleared by
|
||
// stopKeepAwake() on app teardown.
|
||
var keepAwakeTimer = null;
|
||
function startKeepAwake() {
|
||
stopKeepAwake();
|
||
keepAwake();
|
||
keepAwakeTimer = setInterval(keepAwake, 30000);
|
||
}
|
||
function stopKeepAwake() { if (keepAwakeTimer) { clearInterval(keepAwakeTimer); keepAwakeTimer = null; } }
|
||
|
||
// FIX B — VISIBILITY / RESUME handling. On a TV, a background/suspend can (a) release
|
||
// keep-awake and (b) silently drop the socket, leaving it HALF-OPEN — socket.connected stays
|
||
// true while the transport is dead, which socket.io CANNOT detect, so it won't auto-reconnect.
|
||
//
|
||
// Double-connect discipline (the one way this could reintroduce #148's duplicate socket):
|
||
// - DEFER to socket.io when the socket is already disconnected (socket.io owns that
|
||
// reconnect, and #118 re-registers on 'connect').
|
||
// - OWN a clean teardown-before-reopen (via connect(), which disconnects the old socket
|
||
// FIRST — cancelling any socket.io reconnect — then opens exactly ONE new socket) ONLY
|
||
// for the half-open case socket.io can't see.
|
||
// These are mutually-exclusive socket states (connected vs not), so a manual reconnect
|
||
// never races socket.io's auto-reconnect. We do NOT manually re-register (connect's 'connect'
|
||
// handler does, once). Half-open is inferred from how long the app was hidden — socket.connected
|
||
// alone is unreliable post-suspend and there is no server ack channel to actively probe
|
||
// without a server change (out of scope for this client-only build).
|
||
var hiddenAtMs = 0;
|
||
var SUSPEND_HIDE_MS = 3000; // hidden >= this ≈ an OS suspend that can half-open the socket
|
||
|
||
// Pure decision, factored out so the double-connect logic is unit-testable:
|
||
// 'reconnect' = half-open -> own teardown+reopen ; 'defer' = already down -> socket.io owns it ; 'noop'
|
||
function resumeDecision(hasSocket, socketConnected, hiddenMs) {
|
||
if (!hasSocket) return 'noop';
|
||
if (!socketConnected) return 'defer';
|
||
return (hiddenMs >= SUSPEND_HIDE_MS) ? 'reconnect' : 'noop';
|
||
}
|
||
function onVisibility() {
|
||
if (document.visibilityState === 'hidden' || document.hidden) { hiddenAtMs = mono(); return; } // A5: monotonic
|
||
keepAwake(); // re-assert immediately on resume
|
||
var hiddenMs = hiddenAtMs ? (mono() - hiddenAtMs) : 0; // A5: monotonic hidden-duration
|
||
hiddenAtMs = 0;
|
||
var action = resumeDecision(!!socket, !!(socket && socket.connected), hiddenMs);
|
||
if (action === 'reconnect') connect(); // teardown-before-reopen -> exactly one socket; #118 registers once
|
||
// 'defer' -> socket.io auto-reconnects (re-registers on 'connect'); 'noop' -> healthy, do nothing
|
||
}
|
||
if (typeof window !== 'undefined') window.__stResumeDecision = resumeDecision; // test hook (inert in prod)
|
||
|
||
// FIX B (hardened) — application-level LIVENESS WATCHDOG. The resume path above only fires on
|
||
// visibilitychange, so a socket that goes half-open with NO visibility event (network drop, NAT
|
||
// idle timeout, transport death while foregrounded) would never be caught: socket.connected stays
|
||
// true on a dead socket and socket.io won't reconnect. The watchdog watches for server SILENCE.
|
||
// The server sends an engine ping every ~15s (config.pingInterval) AND app events, so a healthy
|
||
// socket refreshes lastServerMsgAt at least every ~15s (markAlive is wired into a central receive
|
||
// path in connect(): socket.onAny + socket.io 'ping'). If the socket goes quiet past the liveness
|
||
// window while we still believe we're connected + authenticated, it is half-open -> clean
|
||
// teardown-before-reopen via connect() (exactly one socket; #118 re-registers once).
|
||
//
|
||
// Double-connect discipline: the watchdog fires ONLY while socket.connected===true (the half-open
|
||
// state socket.io cannot see) — socket.io's own auto-reconnect only runs when socket.connected is
|
||
// false, so the two never overlap. connect() is teardown-first, and it resets lastServerMsgAt, so
|
||
// the watchdog and the resume fast-path can't double-fire a second reconnect. Client-only: uses
|
||
// signals the server already sends; no server change.
|
||
var lastServerMsgAt = 0;
|
||
var livenessConfirmed = false; // H1: DON'T arm until the server has actually talked to us
|
||
var DEFAULT_LIVENESS_MS = 35000;
|
||
var livenessWindowMs = DEFAULT_LIVENESS_MS; // H1: derived from the negotiated engine pingInterval per connect
|
||
var watchdogTimer = null;
|
||
function markAlive() { lastServerMsgAt = mono(); livenessConfirmed = true; } // central receive-path hook; A5 monotonic
|
||
// H1 (config-proof): adapt the silence window to whatever pingInterval the SERVER negotiated, so a larger
|
||
// server pingInterval can't make the client false-fire into a reconnect storm. 2 intervals + 5s margin,
|
||
// floored at the 35s default (which is 2×15s+5s). Called from the 'connect' handler once the handshake is known.
|
||
function setLivenessWindowFromPing(pingIntervalMs) {
|
||
if (pingIntervalMs && pingIntervalMs > 0) livenessWindowMs = Math.max(DEFAULT_LIVENESS_MS, 2 * pingIntervalMs + 5000);
|
||
}
|
||
// Pure, unit-testable. Reconnect ONLY when a connected+authenticated socket whose liveness we have CONFIRMED
|
||
// (seen >=1 real inbound signal — H1 degrade-safe: a server that never talks never arms the watchdog, so no
|
||
// storm) has gone silent past the server-derived window.
|
||
function watchdogShouldReconnect(hasSocket, connected, authed, confirmed, silentMs, windowMs) {
|
||
return !!(hasSocket && connected && authed && confirmed && silentMs > windowMs);
|
||
}
|
||
function startWatchdog() {
|
||
stopWatchdog();
|
||
watchdogTimer = setInterval(function () {
|
||
var silentMs = lastServerMsgAt ? (mono() - lastServerMsgAt) : 0; // A5 monotonic
|
||
if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), authenticated, livenessConfirmed, silentMs, livenessWindowMs)) {
|
||
connect(); // half-open backstop: teardown-first -> one socket, #118 re-registers once
|
||
}
|
||
}, 10000);
|
||
}
|
||
function stopWatchdog() { if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; } }
|
||
if (typeof window !== 'undefined') { window.__stWatchdogShouldReconnect = watchdogShouldReconnect; window.__stSetLivenessWindowFromPing = setLivenessWindowFromPing; }
|
||
|
||
// ---- networking ----
|
||
var socket = null;
|
||
var deviceId = get(LS.id);
|
||
var deviceToken = get(LS.token);
|
||
var serverUrl = get(LS.url);
|
||
var heartbeatTimer = null;
|
||
var beatCount = 0;
|
||
var authenticated = false; // #118: true only between device:registered and disconnect/auth-error
|
||
var streamTimer = null; // #120: dashboard preview streaming interval
|
||
|
||
function deviceInfo() {
|
||
return {
|
||
android_version: 'Tizen ' + (tizenVersion() || ''),
|
||
app_version: APP_VERSION,
|
||
screen_width: window.screen ? screen.width : window.innerWidth,
|
||
screen_height: window.screen ? screen.height : window.innerHeight
|
||
};
|
||
}
|
||
function tizenVersion() {
|
||
try { return tizen.systeminfo.getCapability('http://tizen.org/feature/platform.version'); } catch (e) { return ''; }
|
||
}
|
||
|
||
function telemetry() {
|
||
var t = { uptime_seconds: Math.floor(performance.now() / 1000) };
|
||
// #74/#75: OS timezone + UTC clock (effective-tz resolution + skew indicator)
|
||
try { t.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { t.timezone = null; }
|
||
t.device_utc = Date.now();
|
||
try {
|
||
tizen.systeminfo.getPropertyValue('BATTERY', function (b) {
|
||
t.battery_level = Math.round((b.level || 0) * 100);
|
||
t.battery_charging = !!b.isCharging;
|
||
});
|
||
} catch (e) {}
|
||
return t;
|
||
}
|
||
|
||
function connect() {
|
||
if (!serverUrl) { show(elSetup); return; }
|
||
keepAwake();
|
||
if (socket) { try { socket.disconnect(); } catch (e) {} socket = null; }
|
||
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; } // H4: a fresh connect supersedes any pending re-register
|
||
|
||
var base = serverUrl.replace(/\/+$/, '');
|
||
socket = io(base + '/device', {
|
||
transports: ['websocket', 'polling'],
|
||
reconnection: true,
|
||
reconnectionDelay: 2000,
|
||
reconnectionDelayMax: 10000,
|
||
randomizationFactor: 0.5, // A6: ±50% jitter so a fleet of TVs doesn't reconnect in lockstep (thundering herd) after a server restart — matches the APK
|
||
timeout: 20000 // cheap parity (GAP4c): match /player + APK; 10s prematurely errored slow TV WebKit / WS-blocked networks
|
||
});
|
||
|
||
// FIX B (hardened): central receive-path liveness. A fresh socket is assumed alive; then EVERY
|
||
// inbound server message refreshes lastServerMsgAt — app events via onAny, and the engine ping
|
||
// (~15s) via the manager 'ping'. This resets liveness so the watchdog / resume fast-path can't
|
||
// double-fire, and feeds the watchdog's server-silence detection. (io() returns a fresh socket
|
||
// per connect — verified — so these listeners don't accumulate.)
|
||
lastServerMsgAt = mono(); // A5 monotonic
|
||
livenessConfirmed = false; // H1: arm the watchdog only after a real inbound signal
|
||
livenessWindowMs = DEFAULT_LIVENESS_MS; // reset; refined from the handshake on 'connect'
|
||
socket.onAny(markAlive);
|
||
socket.io.on('ping', markAlive);
|
||
|
||
socket.on('connect', function () {
|
||
// H1: derive the liveness window from the pingInterval the SERVER negotiated at the handshake,
|
||
// so raising server PING_INTERVAL can't make the watchdog false-fire (config-proof). The engine
|
||
// exposes it as `pingInterval` (socket.io-client 4.7.x, the bundled .wgt client) or `_pingInterval`
|
||
// (4.8.x); read both, and fall back to the 35s default if neither is present.
|
||
try {
|
||
var eng = socket.io && socket.io.engine;
|
||
setLivenessWindowFromPing(eng && (eng.pingInterval || eng._pingInterval));
|
||
} catch (e) {}
|
||
// #118: a brand-new socket is not authenticated until device:registered. Reset the
|
||
// flag and kill any heartbeat carried over from the previous socket, so a beat can't
|
||
// fire on this fresh, unregistered connection (TV sleep/wake reconnects often).
|
||
authenticated = false;
|
||
stopHeartbeat();
|
||
clearToast();
|
||
register();
|
||
});
|
||
socket.on('connect_error', function (err) {
|
||
if (!deviceId) {
|
||
// Not provisioned yet — fall back to the server prompt so a bad/unreachable
|
||
// URL can be corrected instead of leaving a blank screen.
|
||
elUrl.value = serverUrl || '';
|
||
elSetupStatus.textContent = 'Could not reach server: ' + (err && err.message ? err.message : 'error');
|
||
elSetupStatus.className = 'status error';
|
||
show(elSetup); elUrl.focus();
|
||
} else {
|
||
toast('Reconnecting…', true);
|
||
}
|
||
});
|
||
socket.on('disconnect', function () {
|
||
authenticated = false; // #118
|
||
stopHeartbeat(); // #118: no beats on a dead socket
|
||
toast('Reconnecting…', true);
|
||
});
|
||
|
||
socket.on('device:registered', function (data) {
|
||
deviceId = data.device_id; deviceToken = data.device_token;
|
||
set(LS.id, deviceId); set(LS.token, deviceToken);
|
||
authenticated = true; // #118: this socket may now send post-register events
|
||
clearToast(); // #118: drop any stale "Not authenticated…" banner
|
||
startHeartbeat();
|
||
reportCapabilities(); // #125: surface the fleet-control backend to the dashboard
|
||
if (data.status === 'provisioning') showPairing();
|
||
});
|
||
|
||
socket.on('device:paired', function () {
|
||
del(LS.code); clearToast(); show(elStage);
|
||
});
|
||
|
||
socket.on('device:unpaired', function () {
|
||
del(LS.id); del(LS.token); del(LS.code); del(LS.payload);
|
||
deviceId = null; deviceToken = null;
|
||
// FIX F — back off 3s before re-registering, symmetric with the auth-error path below,
|
||
// so a repeatedly-unpaired device (e.g. MDM re-pair churn) can't tight-loop
|
||
// register -> unpaired -> register.
|
||
scheduleRegister(3000);
|
||
});
|
||
|
||
socket.on('device:auth-error', function (data) {
|
||
// #118: NEVER sticky. A transient pre-register rejection must self-clear, not paint
|
||
// a permanent strip over still-playing content. Stop the heartbeat so a rejected beat
|
||
// can't sustain a reject -> auth-error loop.
|
||
authenticated = false;
|
||
stopHeartbeat();
|
||
toast((data && data.error) ? data.error : 'Auth error', false);
|
||
// Bad/stale token or fingerprint-reclaim block: drop creds and re-pair.
|
||
del(LS.id); del(LS.token); del(LS.payload); // A2: clear cached content when identity is lost
|
||
deviceId = null; deviceToken = null;
|
||
scheduleRegister(3000);
|
||
});
|
||
|
||
socket.on('device:playlist-update', onPlaylist);
|
||
|
||
// ---- remote control from the dashboard (#120 / #121 / #125) ----
|
||
// Mirror the web/Android player. The server emits device:command with the set in
|
||
// server/routes/device-groups.js (ALLOWED_COMMANDS) plus 'refresh', and the
|
||
// screenshot/remote events below. (The old device:reload listener was dead — the
|
||
// server never emits it — so 'refresh' replaces it.)
|
||
//
|
||
// #125: reboot / screen power / shutdown now go through STDeviceControl, which
|
||
// drives the real Samsung b2bcontrol/systemcontrol surface on a partner-signed
|
||
// panel. Where that surface is absent (web / URL-Launcher / consumer TV), it
|
||
// resolves { supported:false } and we fall back to the local black overlay for
|
||
// screen_off so the command still does something visible.
|
||
socket.on('device:command', function (data) {
|
||
var type = (data && data.type) ? String(data.type).toLowerCase() : '';
|
||
var payload = (data && data.payload) ? data.payload : null;
|
||
if (!type) return;
|
||
|
||
// "Wake" intents always clear any black overlay and re-assert screen-awake,
|
||
// independent of (and in addition to) the panel API.
|
||
if (type === 'screen_on' || type === 'launch') { clearScreenOff(); keepAwake(); }
|
||
|
||
if (!window.STDeviceControl) { reportCmd('error', type, 'device-control unavailable'); return; }
|
||
STDeviceControl.run(type, payload).then(function (res) {
|
||
var note = res.note;
|
||
// No real panel-power surface: keep the pre-#125 behaviour — a black overlay
|
||
// (content keeps running behind it) — so screen_off isn't a silent no-op.
|
||
if (type === 'screen_off' && res.supported === false) {
|
||
showScreenOff();
|
||
res = { ok: true, supported: true, reload: false };
|
||
note = 'no panel API — black overlay fallback';
|
||
}
|
||
var level = res.ok ? 'info' : (res.supported === false ? 'warn' : 'error');
|
||
reportCmd(level, type, note || (res.ok ? 'ok' : 'failed'));
|
||
// Delay the reload so the log/result emit reaches the server first.
|
||
if (res.reload) setTimeout(function () { location.reload(); }, 1200);
|
||
});
|
||
});
|
||
|
||
// #120: dashboard preview — single shot and start/stop streaming.
|
||
socket.on('device:screenshot-request', function () { captureAndSend(); });
|
||
socket.on('device:remote-start', function () { startStreaming(); });
|
||
socket.on('device:remote-stop', function () { stopStreaming(); });
|
||
|
||
// ---- video wall sync (mirrors the web player) ----
|
||
// Leader broadcasts position; followers align index + drift-correct their video.
|
||
socket.on('wall:sync', function (d) { wallController.onSync(d); });
|
||
socket.on('wall:sync-request', function (d) { wallController.onSyncRequest(d); });
|
||
|
||
// #109: PiP overlay — a pushed floating layer above the playlist. The player
|
||
// fetches the uri itself (same trust model as remote_url content).
|
||
socket.on('device:pip-show', function (d) { pipOverlay.show(d); });
|
||
socket.on('device:pip-clear', function (d) { pipOverlay.clear(d && d.pip_id); });
|
||
}
|
||
|
||
function register() {
|
||
var msg = { device_info: deviceInfo(), fingerprint: fingerprint() };
|
||
if (deviceId && deviceToken) { msg.device_id = deviceId; msg.device_token = deviceToken; }
|
||
else { msg.pairing_code = pairingCode(); }
|
||
socket.emit('device:register', msg);
|
||
}
|
||
|
||
function showPairing() {
|
||
elPairCode.textContent = pairingCode();
|
||
show(elPairing);
|
||
}
|
||
|
||
function startHeartbeat() {
|
||
stopHeartbeat();
|
||
heartbeatTimer = setInterval(function () {
|
||
// #118: only beat on a socket that finished device:register, or the server's
|
||
// requireDeviceAuth() rejects the beat with device:auth-error.
|
||
if (!socket || !socket.connected || !deviceId || !authenticated) return;
|
||
socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() });
|
||
// FIX C — every 4th beat (~60s) ask for a fresh playlist by re-emitting device:register;
|
||
// the server responds with a fresh device:playlist-update (deviceSocket.js). This was
|
||
// previously a duplicate device:heartbeat (comment != code), so the .wgt had NO working
|
||
// fallback refresh and relied entirely on server push. Matches the Android player.
|
||
if ((++beatCount % 4) === 0) register();
|
||
}, HEARTBEAT_MS);
|
||
}
|
||
function stopHeartbeat() {
|
||
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
||
}
|
||
|
||
// ---- remote control + dashboard preview (#120 / #121) ----
|
||
// Screen on/off uses a black overlay (a sideloaded web app can't power the panel
|
||
// off cleanly), mirroring the web player.
|
||
function showScreenOff() {
|
||
if (document.getElementById('screenOffOverlay')) return;
|
||
var o = document.createElement('div');
|
||
o.id = 'screenOffOverlay';
|
||
o.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999';
|
||
document.body.appendChild(o);
|
||
}
|
||
function clearScreenOff() {
|
||
var o = document.getElementById('screenOffOverlay');
|
||
if (o && o.parentNode) o.parentNode.removeChild(o);
|
||
}
|
||
// #109: report PiP show/clear over the existing device:log channel (tag 'pip') so it
|
||
// surfaces in the dashboard device log. Used as the PipOverlay log callback.
|
||
function reportPip(level, msg) {
|
||
try {
|
||
if (socket && deviceId) socket.emit('device:log', { device_id: deviceId, tag: 'pip', level: level, message: msg });
|
||
} catch (e) {}
|
||
}
|
||
|
||
// #125: report a command outcome to the dashboard. device:log surfaces live as
|
||
// dashboard:device-log on the open device-detail screen; device:command-result is
|
||
// a structured echo (harmless if the server doesn't handle it).
|
||
function reportCmd(level, type, msg) {
|
||
var message = '[' + type + '] ' + msg;
|
||
try {
|
||
if (socket && deviceId) {
|
||
socket.emit('device:log', { device_id: deviceId, tag: 'command', level: level, message: message });
|
||
socket.emit('device:command-result', { device_id: deviceId, type: type, level: level, message: msg });
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// #125: log the panel's control surface at startup so the dashboard shows whether
|
||
// fleet control is actually wired (backend "none" on web / consumer TV / unsigned).
|
||
function reportCapabilities() {
|
||
try {
|
||
var caps = (window.STDeviceControl && STDeviceControl.capabilities)
|
||
? STDeviceControl.capabilities() : { backend: 'none', reboot: false, panel: false };
|
||
reportCmd('info', 'capabilities',
|
||
'fleet control backend=' + caps.backend + ' reboot=' + caps.reboot + ' panel=' + caps.panel);
|
||
// A3 observability: the keep-awake fix only actually holds the screen if these APIs resolve on the
|
||
// TV's firmware/signing path. Surface their presence to the dashboard log so Bold can VERIFY on real
|
||
// hardware whether keep-awake is real (vs a silent no-op) — the load-bearing check for the flap fix.
|
||
var ka = 'keep-awake: setScreenSaver=' + !!(window.webapis && webapis.appcommon)
|
||
+ ' tizen.power=' + !!(window.tizen && tizen.power);
|
||
reportCmd('info', 'keepawake', ka);
|
||
} catch (e) {}
|
||
}
|
||
|
||
// #120: best-effort dashboard preview. The Tizen TV runtime decodes <video> onto a
|
||
// hardware overlay plane and plays YouTube in a cross-origin <iframe>; neither can be
|
||
// read back into a <canvas> (drawImage yields black / throws). So video/YouTube fall
|
||
// back to a status card — the same shape as the web player's fallback — while images
|
||
// (same-origin / CORS-ok) capture for real. This gives the dashboard a truthful frame
|
||
// instead of a dead button.
|
||
function captureAndSend() {
|
||
if (!socket || !socket.connected || !deviceId || !authenticated) return;
|
||
var canvas = document.createElement('canvas');
|
||
canvas.width = 960; canvas.height = 540;
|
||
var ctx = canvas.getContext('2d');
|
||
var captured = false;
|
||
try {
|
||
var img = elStage.querySelector('img');
|
||
if (img && img.complete && img.naturalWidth > 0) {
|
||
try { ctx.drawImage(img, 0, 0, 960, 540); captured = true; } catch (e) {}
|
||
}
|
||
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 (Tizen)', 480, 235);
|
||
ctx.fillStyle = '#94a3b8'; ctx.font = '16px sans-serif';
|
||
ctx.fillText('Live preview unavailable for video / YouTube on Tizen', 480, 280);
|
||
ctx.fillText(new Date().toLocaleTimeString(), 480, 312);
|
||
}
|
||
} catch (e) {
|
||
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, 960, 540);
|
||
}
|
||
try {
|
||
var base64 = canvas.toDataURL('image/jpeg', 0.4).split(',')[1];
|
||
if (base64 && base64.length > 100) {
|
||
socket.emit('device:screenshot', { device_id: deviceId, image_b64: base64 });
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
function startStreaming() { stopStreaming(); streamTimer = setInterval(captureAndSend, 1000); }
|
||
function stopStreaming() { if (streamTimer) { clearInterval(streamTimer); streamTimer = null; } }
|
||
|
||
// H4 (teardown hygiene): TRACK the register re-try so a reset/reconnect can cancel a pending late
|
||
// register (Lens 2 found it untracked -> a stray register could fire on a fresh socket).
|
||
var registerTimer = null;
|
||
function scheduleRegister(delay) {
|
||
if (registerTimer) clearTimeout(registerTimer);
|
||
registerTimer = setTimeout(function () { registerTimer = null; register(); }, delay);
|
||
}
|
||
// H4: stop the per-SESSION timers/loops when leaving playback (reset / BACK-to-setup). Otherwise the
|
||
// player loop keeps firing on the hidden stage and throws (serverUrl=null), heartbeat/stream keep
|
||
// running, and a pending register can fire late. Keep-awake + the watchdog are LIFETIME timers
|
||
// (guarded no-ops while off-session) and are intentionally left running. Idempotent.
|
||
function teardownSession() {
|
||
stopHeartbeat();
|
||
stopStreaming();
|
||
try { player.stop(); } catch (e) {}
|
||
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; }
|
||
authenticated = false;
|
||
}
|
||
|
||
// ---- playback ----
|
||
var player = new PlaylistPlayer(elStage, function () { return serverUrl.replace(/\/+$/, ''); });
|
||
// Multi-zone layout renderer (matches the Android player). app.js picks the renderer
|
||
// per playlist-update from payload.layout; the two never run at once.
|
||
var zoneRenderer = new ZoneRenderer(elStage, function () { return serverUrl.replace(/\/+$/, ''); });
|
||
// Video-wall sync (mirrors the web player). Drives the single-zone player as leader or
|
||
// follower. canEmit gates wall emits on auth+connection so a pre-register tick can't
|
||
// trip device:auth-error (same guard rationale as the heartbeat).
|
||
var wallController = new WallController(
|
||
elStage, player,
|
||
function () { return socket; },
|
||
function () { return deviceId; },
|
||
function () { return authenticated && !!socket && socket.connected; }
|
||
);
|
||
// #109: PiP overlay layer. Renders into #pip (above #stage); never touches the
|
||
// playlist. Reports show/clear over device:log (tag 'pip').
|
||
var pipOverlay = new PipOverlay(elPip, { log: reportPip });
|
||
|
||
// Rotate the playback stage in software for portrait / flipped signage. Tizen TVs
|
||
// are fixed-landscape, so we rotate the CONTENT (not the panel). Values mirror the
|
||
// dashboard: landscape / portrait / landscape-flipped / portrait-flipped.
|
||
function applyOrientation(o) {
|
||
// #109: apply the SAME transform to #stage AND #pip so the overlay's corner
|
||
// positions track the visible CONTENT, not the physical panel, in every orientation.
|
||
orientEl(elStage.style, o);
|
||
if (elPip) orientEl(elPip.style, o);
|
||
}
|
||
function orientEl(s, o) {
|
||
if (!o || o === 'landscape') {
|
||
s.position = ''; s.top = ''; s.left = '';
|
||
s.width = ''; s.height = ''; s.transform = ''; s.transformOrigin = '';
|
||
return;
|
||
}
|
||
var deg = o === 'portrait' ? 90 : o === 'portrait-flipped' ? 270 : o === 'landscape-flipped' ? 180 : 0;
|
||
var swap = (deg === 90 || deg === 270);
|
||
s.position = 'absolute';
|
||
s.top = '50%';
|
||
s.left = '50%';
|
||
s.width = swap ? '100vh' : '100vw';
|
||
s.height = swap ? '100vw' : '100vh';
|
||
s.transformOrigin = 'center center';
|
||
s.transform = 'translate(-50%, -50%) rotate(' + deg + 'deg)';
|
||
}
|
||
|
||
function onPlaylist(payload) {
|
||
if (!payload) return;
|
||
if (payload.suspended) {
|
||
player.stop();
|
||
zoneRenderer.clear();
|
||
wallController.exit();
|
||
applyOrientation(payload.orientation || 'landscape');
|
||
elStage.innerHTML = '<div class="card" style="position:relative"><h1>' +
|
||
esc(payload.message || 'Display suspended') + '</h1><p class="sub">' +
|
||
esc(payload.detail || '') + '</p></div>';
|
||
show(elStage);
|
||
return;
|
||
}
|
||
// A2: cache the last RENDERABLE payload so a reboot / WS-outage with no connectivity replays it
|
||
// instead of showing the idle card. Only non-suspended payloads are cached.
|
||
try { set(LS.payload, JSON.stringify(payload)); } catch (e) {}
|
||
// If we have content + we're paired, make sure we're on the stage.
|
||
if (elPairing.classList.contains('hidden') === false) show(elStage);
|
||
else if (elStage.classList.contains('hidden')) show(elStage);
|
||
|
||
if (payload.wall_config) {
|
||
// Video wall: fullscreen content mapped into this screen's slice. No multi-zone,
|
||
// and no orientation transform — the wall geometry owns the stage.
|
||
zoneRenderer.clear();
|
||
wallController.apply(payload.wall_config);
|
||
player.setTimezone(payload.timezone || null);
|
||
player.load(payload.assignments || []);
|
||
return;
|
||
}
|
||
|
||
wallController.exit(); // leave wall mode if we were in it
|
||
applyOrientation(payload.orientation || 'landscape');
|
||
var layout = payload.layout;
|
||
if (layout && Array.isArray(layout.zones) && layout.zones.length) { // B3: non-array zones would throw in zoneRenderer
|
||
// Multi-zone layout (matches the Android player). Leave single-zone mode first.
|
||
player.stop();
|
||
zoneRenderer.setTimezone(payload.timezone || null); // #74/#75: effective tz
|
||
zoneRenderer.render(layout, payload.assignments || []);
|
||
} else {
|
||
// Fullscreen single zone. Leave any previous zone layout first.
|
||
zoneRenderer.clear();
|
||
player.setTimezone(payload.timezone || null); // #74/#75: effective tz for schedule eval
|
||
player.load(payload.assignments || []);
|
||
}
|
||
}
|
||
|
||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); }
|
||
|
||
// ---- setup screen wiring ----
|
||
if (serverUrl) elUrl.value = serverUrl;
|
||
elConnect.addEventListener('click', doConnect);
|
||
elUrl.addEventListener('keydown', function (e) { if (e.keyCode === 13) doConnect(); });
|
||
function doConnect() {
|
||
var v = (elUrl.value || '').trim();
|
||
if (!v) { elSetupStatus.textContent = 'Enter a server URL'; return; }
|
||
if (!/^https?:\/\//i.test(v)) v = 'https://' + v;
|
||
serverUrl = v; set(LS.url, serverUrl);
|
||
elSetupStatus.className = 'status';
|
||
elSetupStatus.textContent = 'Connecting…';
|
||
connect();
|
||
}
|
||
elReset.addEventListener('click', function () {
|
||
del(LS.url); del(LS.id); del(LS.token); del(LS.code); del(LS.payload);
|
||
deviceId = null; deviceToken = null; serverUrl = null;
|
||
if (socket) { try { socket.disconnect(); } catch (e) {} }
|
||
teardownSession(); // H4: stop heartbeat/stream/player-loop + pending register (no dangling timers on setup)
|
||
show(elSetup);
|
||
});
|
||
|
||
// TV remote BACK key (10009): from the stage/pairing screen, return to the
|
||
// server prompt so the operator can always change the server; from setup, exit.
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.keyCode === 10009) { // Samsung RETURN / BACK
|
||
if (!elSetup.classList.contains('hidden')) {
|
||
stopKeepAwake(); stopWatchdog(); // FIX A/B: clear timers cleanly before the app exits
|
||
try { tizen.application.getCurrentApplication().exit(); } catch (x) {}
|
||
} else {
|
||
if (socket) { try { socket.disconnect(); } catch (x) {} }
|
||
teardownSession(); // H4: same clean teardown when BACK returns to setup
|
||
elUrl.value = serverUrl || '';
|
||
elSetupStatus.textContent = ''; elSetupStatus.className = 'status';
|
||
show(elSetup); elUrl.focus();
|
||
}
|
||
}
|
||
});
|
||
|
||
// ---- boot ----
|
||
// Always reach the server prompt until the display is actually paired. Only a
|
||
// fully provisioned device (has a saved device_id + token) goes straight to
|
||
// playback; otherwise show the setup screen and ask for / confirm the server.
|
||
startKeepAwake(); // FIX A: assert + re-assert keep-awake on an interval
|
||
document.addEventListener('visibilitychange', onVisibility); // FIX B: suspend/resume fast-path
|
||
startWatchdog(); // FIX B (hardened): server-silence liveness backstop
|
||
if (serverUrl && deviceId && deviceToken) {
|
||
// A2: render cached content IMMEDIATELY so a cold-start/offline TV isn't blank while the socket
|
||
// connects (or if it can't). The socket's fresh device:playlist-update replaces it on connect.
|
||
show(elStage);
|
||
var _cp = get(LS.payload);
|
||
if (_cp) { try { onPlaylist(JSON.parse(_cp)); } catch (e) {} }
|
||
connect(); // paired — reconnect to playback
|
||
} else if (serverUrl) {
|
||
show(elSetup); elUrl.value = serverUrl; // server known, not paired — confirm + connect
|
||
elSetupStatus.className = 'status';
|
||
elSetupStatus.textContent = 'Connecting…';
|
||
connect();
|
||
} else {
|
||
show(elSetup); elUrl.focus(); // first run — ask for the server
|
||
}
|
||
|
||
// Expose for debugging
|
||
window.__st = { connect: connect, reset: function () { elReset.click(); } };
|
||
})();
|