From 78c71e00ab085031d85646c3a202ca6e21ad04cb Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 7 Jul 2026 14:39:16 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(tizen):=20.wgt=20lifecycle=20parity=20?= =?UTF-8?q?=E2=80=94=20keep-awake=20re-assert=20+=20suspend/resume=20handl?= =?UTF-8?q?er=20+=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-only. Brings the standalone Tizen .wgt player toward APK//player parity: - A: re-assert keepAwake() on a 30s interval (power lock / screensaver-off can be released when the TV backgrounds the app); cleared on exit. - B: visibilitychange/resume handler. On resume re-asserts keep-awake and, ONLY for the half-open case socket.io cannot detect (connected===true after a suspend-length hide), owns a clean teardown-before-reopen via connect() (exactly one socket, #118 re-registers once). Defers to socket.io's auto-reconnect when the socket is already disconnected — the two are mutually-exclusive states so no manual reconnect races socket.io. No manual re-register. - C: 4th-beat now re-emits device:register (real fallback playlist refresh) instead of a duplicate device:heartbeat. - D: APP_VERSION_FALLBACK 1.9.1 -> 1.9.2 (repo hygiene; build-wgt.sh stamps at build). - F: device:unpaired now backs off 3s before re-registering (symmetric with auth-error), so MDM re-pair churn can't tight-loop. Keep-awake (A+B) is the LEADING flap candidate, NOT a confirmed cause. Offline caching (E) deliberately excluded. No server changes; no bump/tag/build. --- tizen/js/app.js | 67 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/tizen/js/app.js b/tizen/js/app.js index 43086f6..b805e27 100644 --- a/tizen/js/app.js +++ b/tizen/js/app.js @@ -15,7 +15,7 @@ // 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.1'; // st:app-version — stamped by build-wgt.sh + 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; @@ -85,6 +85,55 @@ try { if (window.webapis && webapis.appcommon) webapis.appcommon.setScreenSaver(webapis.appcommon.AppCommonScreenSaverState.SCREEN_SAVER_OFF); } catch (e) {} } + // 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 = Date.now(); return; } + keepAwake(); // re-assert immediately on resume + var hiddenMs = hiddenAtMs ? (Date.now() - hiddenAtMs) : 0; + 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) + // ---- networking ---- var socket = null; var deviceId = get(LS.id); @@ -179,7 +228,10 @@ socket.on('device:unpaired', function () { del(LS.id); del(LS.token); del(LS.code); deviceId = null; deviceToken = null; - register(); // re-register fresh -> new pairing code + // 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. + setTimeout(register, 3000); }); socket.on('device:auth-error', function (data) { @@ -269,8 +321,11 @@ // requireDeviceAuth() rejects the beat with device:auth-error. if (!socket || !socket.connected || !deviceId || !authenticated) return; socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() }); - // Every 4th beat (~60s) ask for a fresh playlist, matching the Android player. - if ((++beatCount % 4) === 0) 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() { @@ -475,6 +530,7 @@ document.addEventListener('keydown', function (e) { if (e.keyCode === 10009) { // Samsung RETURN / BACK if (!elSetup.classList.contains('hidden')) { + stopKeepAwake(); // FIX A: clear the interval cleanly before the app exits try { tizen.application.getCurrentApplication().exit(); } catch (x) {} } else { if (socket) { try { socket.disconnect(); } catch (x) {} } @@ -489,7 +545,8 @@ // 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. - keepAwake(); + startKeepAwake(); // FIX A: assert + re-assert keep-awake on an interval + document.addEventListener('visibilitychange', onVisibility); // FIX B: handle suspend/resume if (serverUrl && deviceId && deviceToken) { show(elStage); connect(); // paired — reconnect to playback } else if (serverUrl) { From dcd3a05a7e62a7f06032adc8e0c1a4d95614e167 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 7 Jul 2026 14:57:50 -0500 Subject: [PATCH 2/3] feat(tizen): harden FIX B with an application-level liveness watchdog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the resume-only hide-duration heuristic as the AUTHORITATIVE half-open detector with a real server-silence watchdog, so the .wgt self-heals a dead-but-connected socket from ANY cause (network drop, NAT idle timeout, transport death while foregrounded), not just resume. - Central receive-path liveness: markAlive() refreshes lastServerMsgAt on EVERY inbound server message — app events via socket.onAny, and the server's ~15s engine ping via socket.io 'ping' (both client-only signals the server already sends; no server change; heartbeats get no ack). - Watchdog (10s cadence): if socket.connected && authenticated && silent > 35s (2+ missed pings, under engine.io's own ~45s close), treat as half-open and reconnect via the teardown-first connect() -> exactly one socket, #118 re-registers once. - #148 discipline: fires ONLY while socket.connected===true (the state socket.io can't see), so it never races socket.io's own down-socket auto-reconnect; connect() resets liveness so the watchdog and the resume fast-path can't double-fire. Resume path kept as the fast suspend path. - Timers cleared on exit. Client-only; keep-awake+lifecycle remain the leading flap candidate, NOT a confirmed cause. --- tizen/js/app.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/tizen/js/app.js b/tizen/js/app.js index b805e27..4f2c5bc 100644 --- a/tizen/js/app.js +++ b/tizen/js/app.js @@ -134,6 +134,41 @@ } 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 LIVENESS_TIMEOUT_MS = 35000; // ~2+ missed 15s server pings; below engine.io's own ~45s close + var watchdogTimer = null; + function markAlive() { lastServerMsgAt = Date.now(); } // central receive-path hook (see connect()) + // Pure, unit-testable: reconnect only for a connected+authenticated socket gone silent past the window. + function watchdogShouldReconnect(hasSocket, connected, authed, silentMs) { + return !!(hasSocket && connected && authed && silentMs > LIVENESS_TIMEOUT_MS); + } + function startWatchdog() { + stopWatchdog(); + watchdogTimer = setInterval(function () { + var silentMs = lastServerMsgAt ? (Date.now() - lastServerMsgAt) : 0; + if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), authenticated, silentMs)) { + 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; // test hook (inert in prod) + // ---- networking ---- var socket = null; var deviceId = get(LS.id); @@ -184,6 +219,15 @@ timeout: 10000 }); + // 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 = Date.now(); + socket.onAny(markAlive); + socket.io.on('ping', markAlive); + 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 @@ -530,7 +574,7 @@ document.addEventListener('keydown', function (e) { if (e.keyCode === 10009) { // Samsung RETURN / BACK if (!elSetup.classList.contains('hidden')) { - stopKeepAwake(); // FIX A: clear the interval cleanly before the app exits + 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) {} } @@ -546,7 +590,8 @@ // 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: handle suspend/resume + document.addEventListener('visibilitychange', onVisibility); // FIX B: suspend/resume fast-path + startWatchdog(); // FIX B (hardened): server-silence liveness backstop if (serverUrl && deviceId && deviceToken) { show(elStage); connect(); // paired — reconnect to playback } else if (serverUrl) { From 646eab743ac6c5dbc7461ae01f23183d6d166dad Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 7 Jul 2026 21:58:58 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(tizen):=20P0=20audit=20fix=20pass=20?= =?UTF-8?q?=E2=80=94=20watchdog=20config-proofing,=20teardown=20hygiene,?= =?UTF-8?q?=20dead-screen=20self-heal,=20offline=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tizen/js/app.js | 105 ++++++++++++++++++++++++++++++++++++--------- tizen/js/player.js | 20 +++++++-- 2 files changed, 101 insertions(+), 24 deletions(-) diff --git a/tizen/js/app.js b/tizen/js/app.js index 4f2c5bc..19a0327 100644 --- a/tizen/js/app.js +++ b/tizen/js/app.js @@ -32,7 +32,8 @@ id: 'st_device_id', token: 'st_device_token', fp: 'st_fingerprint', - code: 'st_pairing_code' + code: 'st_pairing_code', + payload: 'st_payload_cache' // A2: last renderable playlist-update, replayed on cold-start/offline }; // ---- persistent state ---- @@ -85,6 +86,13 @@ 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 @@ -124,9 +132,9 @@ return (hiddenMs >= SUSPEND_HIDE_MS) ? 'reconnect' : 'noop'; } function onVisibility() { - if (document.visibilityState === 'hidden' || document.hidden) { hiddenAtMs = Date.now(); return; } + if (document.visibilityState === 'hidden' || document.hidden) { hiddenAtMs = mono(); return; } // A5: monotonic keepAwake(); // re-assert immediately on resume - var hiddenMs = hiddenAtMs ? (Date.now() - hiddenAtMs) : 0; + 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 @@ -150,24 +158,34 @@ // 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 LIVENESS_TIMEOUT_MS = 35000; // ~2+ missed 15s server pings; below engine.io's own ~45s close + 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 = Date.now(); } // central receive-path hook (see connect()) - // Pure, unit-testable: reconnect only for a connected+authenticated socket gone silent past the window. - function watchdogShouldReconnect(hasSocket, connected, authed, silentMs) { - return !!(hasSocket && connected && authed && silentMs > LIVENESS_TIMEOUT_MS); + 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 ? (Date.now() - lastServerMsgAt) : 0; - if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), authenticated, silentMs)) { + 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; // test hook (inert in prod) + if (typeof window !== 'undefined') { window.__stWatchdogShouldReconnect = watchdogShouldReconnect; window.__stSetLivenessWindowFromPing = setLivenessWindowFromPing; } // ---- networking ---- var socket = null; @@ -209,6 +227,7 @@ 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', { @@ -216,7 +235,8 @@ reconnection: true, reconnectionDelay: 2000, reconnectionDelayMax: 10000, - timeout: 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 @@ -224,11 +244,21 @@ // (~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 = Date.now(); + 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). @@ -270,12 +300,12 @@ }); socket.on('device:unpaired', function () { - del(LS.id); del(LS.token); del(LS.code); + 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. - setTimeout(register, 3000); + scheduleRegister(3000); }); socket.on('device:auth-error', function (data) { @@ -286,9 +316,9 @@ 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.id); del(LS.token); del(LS.payload); // A2: clear cached content when identity is lost deviceId = null; deviceToken = null; - setTimeout(register, 3000); + scheduleRegister(3000); }); socket.on('device:playlist-update', onPlaylist); @@ -419,6 +449,12 @@ ? 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) {} } @@ -460,6 +496,25 @@ 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 @@ -517,6 +572,9 @@ 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); @@ -534,7 +592,7 @@ wallController.exit(); // leave wall mode if we were in it applyOrientation(payload.orientation || 'landscape'); var layout = payload.layout; - if (layout && layout.zones && layout.zones.length) { + 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 @@ -563,9 +621,10 @@ connect(); } elReset.addEventListener('click', function () { - del(LS.url); del(LS.id); del(LS.token); del(LS.code); + 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); }); @@ -578,6 +637,7 @@ 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(); @@ -593,7 +653,12 @@ document.addEventListener('visibilitychange', onVisibility); // FIX B: suspend/resume fast-path startWatchdog(); // FIX B (hardened): server-silence liveness backstop if (serverUrl && deviceId && deviceToken) { - show(elStage); connect(); // paired — reconnect to playback + // 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'; diff --git a/tizen/js/player.js b/tizen/js/player.js index 41b4477..970535f 100644 --- a/tizen/js/player.js +++ b/tizen/js/player.js @@ -34,7 +34,9 @@ function PlaylistPlayer(stageEl, getBase) { } PlaylistPlayer.prototype.load = function (assignments) { - var items = (assignments || []).filter(function (a) { + // B3: a malformed device:playlist-update with a non-array `assignments` used to throw + // (.filter is not a function) out of the socket handler; coerce to [] instead. + var items = (Array.isArray(assignments) ? assignments : []).filter(function (a) { return a && (a.content_id || a.widget_id || a.remote_url); }); // Stable order @@ -72,7 +74,10 @@ PlaylistPlayer.prototype.idle = function () { }; PlaylistPlayer.prototype.durationMs = function (item) { - var d = item.duration_sec || this.DEFAULT_DURATION; + // B3: a non-numeric duration_sec ("abc") used to yield NaN -> schedule(NaN) -> fire-ASAP spin. + // Coerce; any non-positive/NaN falls back to the default. + var d = Number(item.duration_sec); + if (!(d > 0)) d = this.DEFAULT_DURATION; if (d < this.MIN_DURATION) d = this.MIN_DURATION; return d * 1000; }; @@ -203,7 +208,14 @@ PlaylistPlayer.prototype.playCurrent = function () { // Give a broken item ~2s then move on so the loop never wedges. PlaylistPlayer.prototype.skipSoon = function () { - if (this.items.length > 1) this.schedule(2000); + if (this.items.length > 1) { this.schedule(2000); return; } + // A1: a SINGLE-item playlist used to WEDGE on a broken item — skipSoon did nothing, so a transient + // failure (CDN blip, brief network loss, a 404 that later resolves) left a permanent black screen + // while the heartbeat still reported the device online. Retry the SAME item after a backoff so it + // self-heals instead of going dark forever. + var self = this; + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(function () { self.playCurrent(); }, 5000); }; PlaylistPlayer.prototype.fit = function (el, item) { @@ -239,7 +251,7 @@ PlaylistPlayer.prototype.renderVideo = function (item, single) { // Safety net: if 'ended' never fires (rare), advance after the known // content duration (or the assignment duration) + a buffer. if (!single) { - var secs = item.content_duration || item.duration_sec || this.DEFAULT_DURATION; + var secs = Number(item.content_duration || item.duration_sec) || this.DEFAULT_DURATION; // B3: numeric this.schedule((secs + 5) * 1000); } };