/* 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; // v4 degrade-safe: DON'T arm until a device:heartbeat-ack // v4 canonical anti-herd THRESHOLD: 45s ± up to 10s random jitter (was a fixed 35s), so a fleet // doesn't all declare half-open simultaneously under a shared cause (server load delaying acks // fleet-wide). Matches the APK's LivenessWatchdog.thresholdMs so the three clients behave // identically on the wire. Re-jittered per connect(). var THRESHOLD_BASE_MS = 45000, THRESHOLD_JITTER_MS = 10000; function thresholdMs(rand) { return THRESHOLD_BASE_MS + Math.round((rand - 0.5) * 2 * THRESHOLD_JITTER_MS); } var livenessWindowMs = THRESHOLD_BASE_MS; var watchdogTimer = null; // v4: ANY inbound refreshes the SILENCE timestamp (so other server traffic keeps a healthy socket // alive) — but it does NOT arm. Arming gates on the ack specifically (see the device:heartbeat-ack // handler), so engine pings alone can't arm us against an ack-less server. function markAlive() { lastServerMsgAt = mono(); } // A5 monotonic // Pure, unit-testable. Reconnect ONLY when a connected+authenticated socket whose liveness we have // ARMED (seen >=1 device:heartbeat-ack — v4 degrade-safe: a server that never app-acks never arms // us, so no false-fire even though engine pings keep flowing) has gone silent past the 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.__stThresholdMs = thresholdMs; } // ---- 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: 1000, // v4 canonical: 1s start (was 2s) reconnectionDelayMax: 30000, // v4 canonical: 30s cap, within the ~30-60s band (was 10s) randomizationFactor: 0.2, // v4 canonical: ±20% jitter (was ±50%); exponential-double shape kept 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; // v4 degrade-safe: DIS-arm until a heartbeat-ack re-arms livenessWindowMs = thresholdMs(Math.random()); // v4: fresh 45s ± up to 10s jitter for this connection socket.onAny(markAlive); // refresh SILENCE on any inbound (does not arm) socket.io.on('ping', markAlive); // engine ping refreshes silence too (still does not arm) socket.on('connect', function () { // #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(); }); // v4 degrade-safe ARM: the watchdog arms ONLY after the first app-level device:heartbeat-ack. // A server that sends engine pings but no app-ack (old/pre-contract server) never arms us, so the // watchdog can't false-fire — markAlive (onAny) still refreshed lastServerMsgAt for the silence // check, but ARMING is gated on the ack specifically. socket.on('device:heartbeat-ack', function () { livenessConfirmed = true; }); 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() }; // v4 client identity block — additive, canonical snake_case (same field shape as the APK, so the // server consumes one thing). Backward-compatible: an old server ignores unknown fields. msg.client_type = 'wgt'; msg.client_version = APP_VERSION; // config.xml version (stamped by build-wgt.sh) msg.platform = 'Tizen ' + (tizenVersion() || ''); msg.contract_version = 'v4'; 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