mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
357 lines
20 KiB
Markdown
357 lines
20 KiB
Markdown
# 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`.
|
|
|
|
## Where the files go — card OR internal flash
|
|
|
|
```
|
|
autorun.brs the host
|
|
offline.html local fallback, used after three failed loads
|
|
screentinker.json optional — server URL, sync backend, output mode
|
|
```
|
|
|
|
**A player will boot `autorun.brs` from internal flash, not just from a card.** Confirmed on real
|
|
hardware (XT245, BOS 9.0.189) whose microSD interface is physically dead:
|
|
|
|
```
|
|
Loading 'FLASH:/autorun.brs'
|
|
BSPLAY: https://screentinker.com/player?platform=brightsign&serial=…&model=XT245
|
|
```
|
|
|
|
That matters far beyond one broken unit — it means a player with no card, or a failed card slot,
|
|
is still fully deployable. Push the files over SFTP to `/storage/flash` (user `brightsign`, blank
|
|
password, once SSH is enabled) and reboot.
|
|
|
|
`StorageRoot()` in `autorun.brs` therefore refuses to assume: it probes for `FLASH:/autorun.brs`
|
|
and falls back to `SD:`. Hard-coding `SD:` is exactly the bug that made the first flash boot fail —
|
|
the script loaded and then could not find its own `index.html`.
|
|
|
|
**`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.
|
|
|
|
## autorun.zip — one file instead of four
|
|
|
|
`scripts/build-autorun-zip.sh` packages the host, the fallback page and the config into a single
|
|
`autorun.zip`, attached to every GitHub release:
|
|
|
|
```bash
|
|
scripts/build-autorun-zip.sh --server https://your-server
|
|
```
|
|
|
|
Drop it on the root of a player's storage and power-cycle. `autozip.brs` unpacks it in place,
|
|
renames it `autorun.zip.done` so it never re-extracts, and reboots into the player.
|
|
|
|
Two rules the format imposes, both of which fail silently if broken:
|
|
|
|
- **The archive must expand to files at its ROOT**, with no wrapper directory — a player extracts
|
|
to the storage root, so a nested folder puts `autorun.brs` somewhere the player never looks and
|
|
the card appears to do nothing. The build script zips from *inside* the staging directory and
|
|
then asserts the layout rather than trusting it.
|
|
- **`autorun.brs` must NOT sit next to `autorun.zip`** on the storage root; its presence stops the
|
|
zip being processed at all. It belongs inside the archive.
|
|
|
|
The rename is what makes it idempotent. Without it the player extracts, reboots, extracts, reboots
|
|
— a loop that looks exactly like a hardware fault. An extraction *failure* deliberately does not
|
|
rename, so a truncated copy is retried after someone replaces it rather than skipped forever.
|
|
|
|
Requires BrightSignOS 7.0.60+ (`roUnzip`).
|
|
|
|
## 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.
|
|
|
|
Confirmed multi-output: **XC2055** (dual HDMI) and **XC4055** (quad).
|
|
|
|
⚠️ **Do not trust the series-level spec blurb.** It credits the whole XT5 family — XT245, XT1145,
|
|
XT2145 — with "dual HDMI outputs", but an **XT245 in hand is single-output**; that phrase appears
|
|
to cover HDMI *in* plus *out*. Verify the individual model before enabling `dual`.
|
|
|
|
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.
|
|
|
|
### How the choice reaches a screen
|
|
|
|
`device_groups.sync_backend` (`auto` | `screentinker` | `brightsign`) is the operator's **request**.
|
|
The server resolves it per push through `resolveSyncBackend()` and sends the answer — plus the
|
|
reason and a `downgraded` flag — in the `group_sync` payload, so the players, the dashboard and the
|
|
stored setting can never disagree about which protocol is running.
|
|
|
|
Three things force a fallback to our protocol, and each is reported rather than applied silently:
|
|
|
|
| condition | why native sync cannot run |
|
|
|---|---|
|
|
| any non-BrightSign member | BrightWall cannot include a foreign screen |
|
|
| members on different subnets | it is multicast; it does not cross networks |
|
|
| the elected leader is offline | it is leader/follower — nobody would broadcast |
|
|
|
|
That last one has no equivalent in our protocol, which is leaderless and carries on regardless.
|
|
Leadership uses the existing election (`resolveGroupLeader`): the pinned leader if it is an online
|
|
member on the shared playlist, else the first online member, else the first member by id.
|
|
|
|
**Item selection stays clock-derived under both backends.** Native sync only replaces the
|
|
seek/nudge drift correction, because `setSyncParams` has the video element hold its own alignment —
|
|
and correcting it ourselves would fight the platform. That also keeps images and widgets, which have
|
|
no `setSyncParams`, advancing with the videos instead of drifting off on their own.
|
|
|
|
## Command parity
|
|
|
|
The web player handles four of the ~20 fleet commands — `launch`, `refresh`, `screen_on`,
|
|
`screen_off` — because a browser tab genuinely cannot do more. A BrightSign can, through the host
|
|
and the platform APIs:
|
|
|
|
| command | web player | BrightSign |
|
|
|---|---|---|
|
|
| `screen_on` / `screen_off` | black overlay; panel stays lit | **CEC** Image View On / Standby — the display actually sleeps |
|
|
| `reboot` | ignored | **real reboot** via `RebootSystem` in the host |
|
|
| `set_volume` | — | applied to current and future media |
|
|
| `refresh` | `location.reload()` | widget rebuilt by the host (reload is unreliable here) |
|
|
|
|
### ⚠️ Nothing in the DOM can cover video
|
|
|
|
With `hwz_default: "on"` the widget decodes video onto a **hardware plane**, and the graphics plane
|
|
— everything in the DOM — sits behind it. Blanking the screen took three attempts on real hardware,
|
|
and each failure taught the same lesson from a different angle:
|
|
|
|
1. **Black overlay** → the video played straight *through* it. A `z-index: 9999` div cannot cover a
|
|
hardware plane.
|
|
2. **Pause + hide the element** → playback stopped, but the **last decoded frame stayed on screen**.
|
|
Hiding a DOM element does nothing to the plane; the plane is not part of the DOM.
|
|
3. **Pause + `removeAttribute('src')` + `load()`** → releases the plane. Black at last.
|
|
|
|
Coming back out re-mounts through `nextItem()`, because a torn-down element cannot be resurrected.
|
|
The playlist keeps advancing while the screen is off, so each newly started item is torn down too,
|
|
caught on the `play` event in the capture phase — otherwise the next video lights the panel back up.
|
|
|
|
Any feature that assumes an overlay can hide video needs rethinking here: screen blanking, masking,
|
|
fades over video.
|
|
|
|
`displayPower()` (CEC) is best effort and deliberately **not** load-bearing — it returns false when
|
|
CEC is unavailable and the media teardown does the real work. Our XT245 reports
|
|
`failed to get cec clock` in the kernel log and does not respond to CEC at all, which is exactly why
|
|
blanking must not depend on it. Plenty of displays ignore broadcast CEC or need direct addressing. Volume is re-applied on every `play` event in the capture phase, because
|
|
media elements are created per item across several code paths and setting it once would otherwise
|
|
last only until the playlist advanced.
|
|
|
|
Still Android-only, and correctly inert here: the Tier-2 device-owner commands (`kiosk_lock`,
|
|
`install_apk`, `shell`, `block_uninstall`, …) and `set_brightness` / `set_screen_timeout`, which
|
|
have no BrightSign equivalent — a signage player has no per-window brightness or screen timeout.
|
|
|
|
## Offline playback
|
|
|
|
Content bytes are cached by the service worker (`server/player/sw.js`) into a dedicated
|
|
`rd-content-v1` cache, so a player that loses its server keeps playing its playlist.
|
|
|
|
This used to be left to the browser's HTTP cache — the server sends
|
|
`Cache-Control: public, max-age=2592000, immutable`. That is fine on a desktop and is **not a
|
|
documented-persistent store here**: BrightSign guarantees survival across reloads, app restarts and
|
|
reboots for **IndexedDB, localStorage and SQLite**, and their own answer for offline video is to
|
|
cache the bytes explicitly. A panel could come back from a power cut with its playlist intact (that
|
|
lives in `localStorage`) and no media to play.
|
|
|
|
The reason content was skipped originally is real, and `server/lib/player-cache-policy.js` is what
|
|
makes intercepting it safe. Video elements issue **range requests** when they seek, and naive
|
|
caching breaks playback in two ways that are worse than not caching at all:
|
|
|
|
- storing a `206` as if it were the whole file — every later full request gets a fragment, and it
|
|
stays broken until eviction
|
|
- answering a range request with a `200` — some media stacks treat the mismatch as fatal and the
|
|
video never starts
|
|
|
|
So only complete `200`s are ever stored, and a range request is served by slicing the stored body
|
|
into a correct `206`. The content cache is deliberately **not** dropped when the shell is
|
|
re-versioned, or every deploy would re-download the whole playlist over a link that may be exactly
|
|
what is broken.
|
|
|
|
## Self-update
|
|
|
|
The player can replace its own host package. This is the most dangerous thing it does: a truncated
|
|
or half-applied `autorun.brs` is a dark panel and a site visit, because there is no app underneath.
|
|
|
|
The safety is the **ordering**, and every step earns its place:
|
|
|
|
1. Download to `autorun.zip.part` — never straight to `autorun.zip`. A file still downloading must
|
|
never be a candidate for extraction.
|
|
2. Verify **sha256 and size** before promoting. A captive portal answering with a login page
|
|
produces a perfectly well-formed small file; the size floor catches that, the hash catches the
|
|
rest. sha256 specifically, because that is what BrightScript's `roMessageDigest` can compute —
|
|
a checksum the player cannot verify is an unverifiable package.
|
|
3. Promote: delete the `.done` marker **first**, then rename `.part` → `autorun.zip`, then reboot.
|
|
Marker first is not stylistic — leaving it makes the next boot skip the new archive and the
|
|
update silently never happens.
|
|
4. A failed extract renames the archive to `.bad` rather than retrying. A zip that cannot be
|
|
unpacked will not unpack on the tenth attempt, and retrying every boot is a loop that looks
|
|
exactly like a hardware fault.
|
|
|
|
**The decision is the server's**, in `server/lib/brightsign-update.js` — unit-tested, and the same
|
|
place the prerelease rule lives. The host only executes what it is told; re-implementing the version
|
|
comparison in BrightScript would put the prerelease trap somewhere it cannot be tested.
|
|
|
|
**The version is baked into `autorun.brs`**, stamped at build time by both
|
|
`scripts/build-autorun-zip.sh` and `server/lib/brightsign-package.js`, anchored on the
|
|
`ST_PACKAGE_VERSION` marker. A version record that can disagree with the code actually running is
|
|
the OTA-loop condition by the back door: apply, still report the old version, get offered the same
|
|
package forever.
|
|
|
|
**The manifest and the download come from one buffer**, hashed once. Advertising a version whose
|
|
checksum does not match the bytes served is the same loop from the front door.
|
|
|
|
Config: `self_update` (default **on** — a fleet that cannot be updated remotely needs a van) and
|
|
`allow_prerelease` (default off, mirroring the Android beta channel; an opted-in player also
|
|
*holds* a prerelease of its own core rather than being pulled back to the release).
|
|
|
|
## What is NOT done yet
|
|
|
|
Stated plainly so nobody reads this as finished:
|
|
|
|
- **Nothing consumes the `bs_model` / `bs_serial` / `bs_screen` fields** the player reports. Device
|
|
telemetry (temperature, storage) also has no schema to land in yet.
|
|
- **Native sync is wired but UNPROVEN on hardware.** The player drives it end to end — the leader
|
|
announces on each advance, every member (leader included) binds via `attachVideo()` on a new id,
|
|
and the resolved backend is chosen per group and pushed down. It cannot be verified with one
|
|
player: a single unit is trivially "in sync with itself". **Two BrightSigns on one subnet are
|
|
needed** to confirm frame alignment, that the leader does not run ahead, and that the 1Hz repeat
|
|
causes no visible reload.
|
|
|
|
```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()`.
|