mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
22 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
684e60fc55 |
Offline media on every player, and a revision so the cache can still be updated
Two halves of the same problem. A screen has to keep playing when the link is gone, and it must not keep playing the wrong thing once the link is back. CACHING FOR OFFLINE, on the players that could not: - Tizen cached nothing but the playlist, so a panel came back from a reboot knowing exactly what to show and fetched every frame of it from a server that was not there. tizen/js/media-cache.js caches the media itself to wgt-private (the store Tizen documents as surviving reboots), resumable via Range and If-Range, with the transfer async so a stalled chunk cannot freeze the player. offline.cache moves from "absent" to a runtime claim: a build with no writable private storage still says nothing. - The web player's worker stored only what a single fetch() happened to complete, which on a marginal link is nothing at all — a 200MB asset never finishes in one go and every retry starts from zero. It now accumulates in resumable chunks, driven by the player's playlist rather than by playback, so the prefetch is not competing with the video that is currently on screen for the same scarce bandwidth. BrightSign inherits this. STILL UPDATING, which caching quietly breaks: PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every cache keys on that id, so before this the new bytes could not reach a panel that already held the old ones — not until the next refresh, but never. Content now carries a revision, stamped onto each item at send time like widget revs, and every player keys its cache on it. The same send-time refresh fixes a second bug: a replace writes a new randomly-named file and unlinks the old one, so the filepath in a published snapshot pointed at a deleted file and web panels 404'd on the item until somebody republished the playlist. The route now also pushes to affected devices, which it never did. Bytes are kept only where they can be built upon: no validator means no safe resume, so the partial is discarded and the attempt backs off as the failure it is rather than re-fetching the same prefix forever. Server needed no new transfer support — res.sendFile already does Range, If-Range and 416. The Tizen cache and the service worker are both driven in Node against fakes, because neither can be exercised without hardware and "the chunks assemble correctly" is not something to discover from a panel showing a corrupt video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL |
||
|
|
c07a56b47d |
Tizen declares what it can do, and volume and blanking now work
The dashboard offered every control to every display, so on a Tizen panel the volume
slider and screen_off did nothing and read as bugs. Two of them were genuinely dead:
- set_volume fell through STDeviceControl.run()'s default case and was answered
"unknown command". It is not a Samsung fleet action and must work on every build,
so it is handled in app.js instead: tizen.tvaudiocontrol where the TV profile
provides it (that is the TV's own volume, the only thing that reaches AVPlay video
on the hardware plane), otherwise the media elements. The level is remembered and
re-applied on 'play' — media elements are created per item, so a one-shot set
lasted only until the playlist advanced.
- screen_off was a z-index overlay, which covers the web layer only. Portrait and
flipped video runs through AVPlay on a separate hardware plane the DOM cannot draw
over, so the overlay went up and the video played straight through it. It now tears
the AVPlay session down as well; screen_on re-mounts via playCurrent(), because a
torn-down session cannot be resumed and gotoIndex() early-returns on an unchanged
index.
js/capabilities.js declares the rest at runtime rather than from a static table,
because on Tizen the answer varies by build: reboot exists only through the B2B
surface injected on a partner-signed .wgt, and tizen.tvaudiocontrol is absent in a
browser context. Against the server baseline this adds display.power, remote.screenshot
and remote.stream (all backed by real handlers) and drops offline.cache — the payload
is cached, but media bytes are still fetched from the network, so content does not
survive an outage and claiming it would overstate.
Adds the tv.audio privilege; without it tvaudiocontrol throws SecurityError.
|
||
|
|
cf1124d687 |
Mute reaches YouTube items — it never did, and failed opposite ways per player
Muting was implemented three times and agreed nowhere. A YouTube item is a
cross-origin iframe, so `el.muted` reaches nothing; only the IFrame API can
touch it. Both browser-family players got this wrong, in opposite directions:
web playerVars.mute was `userHasInteracted ? 0 : 1` — autoplay policy and
NOTHING else. An item an operator muted in the admin console played
WITH SOUND, a wall follower blared alongside its leader, and the
real-time device:mute-changed toggle only ever touched `<video>`.
onReady then unmuted unconditionally, and the click-to-unmute overlay
appeared on deliberately-muted items and undid the operator's setting.
tizen the embed URL hardcoded `mute=1`, so YouTube there was PERMANENTLY
silent: the per-item flag was never read and nothing could unmute it.
device:mute-changed did nothing at all, because it dereferenced a
<video> that is null for a YouTube item.
Android was already correct and is unchanged — it is the reference here.
The rule now lives once, in server/lib/media-mute.js, served to the web player
from its single source the same way schedule-eval.js is, and mirrored in Tizen
(which ships inside the .wgt and cannot import it). The ORDER is the substance:
a wall follower is always silent (one wall, one audio source) > autoplay policy,
which is a hard constraint rather than a preference because unmuted playback
without a gesture is refused outright and costs the VIDEO > a live operator
toggle, who is looking at the screen > the item's stored flag.
shouldOfferUnmute() exists so the prompt only appears when a gesture is the ONLY
thing in the way. Prompting on a muted item trains viewers to click a button
that undoes an operator's decision.
Tizen gains enablejsapi + a postMessage bridge so a live toggle flips the embed
without reloading it — reloading would restart the video from zero every time
someone touched the control.
11 new tests pinning each precedence step separately; 1055 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
|
||
|
|
b938fce368 |
feat(auth,tizen): TOTP 2FA UI, email verification on signup, Tizen SSSP install
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> |
||
|
|
07419fee1f |
feat(players): proof-of-play on Android+Tizen; close Tizen parity gaps
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> |
||
|
|
335681b907
|
feat(directory-board): panel-ring scroll + in-place refresh + per-device frame diagnostic (#203)
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. |
||
|
|
c2115c88e3
|
fix(tizen): portrait/flipped VIDEO via AVPlay (hardware-plane rotation) — no more black screen (#170) (#174)
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> |
||
|
|
9c70fcc790
|
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* 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>
|
||
|
|
501ffb11c1
|
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159. |
||
|
|
938a43a466
|
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* 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>
|
||
|
|
5f12315e39 |
fix(tizen): player wedge on shared #stage — same class as #162
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> |
||
|
|
8ad2258e7c |
feat: app-ending signal (exit-signal contract v1) — server + APK + .wgt + /player
Best-effort "last gasp" so Offline is annotated with WHY it went away — completing the liveness story.
Categories: crashed (client uncaught-exception), clean_exit (client confident lifecycle-end, best-effort),
silent (SERVER-inferred by absence — the honest catch-all for violent/external death incl. force-stop/MDM).
SERVER:
- device:exit socket handler + token-authed beacon POST /api/device/exit (reliable-on-unload). Both gated
by liveness.sanitizeExitReason (honesty: only crashed/clean_exit accepted; 'silent'/unknown rejected).
- offline_reason/offline_reason_at/offline_detail columns (additive migration). Clear-on-online (a reason
is always THIS session's); offline transition COALESCEs to 'silent'. Pure annotation — offline detection
and #148/liveness are untouched. Offline dashboard emits carry offline_reason + client_type.
CLIENTS (canonical {reason,detail} shape):
- /player: window error/unhandledrejection + pagehide(persisted=false) -> sendBeacon.
- .wgt: same + BACK-key exit -> socket.emit + sendBeacon.
- APK: global UncaughtExceptionHandler -> crashed (blocking beacon, chains to default); Service.onDestroy
-> clean_exit (socket + bounded beacon). New ExitSignal.kt. onStop/onPause NOT wired (background != exit).
Proven (Phase 3): per-category classification, nothing misclassified, external kill -> silent (never
clean_exit), backgrounding emits no false exit, #148/reconnect-vs-exit intact. 382/382 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
bcbb3752c6 |
feat(tizen): .wgt v4 delta — arm-after-ack, threshold/backoff params, identity block
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> |
||
|
|
646eab743a |
fix(tizen): P0 audit fix pass — watchdog config-proofing, teardown hygiene, dead-screen self-heal, offline snapshot
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. |
||
|
|
dcd3a05a7e |
feat(tizen): harden FIX B with an application-level liveness watchdog
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. |
||
|
|
78c71e00ab |
feat(tizen): .wgt lifecycle parity — keep-awake re-assert + suspend/resume handler + fixes
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. |
||
|
|
965920cd17
|
PiP overlay MVP: push image/web overlays to a device or group (#109) (#127)
* 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> |
||
|
|
e2ff8f47b7 |
Tizen player: real Samsung B2B fleet control (#125), folding in #126
#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> |
||
|
|
9c4b48800f |
Tizen player 1.9.1-beta3: bug fixes, multi-zone layouts, video walls
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. |
||
|
|
2ccf3264a9 |
feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Each playlist item can carry schedule blocks (active days, start/end time-of-day, optional start/end dates). An item plays when the screen's local "now" matches at least one block; an item with no blocks always plays. #74 covers time-of-day/day-of-week windows including overnight wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is on-device, so dayparting and expiry work offline. - Shared evaluator contract: shared/schedule-vectors.json (39 vectors — DST US+AU, overnight-wrap anchoring, timezone correctness, date boundaries). Canonical JS evaluator in server/lib/schedule-eval.js; Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff test, Kotlin JUnit reads the shared JSON, new android-test CI job). - All three players (web, Android, Tizen) filter by schedule against their own clock, idle with a "Nothing scheduled" message + 30s re-check when everything is filtered, and fail open on any evaluator error. - Editor: per-item schedule modal + row badge in the playlist editor; client validation mirrors the server; editing marks the playlist draft. - Part B (behaviour change): device/group schedule overrides now evaluate in each device's effective timezone instead of server-local time. - Device detail shows the reported timezone + a clock-skew warning. - i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.* to avoid colliding with the device-schedule calendar's schedule.*). - CHANGELOG documents the feature, the Part B change, the fail-open guarantee, and the scheduled-single-video re-render tradeoff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
dfc8a4e358 |
feat(player): software orientation (portrait + flipped) on both players (1.7.12)
The dashboard exposes landscape / portrait / landscape-flipped / portrait-flipped and the README promises rotation, but neither player ever read the device's orientation field - it was hardcoded landscape. Reported by a customer testing Firestick + Samsung signage. Rotate the CONTENT in software, not the panel: Fire TV / Android TV / Tizen are fixed-landscape and ignore setRequestedOrientation (can't physically rotate). - Android (MainActivity): applyOrientation() resizes rootView to the rotated dimensions, recenters, and rotates 0/90/180/270. rootView is the shared container for single-zone AND multi-zone, so both are covered. Driven from the playlist-update payload. - Tizen (app.js): CSS transform on the stage (rotate + swapped 100vh/100vw), same four values, from the playlist payload. Verified on an Android 16 emulator: device set to portrait -> 'Applied orientation: portrait (rotation=90, swap=true)' and the video renders rotated. |
||
|
|
0cfa09046c |
feat(tizen): Samsung Tizen TV web player (.wgt)
Ports the ScreenTinker player to a Tizen TV / signage web app, speaking the SAME /device socket.io protocol as the Android player — no server changes; a Tizen display pairs from the same dashboard. - app.js: device protocol client — register (pairing_code | device_id+token), device:registered/paired/unpaired/playlist-update, 15s heartbeat, keep-awake. Always reaches the server prompt until the display is actually paired; a saved-but-unreachable server falls back to the prompt (no blank screen); BACK returns to it. - player.js: fullscreen single-zone renderer — image (duration timer), video (play-to-end + loop), YouTube (iframe embed), widget (iframe render endpoint). - config.xml: Tizen TV manifest; build-wgt.sh packages (signs if Tizen CLI present, else unsigned); README covers URL-Launcher and signed-.wgt deploy. Validated: headless protocol test vs the live server passed end-to-end (register -> pair -> reconnect-auth -> playlist(2) -> content 200); loads + renders in Chromium with no JS errors. Not yet ported (fullscreen single-zone covers most signage): multi-zone, video walls, screenshots, remote control, self-OTA. .wgt is a build artifact (gitignored). |