# ScreenTinker on BrightSign

The player is the ordinary web player (`server/player/index.html`) running in an `roHtmlWidget`.
It already runs unmodified on real hardware — a Series 5 (HD1026, BOS 9.1, Chromium 120) played
4,723 items over 12.4h averaging 9.4s against a 10s slot. So the port is not "can it run". It is
the four things a page cannot do for itself.

```
  autorun.brs      the host: owns the widget, identity, outputs, recovery
      | @brightsign/messageport  (bidirectional)
  st-bridge.js     the page's half of the same contract
      |
  server/player/index.html        the unmodified player
```

## Files

| file | role |
|---|---|
| `autorun.brs` | BrightScript host. Builds the widget, supervises it, persists identity, drives a second output, executes what the page cannot. |
| `st-bridge.js` | Loaded by the player on this platform. Registry identity, restart-instead-of-reload, heartbeat, sync-backend reporting. Degrades to no-ops everywhere else, so it is safe to load unconditionally. |
| `st-sync.js` | Native SyncManager adapter. Inert without the platform module, so the player falls back to its own group sync. |
| `probe.html` | The original capability probe. Still useful on a new model/OS build. |
| `offline.html` | Local fallback page — names the server, keeps probing it, and asks the host to restart the player the moment it answers. |

## The four things the host exists for

**1. It owns the widget lifecycle.** A page-initiated `location.reload()` does not reliably bring
an `roHtmlWidget` back. On 2026-07-28 a ScreenTinker deploy reloaded every connected player;
the BrightSign was the only one that never returned, and a browser on the same deploy reloaded and
was heartbeating minutes later. So the page never reloads itself here — it posts
`{type:"restart"}` and the host tears the widget down and builds a new one. Without this, every
deploy silently darkens every BrightSign panel until someone power-cycles it.

**2. It recovers.** `load-error` retries with backoff (5s → 15s → 30s → 60s) and after three
failures falls back to a local page, so a dead server shows something truthful instead of white.
On top of that, a watchdog: the page beats every 30s and three missed beats rebuild the widget.
That covers the case `load-error` never reports — a page that loaded fine and then wedged on a
dead socket, a JS exception, or a stalled decoder.

**3. Identity lives in the registry.** `localStorage` is tied to the page's origin and quota; the
registry survives reboots, content updates and origin changes. The hardware serial is the stable
id, so two panels imaged from the same card never collide — which is exactly how the web player's
hardware-only fingerprint once merged two identical panels into a single device row.

**4. It reaches BrightScript-only capabilities** — video mode, a second output, and native
BrightWall sync — on the page's behalf, over `@brightsign/messageport`.

## What goes on the SD card

```
autorun.brs          the host
offline.html         local fallback, used after three failed loads
screentinker.json    optional — server URL, sync backend, output mode
```

**`st-bridge.js` and `st-sync.js` do NOT go on the card.** The player pulls them from the server
(`/player/st-bridge.js`, `/player/st-sync.js`) so they can never skew from the player that uses
them. A stale copy on a card is precisely the version skew that would leave a panel unable to
restart itself.

## Provisioning

Config resolves `screentinker.json` on the card **>** registry **>** built-in default. The JSON
file is how a batch gets imaged without touching each box:

```json
{ "server_url": "https://screentinker.com", "sync_backend": "auto", "output_mode": "single" }
```

## Dual output

`output_mode` is `single` | `dual` | `clone`.

- **dual** — a second widget loads the same player with `&screen=2`, so the server can hand it its
  own playlist. Two independent displays from one player.
- **clone** — the second widget loads `&screen=1`: the same content on both outputs.

Multi-output models are **XC2055** (dual HDMI), **XC4055** (quad), and **XT245 / XT1145 / XT2145**
(dual HDMI, dual 4K60p simultaneous). Every other model is single-output, so the second widget is
only ever created when the config asks for it — an unsupported model keeps working as a normal
single-screen player rather than failing to start.

## Synchronisation — ours or theirs

Both, chosen per group. `server/lib/sync-backend.js` decides and `resolveSyncBackend()` is pure,
so the decision is tested without a fleet (`server/test/sync-backend.test.js`).

| backend | reach | accuracy |
|---|---|---|
| `screentinker` | Android, web, Tizen, BrightSign — any mix | to the second; clock-derived, no leader, survives a server outage |
| `brightsign` | BrightSign only | frame-accurate (BrightWall) |

`auto` picks native sync when **every** member is a BrightSign and ours otherwise. Explicit
settings are honoured, with one refusal: native sync selected for a group containing a
non-BrightSign display **downgrades and reports why**. A group that half-syncs is worse than one
that syncs to the second everywhere — and the failure would be invisible from the dashboard,
because the BrightSigns would look perfectly synchronised while the odd panel drifted alone.

A player paired before this port is still recognised, by its BrightSign user agent.

## What is NOT done yet

Stated plainly so nobody reads this as finished:

- **No server-side plumbing**: no `sync_backend` column, no dashboard control, nothing sends
  `set-sync-backend` down, and nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields
  the player now reports. The resolver is ready for all of it.
- **Native sync is implemented but not yet driven by the playlist engine.** `st-sync.js` wraps
  SyncManager and is tested (`server/test/brightsign-sync.test.js`), but nothing in the player
  calls `announce()` on item advance or binds `attachVideo()` yet, and no leader is designated.
  That wiring is the next step and wants hardware to validate.

  ```js
  const SyncManager = require('@brightsign/syncmanager');          // BrightSignOS 8.2.10+
  const sync = new SyncManager('', 'ScreenTinkerSync', '224.0.126.10', 1539);
  sync.leader = true;                                              // followers just omit this
  sync.addEventListener('syncevent', (e) => {                      // BOTH roles listen
    if (e.id === lastId) return;                                   // 1Hz rebroadcast — dedupe!
    lastId = e.id;
    video.setSyncParams(e.domain, e.id, e.iso_timestamp);          // extension on <video>
    video.load(); video.play();
  });
  sync.synchronize('item_' + Date.now(), 1000);                    // leader only; msDelay to prep
  ```

  Three properties that shaped the design: it is **leader/follower** where ours is leaderless (and
  the leader starts from its OWN broadcast, or it runs ahead of the group); it synchronises
  **video only**, so images and widgets get item-boundary alignment at best; and it is
  **multicast**, so the whole group must share one L2 network — the resolver now treats differing
  subnets as evidence against it.

  Also: MP4/MOV are fine, MPEG-TS needs its presentation timestamp starting at 0, MPEG-PS is
  unsupported. `synchronize()` rebroadcasts at 1Hz so late-powered players still join, which is
  why the dedupe above is mandatory rather than an optimisation — without it every player reloads
  its video once a second, forever.
- **Addressing a specific HDMI connector from JS is unverified.** `@brightsign/videooutput`
  documents `setMode({width,height,refreshRate})` with no output index. Dual output above assumes
  a second widget maps to the second connector; that needs hardware confirmation.
- **Registry from a remote origin is still unproven** — the original probe question. If injection
  turns out to be origin-dependent, identity moves to a local shim page that owns the registry and
  passes it to the hosted player in an iframe via `postMessage`.
- **Nothing here has run on hardware.** It is written against the BrightDeveloper docs and
  checked line-by-line against the `brightsign/dev-cookbook` examples, which corrected four
  config keys, the registry API and a hard SyncManager requirement (see below).

## Verified against the dev-cookbook

`autorun.brs` and `st-bridge.js` were reviewed against the real examples rather than the prose:

- **`brightsign_js_objects_enabled: true` is required** alongside `nodejs_enabled` for
  `require("@brightsign/*")` (`syncmanager-js/autorun.brs`). Without it the bridge degrades to
  no-ops and the player silently loses identity *and* restart delegation — the failure would look
  like "BrightSign just doesn't work" rather than a missing flag.
- **`storage_path` is a directory name** (`"/cache"`), not a volume, and **`storage_quota` is a
  string** (`indexeddb-caching/autorun.brs`).
- **`security_params: { websecurity: true }`** and `hwz_default: "on"` are the shapes the examples
  use; local URLs carry the volume (`file:/SD:/index.html`).
- **The registry API is asynchronous and section-oriented**: `read(section, key)` returns a
  **Promise** and writes take an object — `write(section, {k: v})`. The bridge prefetches into a
  cache and exposes `onReady()`; the player waits for it before its first connect, because
  registering early would pair the panel as a new display and strand its real row.
- **SyncManager needs `networking/ptp_domain = "0"`, applied by a reboot**
  (`syncmanager-js/autorun.brs`). Done only when this player is configured for native sync, and
  read-before-write so it reboots at most once rather than every boot.
- Confirmed correct as written: `@brightsign/messageport` (`new`, `addEventListener('bsmessage')`,
  `PostBSMessage`), the `roHtmlWidgetEvent` loop, and `RebootSystem()`.
- The notes state a widget URL may be **"an externally hosted page"** with the same access to the
  BrightSign JS APIs, which is the answer the original probe was built to get — still worth
  confirming on hardware, but the documented answer is the favourable one.

## Model notes

Target **Series 5** (Chromium 120) or newer. **Series 4 is pinned to Chromium 87**, and Series 4
and older have fixed graphics/JS memory splits (XTx43/44: 512MB/512MB; HDx23: 256MB/128MB) where
Series 5 allocates dynamically. Image size defaults to 2048x1280x32bpp (3840x2160 on XT/4K models)
and is raised with `roVideoMode.SetImageSizeThreshold()`.
