Taking a display out of a sync group, or deleting the wall it belonged to, froze it on whatever was
playing. The clip looped forever and every later refresh took the "unchanged" branch, because the
element was attached, playing and un-errored — healthy by every check the player makes. Only a
reboot cleared it.
On the web player, reconcileAdvanceTimerForMode re-arms a solo timer for widgets and images but
skips video and YouTube, on the grounds that they "self-advance via their own end handlers". The
handler that is live at that moment, though, was built for the mode being left: a group-rendered
video was created with `loop = !!groupSync`, a wall-follower video with `isFollower` true, and both
are captured in the closure at render time. A looping element never fires `ended`, and a follower's
handler declines to advance — so nothing self-advances and nothing re-renders. It now re-renders
whenever the element on screen is still looping, rather than guessing which media types can look
after themselves.
Tizen had the same freeze by a different route. GroupSyncController.exit and WallController.exit
both call player.invalidate() for exactly this purpose, but invalidate only cleared the change
signature — and load() returns at the continuity check ("current item survives, just retarget the
index") before reaching any render, so the invalidate was a no-op. It now forces the next load to
re-render, which is what those call sites always intended. On Tizen this froze every item type, not
just video, because `single` skips the timer in all of the renderers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Replacing the single item of a one-item playlist did nothing. The old promo, board or clip kept
playing while the dashboard showed the new playlist published and the device perfectly healthy —
only a reboot or a manual refresh cleared it.
#157 defers a rotation so a live item is not yanked mid-play, and applies it "on the next natural
advance". For a one-item playlist there is no such thing, by design: single-item rendering
deliberately never advances. A video gets `loop = (playlist.length === 1)` and so never fires
`ended`; a YouTube embed loops for the same reason and skips its safety net; a solo widget is "held"
on a self-re-arming refresh that never calls nextItem, because reloading it would reset a directory
board's scroll. Tizen is worse still — `single` makes every renderer skip its timer, so images
freeze too.
Two guards, the same pair already applied to the Android controller:
- A one-item playlist is never deferred. There is nothing to protect from being cut off, since
nothing was going to advance anyway.
- Any deferral that does happen gets a 60-second deadline. The deferral is a bet that an advance is
coming; if the bet loses, the change must still land rather than strand the screen on content the
operator has already replaced.
Verified in headless Chrome: a one-item playlist holding a solo widget (the "held" case that never
advances), its only item replaced with a different widget — the screen followed, with no reload and
no restart. Before the change it stayed on the replaced item indefinitely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Same fault as Android, in both other players, and my earlier read of them was wrong: I assumed they
rebuilt the iframe each cycle so could not go stale. They do rebuild — but only after the update
survives a change check, and both change checks key on IDENTITY:
web content_id|widget_id|remote_url|filepath|filename|schedules|transition
tizen [content_id, widget_id, remote_url, mime_type, schedules, transition]
A widget's identity does not change when it is edited, so an edit produced an identical signature,
the update was discarded as "unchanged", and the old render stayed up. widget_rev now sits in both,
alongside schedules and transition, which are there for exactly this reason.
The render URL carries the rev on both players as well. In the zone path the web player was picking
up `item.widget_rev` inside a loop whose variable is `a` — that would have been undefined on every
zone; it now reads the zone assignment's own rev.
Caching, which is the reason this is worth doing properly rather than just busting the URL: a URL
carrying ?rev=<updated_at> is content-addressed, so those bytes cannot change without the URL
changing. The render endpoint now returns immutable caching for a pinned URL and keeps no-store for
a bare one, and the service worker serves pinned renders cache-first (CACHE_NAME v18).
That closes a real gap. no-store meant widgets were the ONE thing the player's offline cache could
never hold, so a display that lost its uplink lost its widgets — while its images and video kept
playing. Offline resilience is the point of that cache. Old players sending no rev are unaffected:
they still get no-store, because without a rev nothing distinguishes one render from the next.
Verified live: bare URL -> no-store; ?rev=123 -> public, max-age=31536000, immutable. 859 server
tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
Three features from this session, full server suite green (535/535).
TOTP 2FA (#100) — backend shipped without a UI; add it:
- Login: mfa_required -> 6-digit challenge (recovery codes accepted) -> /totp/verify.
- Settings > Account: enable (QR + confirm -> recovery codes once), regenerate,
disable; SSO accounts see "managed by your identity provider".
- /totp/setup returns a server-rendered qr_data_url (bundled qrcode dep). keyuri
folds the request Host into the issuer so multi-instance accounts are
distinguishable in the authenticator app.
Email verification on signup — hosted HARD-block / self-host SOFT-nudge:
- email_verified column; existing users asked on first login (SSO + platform
admins grandfathered); single-use 24h tokens (SHA-256 hashed).
- Gate engages only when email is configured (never locks out a no-mail instance).
GET /verify-email + POST /resend-verification (generic, no account enumeration).
- Client: "confirm your email" flow + resend, verified/error toasts, self-host
banner; onAuthSuccess refuses a tokenless response (defensive).
Tizen SSSP URL-Launcher install — Fusion-style one-URL native install:
- Server hosts /tizen/sssp_config.xml (dynamic <size>, always matches the served
.wgt) + /tizen/ScreenTinker.wgt + a human landing. lib/wgt-cache.js resolves the
signed .wgt (/data mount wins, mirroring the APK).
- build-wgt.sh also emits a static sssp_config.xml for CDN hosting.
- Retail panels require a Samsung Partner cert; dev-mode is SDB self-signed only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Android and Tizen never emitted device:play-event, so Reports showed Total
Plays / Hours / proof-of-play as all zero for those devices (only the web
player logged plays). Both now emit play_start on show and play_end on
advance/teardown, mirroring the web player's contract (leader-gated for walls,
widget-id fallback so durations close). Server side unchanged — play_logs and
the reports queries were already waiting for the events.
Tizen parity with the web player (from the parity audit):
- audio: landscape <video> honors item.muted (warm-muted for autoplay, then
applies the real state) instead of force-muting; wall followers stay silent
- device:mute-changed: real-time per-item mute of the on-screen video
- device:remote-key: D-pad/volume/mute/home, BACK=info overlay, POWER=screen-off
- device:remote-touch: normalized-coordinate touch injection
- buffered widget swap: reveal the new iframe on load then clear (no black flash)
- diagnostic info overlay toggled by the dashboard BACK key
Still muted on Tizen: the portrait AVPlay video path and transition-composited
video (would need webapis.avplay volume APIs / renderVideoBuffered work).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-release review follow-up to #204: fixes the Android superseded-wipe playlist wedge + GL leak, and adds the stale-item guard to web/Tizen renderVideoBuffered.
Compositor panel-ring board scroll (smooth on Blink+Gecko, no blank-on-refresh), a per-device frame-rate diagnostic widget + dashboard card, and web/Android/Tizen device-id passthrough to widget render URLs.
The Tizen .wgt player black-flashed between IMAGE items on slow decode HW
(Samsung OM55B / SSSP). Root cause: playCurrent() called clearStage() before
renderImage() set img.src, so the stage was empty (black) until the new image
decoded. Images had no decode-gated double-buffer — video gained one in #167
(938a43a), which made the always-present image flash conspicuous by contrast.
renderImage has been byte-identical since the first Tizen commit, so there was
nothing to revert; the buffer had to be ADDED, mirroring the video path.
- preloadImage()/_takePreloadImage()/_releasePreloadImage(): one-ahead,
image-only decode-gated buffer modeled on preloadVideo()/_takePreload().
Detached <img>, src set, warmed via HTMLImageElement.decode() (feature-
detected — onload/complete fallback for Tizen 5.0 / SSSP6). Warmed when the
current image begins its dwell and from the group-sync boundary tick.
- renderImage now SWAPS: take the pre-decoded <img> (or decode a fresh one) and
only THEN clearStage()+append, in one synchronous block — the compositor
never sees an empty stage. Never clear-then-load on the image path.
- Scoped to images only: playCurrent() skips the up-front clearStage() solely
for image targets (same branch order as the dispatch); video/youtube/widget
keep their pre-dispatch clear untouched. onerror and decode() rejection route
to skipSoon(); stale-index guard blocks mounting a stale decode over the
current item after next()/gotoIndex/load(); one-ahead with stale release on
index move, load(), stop(), and group-sync exit. #A1 single-item heal intact.
Tests:
- server/test/tizen-image-blackflash.test.js (new, 4/4): loads the real
player.js in a vm context with a test-controllable decode() Promise and proves
the invariant — across image->image the #stage is NEVER without a mounted
<img> (old element held until the new image's decode()/onload resolves, then
swap). Covers decode() supported, decode() absent (onload fallback), and
broken-image (decode-reject / onerror) -> skipSoon. Proven to VIOLATE on the
old clear-then-load ordering and HOLD on the fix.
- server/test/pip-overlay.test.js: the decode-gate makes image mount async,
which broke its older shim (no decode()/onload/complete). Teach the shim
element complete/naturalWidth so renderImage takes its synchronous
complete-fallback branch and mounts. Test-only.
Full server suite 486/486. node -c clean. Headless proves the DOM ordering
invariant (the flash's precondition); final black-frame sign-off needs a real
OM55B panel (manual steps in the test-file header).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Vertical Shorts were played in a player forced to 100%x100% on a landscape
frame, so they looked wrong (pillarboxed/small). Option A: detect vertical at
ingest, persist it, and have every player honor it.
- Ingest (routes/content.js): detect a Short from the /shorts/ URL form OR
portrait oEmbed dims (oEmbed now queried with the ORIGINAL url so /shorts/
reports its true dimensions), and persist it as st_aspect=vertical on the
stored embed URL. That's the only signal players get (remote_url), so it must
be captured at ingest, not re-derived per loop. YouTube ignores the unknown
param; players read the video id, not the full URL, to build the embed.
- Players read st_aspect=vertical and center a 9:16 box (fills a portrait screen,
pillarboxes cleanly on landscape) instead of 100%x100%:
web (player/index.html), Android (WebViewSupport.youtubeEmbedHtml), Tizen
(player.js single-zone + zone paths). Dashboard library uses a static thumbnail,
so it's unaffected.
Not doing Option B (yt-dlp): runtime dep + storage/bandwidth + maintenance +
YouTube ToS; embed-disabled Shorts already skip gracefully.
Tests: youtube-shorts.test.js (4) — /shorts/ and portrait-dims tag vertical,
landscape stays untagged, /shorts/ tags even if oEmbed fails. Android compiles;
web player inline JS + Tizen player.js parse.
Note: pre-existing Shorts added before this aren't retagged (would need an oEmbed
backfill) — re-add to fix, or a follow-up migration.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Bold field report: portrait video on Tizen = black screen. Root cause: our orientation
support rotates #stage with a CSS transform, but on Tizen the HTML5 <video> is composited
on a HARDWARE video plane that ignores CSS rotate — so portrait/flipped video blacks out
(images/widgets/text rotate fine). It's a Tizen platform limitation, not a regression.
Fix: route portrait / portrait-flipped VIDEO through Tizen AVPlay, whose setDisplayRotation
rotates the hardware plane itself. Scoped to portrait/flipped ONLY — landscape keeps the
proven HTML5 <video> path (double-buffer + group-sync drift), so the working case is untouched.
- index.html: <object id="avPlayer" type="application/avplayer"> hole-punch surface (hidden
until a portrait video plays; off-hardware it's inert).
- player.js: setOrientation()/avAvailable(); renderVideoAv() (open/setDisplayRect/
setDisplayRotation 90|270/setDisplayMethod LETTER_BOX/prepareAsync/play; onstreamcompleted
-> loop|advance); avStop() torn down in clearStage() before every render; renderVideo()
branches to AVPlay only for portrait/flipped when webapis.avplay exists.
- Graceful: any AVPlay error/absence -> honest note (portrait_video_unsupported, 5 locales),
NEVER a silent black screen. currentVideoEl stays null for AV (portrait is solo-first;
schedule engine still drives index/position).
- app.js: applyOrientation() now tells the player the orientation so renderVideo can choose
the path.
Exit-signal marker slice unaffected (test green). Landscape + off-hardware paths unchanged.
NEEDS ON-DEVICE VALIDATION on a real Tizen TV (AVPlay can't be exercised off-hardware) —
signed test .wgt built for Bold.
Refs #170.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause
Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.
Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.
Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
(router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.
Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.
Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.
CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).
Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.
Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)
Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
- link_lost=true -> Wi‑Fi/Ethernet link lost (device's own link)
- link up, internet_ok=true -> server_down: internet reachable, OUR server was unreachable
- link up, internet_ok=false -> no_internet: router/ISP down
- link up, no probe result -> generic router/upstream (unchanged fallback)
- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.
Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)
When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PlaylistPlayer and ZoneRenderer share one #stage node, but every playlist-update
unconditionally blanked the OTHER renderer (zoneRenderer.clear() in the single-zone
branch, player.stop() in the layout branch) and then hit that renderer's unchanged-
signature `return` — leaving the stage BLANK. Same class as the Android #162 wedge (a
stale "still on screen" belief trusted while nothing is actually rendered):
- fires on the routine ~60s heartbeat re-register (server re-pushes the same playlist),
- permanent for a single looping item (no advance timer to self-heal),
- also stranded the stage on suspend -> resume and cold-start cached-playlist restore.
Fix: track which renderer owns the shared stage (stageOwner) and only blank the other
one when actually switching modes, invalidating the incoming renderer's signature so it
repaints on the switch; never blank on a same-mode unchanged update. Added
ZoneRenderer.invalidate() to mirror PlaylistPlayer.invalidate(). Verified with a
faithful state-machine simulation: old code blanks 8x across a realistic
pair/heartbeat/switch/suspend sequence, fixed code 0x.
The web player (server/player/index.html) was reviewed and is NOT vulnerable — its
unchanged-guard already verifies real DOM surface health (needsReattach, the #146 fix)
and its JS state resets on reload, so there is no analogous wedge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tizen .wgt v4-conformance delta — client-only, targeted (the lifecycle hardening was
already merged; this only closes the v4 gaps the .wgt predated):
- Arm-after-ack (the behavioral change): the watchdog arms ONLY after a
device:heartbeat-ack, not on any inbound/engine ping — so an ack-less/old server
never arms it (degrade-safe). markAlive still refreshes lastServerMessageAt on any
inbound for the silence check; arming gates on the ack.
- Threshold: fixed 35s -> v4 canonical 45s ± up to 10s jitter (re-jittered per connect).
- Backoff params: 1s start / 30s cap / ±20% jitter (was 2s/10s/±50%), exp-double kept.
- v4 client identity block on register (client_type=wgt / client_version / platform /
contract_version=v4), canonical snake_case matching the APK.
- No-poll confirmed. Preserved untouched: keep-awake re-assert, resume handler, #148
teardown-before-reopen, 4th-beat refresh, unpaired 3s backoff.
Verified: arm-after-ack both directions (engine-ping-no-ack -> NOT armed; ack -> arms +
catches half-open), v4 runtime values, #148 one-socket-one-register, server suite green.
Depends on the server device:heartbeat-ack (core pass) — degrade-safe until then.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
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.
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.
* PiP overlay MVP: push image/web overlays to a device or group (#109)
Implements the #109 MVP from docs proposal: a floating overlay PUSHED to a device or
group in real time, rendered above the playlist without disturbing it. Scope is the
MVP only — video/RTSP, MQTT, offline-queue, and the priority/stacking system are
deferred to follow-up PRs as the proposal specifies.
Protocol (/device socket, player-agnostic):
- device:pip-show { pip_id, type:image|web, uri, position, width, height, duration,
title?, title_color?, background_color?, opacity?, border_radius?, close_button? }
- device:pip-clear { pip_id? }
The player fetches uri itself (same trust model as remote_url content; server never
proxies). type:web is full-trust by design, hence the 'full' token scope.
Server (server/routes/pip.js, new; mounted in config/api-surface.js PUBLIC_ROUTERS):
- POST /api/pip and POST /api/pip/clear + DELETE /api/pip, all requireScope('full').
- Resolves device_id to a device OR a group, expands a group to members, and emits
per-device — reusing the group command route's room-size online check and
{device_id, name, status: sent|offline} result shape. Generates pip_id.
- Validates type/position allowlists, uri http(s), numeric bounds on
width/height/duration/opacity/border_radius, colors via the existing VALID_COLOR
(#RRGGBB; transparency is the separate opacity field).
- Workspace-isolated: every target query is scoped to req.workspaceId, so a token
bound to workspace A can't address workspace B (404). Offline devices are reported,
never queued (PiP is ephemeral).
Player overlay layer (Tizen; tizen/js/pip-overlay.js, new):
- A #pip sibling ABOVE #stage that PlaylistPlayer/ZoneRenderer never touch.
- applyOrientation now applies the SAME transform to #pip as #stage, so corner
positions track the visible CONTENT in all four orientations.
- image -> <img>, web -> <iframe> (muted by default: empty allow= denies autoplay),
sized/positioned/styled per payload, optional title bar.
- Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
(id-aware) or timer tears down; teardown wrapped so a malformed payload can't wedge
the layer. Reports show/clear over device:log (tag 'pip').
Dashboard: a minimal "Send overlay" / "Clear overlay" tester on the device-detail
controls (device/group via the open device, type, uri, position, duration), calling
POST /api/pip through the api helper.
Tests (server suite green, 161/161):
- api.test.js: PiP tier — authz (read/write 403, full passes), workspace isolation
(wsA token -> wsB device 404), payload validation, device + group targeting, clear;
plus the PUBLIC_ROUTERS snapshot-firewall updated for /api/pip.
- pip-overlay.test.js: loads the real player.js + pip-overlay.js in a vm with a DOM
shim; proves the overlay shows, auto-dismisses on the duration timer, and never
changes the playlist signature / touches #stage; web->iframe, last-show-wins,
id-aware clear, malformed-payload safety.
Not in this PR (intentional):
- Android player overlay — fast-follow. Protocol + server are player-agnostic; the
Android layer (an overlay View above the player, orientation-matched to MainActivity's
rootView rotation) is the same shape and lands next.
- OpenAPI docs for POST /api/pip — the contract test's scope heuristic only treats
'command' paths as full-scope, so documenting a full-scope non-command route there
needs that heuristic extended first; deferred with the docs item (proposal §8.6).
- video/rtsp types, MQTT, offline queue-on-reconnect, priority/stacking, arbitrary
(x,y)/selector positioning (proposal §6).
Refs #109
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* PiP overlay: add Android + web players (#109)
Extends the #109 PiP MVP to the other two players so the protocol (device:pip-show /
device:pip-clear) is honored fleet-wide, not just on Tizen. No server/protocol changes —
the route and socket messages are player-agnostic; these are the two missing surfaces.
Web player (server/player/index.html):
- New #pipContainer layer above #playerContainer, pointer-transparent, that the playlist
render never touches. The same orientation transform is applied to it as to
#playerContainer (extended to also reset width/height on landscape so a
portrait->landscape switch realigns), so corner positions track the visible content.
- Inline PiP logic mirroring tizen/js/pip-overlay.js: image -> <img>, web -> <iframe>
(muted by default via empty allow=), position/size/bg/opacity/radius/title, single slot
last-show-wins, duration timer (0 = until cleared), id-aware clear, wrapped teardown.
- device:pip-show/clear handlers; reports show/clear over device:log (tag "pip").
Android player:
- activity_main.xml: a pipLayout FrameLayout as the LAST child of rootLayout — it draws
above the content AND inherits rootView's orientation rotation/translation, so corner
positioning is orientation-matched for free.
- PipOverlay.kt (new): builds the overlay box into pipLayout. image -> ImageView (decoded
off-thread via ImageLoader, dropped if torn down mid-decode); web -> WebView with
mediaPlaybackRequiresUserGesture=true (mute-by-default). Gravity-based corner/center
placement with a 4% inset, GradientDrawable bg + corner radius, alpha=opacity, optional
title bar. Single slot last-show-wins; duration timer; id-aware clear; teardown wrapped
and also run on activity destroy (WebView cleanup).
- WebSocketService: onPipShow/onPipClear callbacks + safeOn handlers posted to the main
thread (they build Views) + a sendLog(tag, level, message) emitter for device:log.
- MainActivity: instantiate PipOverlay (log -> wsService.sendLog("pip", ...)), wire the
callbacks, tear down on destroy.
Verified: Android assembleDebug builds clean; web player inline JS parses; server suite
still 161/161 (no server changes this commit). Not yet validated on real hardware —
four-orientation corner positioning mirrors the player container/rootView transform but
should be eyeballed on a panel.
Refs #109
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#123 already shipped a placeholder device:command handler (#121/#122): screen_off
was a black overlay, reboot/shutdown a toast, update a "re-install" toast. This
replaces that with the real control surface from #125, reconciled into the single
handler #123 introduced (rather than landing a second, competing handler).
- NEW tizen/js/device-control.js: window.STDeviceControl = { run, capabilities,
backend }. Feature-detects webapis.systemcontrol.* (Tizen 6.5/7, sync/throws) then
b2bapis.b2bcontrol.* (SSSP/Tizen 4, async), normalises both to Promises, re-probes
each call. run() never rejects; resolves { ok, supported, action, note, reload }.
Panel power: setPanelMute (mute ON = backlight OFF) -> setDisplayPanel/setPanelStatus
fallback. reboot -> rebootDevice(); shutdown mutes the panel and notes SSSP has no
true power-off; update/reload -> reload:true.
- tizen/js/app.js: device:command now calls STDeviceControl.run and reports the
outcome via reportCmd (device:log tag=command -> dashboard:device-log, plus a
structured device:command-result), reloading ~1.2s later on result.reload. screen_off
falls back to the existing black overlay (showScreenOff) when no B2B surface exists;
screen_on/launch still clear the overlay + keepAwake. Dropped the now-dead
tryPowerControl. reportCapabilities() runs on device:registered so the dashboard sees
the backend ("none" on web/URL-Launcher/consumer TV).
- tizen/config.xml: partner-level b2bcontrol + systemcontrol privileges (ignored, not
fatal, on unsigned/URL-Launcher/web/consumer builds).
- tizen/index.html: load $WEBAPIS/webapis.js + $B2BAPIS/b2bapis.js before the app
scripts (404 harmlessly off-hardware) and device-control.js before app.js.
- tizen/README.md: rewrote the remote-control table for real B2B control + a
partner-signing caveat; added device-control.js to the file list.
Supersedes PR #126 (feat/tizen-device-command-125), which targeted main unaware that
this branch already had a device:command handler.
Verified: node --check on both JS files; config.xml well-formed (xmllint). Not yet
validated on a real SSSP panel — the control surface only takes effect on a
partner-signed .wgt (backend reports "none" on the dev/URL-Launcher build).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the Tizen TV player to parity with the other players: closes the five
Tizen issues Bold Media Group filed (#118-#122) and adds the two larger renderer
features it was still missing.
Fixes (#118-#122)
- #118 Sticky "Not authenticated" banner. On TV sleep/wake the socket reconnects
and a heartbeat could land on the fresh, not-yet-registered socket; the server
rejected it and the old handler painted a permanent banner AND dropped the saved
credentials, forcing a re-pair. Heartbeats are now gated on a per-connection
authenticated flag (true only between device:registered and disconnect/auth-error),
the heartbeat stops on connect/disconnect/auth-error, the banner clears on
device:registered, and the auth-error toast is non-sticky.
- #119 app_version stuck at 1.0.0. Resolved at runtime from config.xml via the Tizen
application API, with a fallback constant that build-wgt.sh stamps from config.xml.
- #121 Remote commands. Added a device:command handler (refresh/launch/screen_on/
screen_off; honest no-op toasts for update/reboot/shutdown, which need B2B/MDM
privileges a sideloaded app lacks). Removed the dead device:reload listener.
- #120 Dashboard preview. Added device:screenshot-request + remote-start/remote-stop.
Images capture; video/YouTube fall back to a status card (TV hardware video plane
and cross-origin iframes can't be read into a canvas).
- #122 Updates/boot. Documented the real paths (re-sideload or URL Launcher/MDM
refresh; display-level kiosk/boot settings) since a sideloaded .wgt has no in-app
OTA or config.xml autostart.
Multi-zone layouts (Android parity)
- New ZoneRenderer ports the Android ZoneManager: zones positioned by percent
geometry with z_index/fit_mode/background, assignments grouped by zone_id
(unassigned content goes to the first zone), each zone rotating independently with
the same per-item schedule gating (#74/#75). app.js selects the renderer from
payload.layout; single-zone playback is unchanged.
Video walls (web-player parity; Android has none)
- New WallController mirrors the web player: when payload.wall_config is present the
stage is positioned (vw/vh) as this screen's slice of the wall. The leader plays
normally and broadcasts wall:sync at 4Hz; followers hold the leader's item, align
index, and lock their video to the leader's clock with a latency-compensated drift
controller (hard-seek past 0.3s, gentle +/-3% playbackRate nudge past 0.05s), and
request an immediate position on (re)connect via wall:sync-request. Per-tile
rotation is not applied yet (matches the web player). Wall emits are gated on
auth + connection so a pre-register tick can't trip device:auth-error.
Not ported: video-wall per-tile rotation, plus the minor Android-only reporting
events (device:playback-state, device:log) and the N/A offline-cache events
(device:content-ack/content-delete). None affect on-screen playback.
Verified: JS syntax + headless unit tests of zone grouping/geometry and wall
leader/follower + drift logic. NOT yet validated on Tizen hardware - multi-screen
video sync in particular needs a real wall to tune.