/* 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.11'; // 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 clock: 'st_clock_offset' // #group-sync: cached server-clock offset (survives reboot/outage) }; // ---- 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 // feat/offline-cause-log: connectivity-report state. Track in-session disconnects so a reconnect can // tell the server WHY it was gone (local link lost vs server/upstream unreachable). A browser can't // see SSID/RSSI, so we send only offline_ms + link_lost + cold_start:false. var disconnectedAtMono = 0; // mono() at the first disconnect of the current gap (0 = not in a gap) var linkLostDuringGap = false; // navigator went offline at any point during the gap // #group-sync clock discipline. Server is the time authority (heartbeat-ack). Cache a smoothed // offset so synced_now = Date.now() + clockOffsetMs keeps schedule sync aligned through an outage. var clockOffsetMs = (function () { var v = Number(get(LS.clock)); return isFinite(v) ? v : 0; })(); var clockRttMs = null; function syncedNow() { return Date.now() + clockOffsetMs; } function ingestClockSample(serverMs, clientMs) { if (!serverMs || !clientMs) return; var t4 = Date.now(), rtt = Math.max(0, t4 - clientMs); if (rtt > 5000) return; // absurd RTT (GC/sleep stall) — don't poison offset var sample = serverMs - (clientMs + t4) / 2; // NTP-style: offset = server - (t1+t4)/2 if (clockRttMs === null || Math.abs(sample - clockOffsetMs) > 1000) clockOffsetMs = Math.round(sample); else clockOffsetMs = Math.round(clockOffsetMs * 0.8 + sample * 0.2); clockRttMs = Math.round(rtt); set(LS.clock, String(clockOffsetMs)); } // Stream a group-sync diagnostic to the dashboard live-log (tag 'sync'). function reportSync(level, msg) { try { if (socket && socket.connected && deviceId) socket.emit('device:log', { device_id: deviceId, tag: 'sync', level: level, message: msg }); } catch (e) {} } 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 // feat/offline-cause-log: open an offline gap so the next reconnect can report cause. if (!disconnectedAtMono) { disconnectedAtMono = mono(); linkLostDuringGap = (typeof navigator !== 'undefined' && navigator.onLine === false); } 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 // feat/offline-cause-log: reconnected after an in-session disconnect -> report the gap length + // whether the local link dropped. cold_start:false because the app SURVIVED the gap (a reboot // would have lost this in-process state). Browser has no SSID/RSSI to add. if (disconnectedAtMono) { try { socket.emit('device:connectivity-report', { device_id: deviceId, offline_ms: Math.max(0, Math.round(mono() - disconnectedAtMono)), link_lost: linkLostDuringGap, cold_start: false }); } catch (e) {} disconnectedAtMono = 0; linkLostDuringGap = false; } 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 (d) { livenessConfirmed = true; if (d) ingestClockSample(d.server_ms, d.client_ms); }); 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(); }); // Dashboard remote control (parity with the web player) — touch injection + D-pad/volume/mute keys // + real-time per-item mute. All wrapped so a malformed payload can never wedge the socket. socket.on('device:remote-touch', function (data) { try { if (!data) return; var x = (data.x || 0) * elStage.offsetWidth, y = (data.y || 0) * elStage.offsetHeight; var el = document.elementFromPoint(x, y); if (el && el.click) el.click(); } catch (e) {} }); socket.on('device:remote-key', function (data) { try { if (!data) return; var v = player.getCurrentVideo(); var n = player.getItemCount(); switch (data.keycode) { case 'KEYCODE_DPAD_RIGHT': player.advance(); break; case 'KEYCODE_DPAD_LEFT': if (n > 0) player.gotoIndex((player.getIndex() - 1 + n) % n); break; case 'KEYCODE_DPAD_CENTER': case 'KEYCODE_ENTER': if (v) { if (v.paused) v.play(); else v.pause(); } break; case 'KEYCODE_VOLUME_UP': if (v && !player.isWallFollower()) { v.volume = Math.min(1, v.volume + 0.1); v.muted = false; } break; case 'KEYCODE_VOLUME_DOWN': if (v) { v.volume = Math.max(0, v.volume - 0.1); } break; case 'KEYCODE_MENU': if (v && !(player.isWallFollower() && v.muted)) { v.muted = !v.muted; } break; case 'KEYCODE_HOME': if (n > 0) player.gotoIndex(0); break; case 'KEYCODE_BACK': toggleInfoOverlay(); break; case 'KEYCODE_POWER': if (document.getElementById('screenOffOverlay')) clearScreenOff(); else showScreenOff(); break; } } catch (e) {} }); // #129 real-time per-item mute — apply immediately if the toggled item is the one on screen now. socket.on('device:mute-changed', function (data) { try { var item = player.getCurrentItem(); var v = player.getCurrentVideo(); if (!data || !item || !data.content_id || item.content_id !== data.content_id) return; // `v` is null for a YouTube item — it is an iframe, not a