screentinker/tizen
screentinker 938a43a466
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
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>
2026-07-11 14:24:31 -05:00
..
css PiP overlay MVP: push image/web overlays to a device or group (#109) (#127) 2026-06-18 14:54:44 -05:00
js Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167) 2026-07-11 14:24:31 -05:00
.gitignore feat(tizen): Samsung Tizen TV web player (.wgt) 2026-06-09 19:01:58 -05:00
build-wgt.sh Tizen player 1.9.1-beta3: bug fixes, multi-zone layouts, video walls 2026-06-18 13:28:08 -05:00
config.xml chore(release): v1.9.4 2026-07-10 11:57:43 -05:00
icon.png feat(tizen): Samsung Tizen TV web player (.wgt) 2026-06-09 19:01:58 -05:00
index.html PiP overlay MVP: push image/web overlays to a device or group (#109) (#127) 2026-06-18 14:54:44 -05:00
README.md Tizen player: real Samsung B2B fleet control (#125), folding in #126 2026-06-18 13:28:08 -05:00

ScreenTinker — Tizen TV Player (.wgt)

A Samsung Tizen TV / signage web port of the ScreenTinker player. It speaks the exact same /device socket.io protocol as the Android player, so a Tizen display pairs and plays from the same dashboard with no server changes.

What it does

  • Enter a server URL → connects to {server}/device (socket.io v4).
  • Registers, shows a 6-digit pairing code; you claim it in the dashboard (Devices → Pair a display). On device:paired it switches to playback.
  • Reconnects automatically with a stored device_id + device_token.
  • Renders multi-zone layouts (matching the Android player) when a layout is assigned — each zone has its own percent geometry, z_index, fit_mode, background, and rotates its own assignments independently — and falls back to fullscreen single-zone when no layout is set, looping:
    • image → shown for duration_sec (min 3s)
    • video (/api/content/{id}/file or remote_url) → plays to end, then next; single item loops
    • YouTube (mime video/youtube) → muted autoplay <iframe> embed
    • widget<iframe> of {server}/api/widgets/{id}/render
  • Sends device:heartbeat every 15s (with best-effort Tizen telemetry).
  • Keeps the screen awake (tizen.power / Samsung appcommon screensaver-off).
  • Video walls (mirrors the web player): when the device is a wall member the payload carries wall_config; the stage is positioned (in vw/vh) as this screen's slice of the wall, the leader broadcasts wall:sync and followers align index + drift-correct their video to the leader's clock. Per-tile rotation is not applied yet (matches the web player); video walls have no Android equivalent.

Files

config.xml          Tizen TV web-app manifest (privileges, profile, icon)
index.html          setup / pairing / stage screens
css/style.css
js/app.js           device protocol client (register, pair, heartbeat, state)
js/device-control.js Samsung B2B/system fleet control (device:command) — #125
js/player.js        fullscreen playlist renderer
js/socket.io.min.js socket.io-client v4.7.5 (bundled)
icon.png
build-wgt.sh        package (signed if Tizen CLI present, else unsigned)

Build

./build-wgt.sh            # -> ScreenTinker.wgt

Without the Tizen CLI this is an unsigned .wgt.

Why the released .wgt is unsigned: Samsung distributor certificates are locked to the DUID of the signer's own TVs, so a .wgt we signed would not install on your TV anyway. Releases therefore ship it unsigned (for inspection only). To actually run it, use path A (no signing) or sign it yourself with your own certificate (path B).

Deploy — two paths

A) URL Launcher / TV browser (easiest, no signing)

No package, no Tizen Studio. Point the TV's URL Launcher (or just its web browser) at your server's built-in web player: https://<your-instance>/player. The TV runs it as a web app on boot, pairs with a 6-digit code, and plays - best for Samsung B2B signage (SSSP). (You can instead self-host this tizen/ folder and point the URL Launcher at …/index.html for the Tizen-specific build.)

B) Signed .wgt (installed app)

A signing profile is already set up on the build box (Tizen Studio CLI 6.1):

  • Profile ScreenTinker = a self-signed author cert (~/tizen-studio-data/keystore/author/st_author.p12) + the default Tizen distributor cert. ./build-wgt.sh auto-detects the CLI and signs with it, producing a .wgt with author-signature.xml + signature1.xml.
  • This installs on developer-mode Samsung TVs and the Tizen emulator — the right path for a self-hosted fleet you control (enable Developer Mode on each TV once: Apps → enter 12345 → set the host IP).

Install onto a dev-mode TV:

sdb connect <tv-ip>
tizen install -n ScreenTinker.wgt -t <tv-device>

Production / retail (no developer mode): re-sign with a Samsung Partner or Public distributor certificate from the Tizen Certificate Manager (free Samsung account; distributor cert tied to each TV's DUID), then ./build-wgt.sh <thatProfile>. The self-signed author cert is not committed (it lives in ~/tizen-studio-data, password screentinker).

Validated (2026-06-09)

  • Protocol: headless test against the live server passed end-to-end — register(pairing_code) → device:registered → pair → reconnect(device_id+token) → device:playlist-update(2 items) → GET /api/content/{id}/file = 200.
  • Runtime: loads + renders in Chromium with no JS errors (setup screen verified).
  • Not yet on real Tizen hardware — needs signing + a TV (or URL Launcher).

Remote control & preview (#120 / #121 / #125)

The Tizen player listens for the same dashboard events as the web/Android player. device:command is handled by js/device-control.js, which drives the real Samsung fleet-control surface (webapis.systemcontrol on Tizen 6.5/7, else b2bapis.b2bcontrol on SSSP/Tizen 4) and reports each outcome back via device:log (tag command, shown live on the device-detail screen) plus a structured device:command-result:

Command (device:command type) Tizen behaviour
refresh / reload location.reload()
launch / screen_on clears the screen-off overlay + re-asserts wake; setPanelMute("OFF") when the B2B surface is present
screen_off setPanelMute("ON") (backlight off) on a B2B panel; black overlay fallback otherwise
update reload to re-pull URL-Launcher content (no in-app OTA — see Updates)
reboot rebootDevice() on a B2B panel; unsupported otherwise
shutdown setPanelMute("ON") + note (SSSP web API has no true power-off)
unknown reported as unsupported
device:screenshot-request best-effort capture (see note)
device:remote-start / -stop start/stop ~1 fps preview streaming

Partner-signing caveat (#125): the b2bcontrol / systemcontrol privileges in config.xml only take effect on a partner-signed .wgt on a real SSSP panel. On the dev/URL-Launcher/web build (or a consumer TV) those surfaces are absent, so reboot returns unsupported, screen_off uses the black overlay, and the startup capability log reports backend=none. Only a partner-signed build on real hardware fully validates reboot / panel power.

Screenshot/preview note: the TV decodes <video> onto a hardware overlay plane and plays YouTube in a cross-origin <iframe>, neither of which can be read back into a <canvas>. So images capture for real; video/YouTube fall back to a status card (device + timestamp). The dashboard preview shows a truthful frame rather than a dead button. Full-fidelity video preview isn't feasible on the sideloaded Tizen runtime.

Updates (#122)

There is no in-app OTA for a sideloaded, signed .wgt. Updating a screen means re-building and re-sideloading the .wgt (path B above), or — on Samsung B2B signage — pushing it through the URL Launcher refresh / MDM (MagicINFO / SSSP) channel. The dashboard update command therefore just tells the screen an update is pending; it cannot self-apply. If you run the URL Launcher path (A), a plain TV reboot re-fetches …/player and you're current with the server with no .wgt step.

Auto-launch on boot (#122)

Boot auto-start for a sideloaded consumer TV web app is a display setting, not an app setting — there's no config.xml autostart for the TV profile. Configure it on the panel:

  • URL Launcher path (A): set the URL Launcher as the boot app (it relaunches on power-up automatically) — the recommended signage setup.
  • Signed-app path (B): use the TV's kiosk / auto-start app setting (B2B/SSSP firmware) to launch ScreenTinker on boot; on dev-mode consumer TVs there's no guaranteed boot-launch, so the URL Launcher path is preferred for unattended screens.

Version reporting (#119)

app_version is sourced from config.xml's version="" — read at runtime via the Tizen application API, with a build-stamped constant fallback (build-wgt.sh stamps it from config.xml). The dashboard always shows the version actually installed.