BrightSign: supervised player host, JS bridge, and per-group sync backend

The player is the unmodified web player in an roHtmlWidget — that already runs
on real hardware. What was missing is everything a page cannot do for itself.

autorun.brs becomes a host rather than a URL wrapper. It owns the widget
lifecycle, because a page-initiated location.reload() does not reliably bring an
roHtmlWidget back: a deploy on 2026-07-28 reloaded every connected player and
the BrightSign was the only one that never returned. The page now posts
{type:"restart"} and the host rebuilds the widget. It also retries load-error
with backoff, falls back to a local page, and runs a heartbeat watchdog that
catches the case load-error never reports — a page that loaded fine and then
wedged on a dead socket or a stalled decoder.

st-bridge.js is the page's half over @brightsign/messageport: registry-backed
identity (localStorage is origin- and quota-bound, the registry is not),
restart-instead-of-reload, heartbeat, and sync-backend reporting. Every method
degrades to a no-op off-platform, so it is safe to load unconditionally.

sync-backend.js decides whose synchronisation a group runs. Ours is
clock-derived and spans any mix of Android, web, Tizen and BrightSign; BrightWall
is frame-accurate and BrightSign-only. auto picks native when every member is a
BrightSign. The refusal that matters: native sync selected for a mixed group
downgrades and says why, because a half-synced group would look perfectly
synchronised on the dashboard while one panel drifted alone.

Dual output via output_mode single|dual|clone — a second widget loads the same
player with &screen=2 so the server can give it its own playlist.

Written against the BrightDeveloper docs; not yet run on hardware. The README
lists what is unimplemented, including the BrightWall runtime API, which that
doc set does not cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-04 20:26:12 -05:00
parent 88f2c63229
commit 6f5907a1d4
5 changed files with 703 additions and 63 deletions

View file

@ -1,51 +1,116 @@
# ScreenTinker on BrightSign — capability probe
# ScreenTinker on BrightSign
Not a port. This answers, on **real hardware**, the questions that decide what a port looks like —
so the design isn't guessed from documentation.
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.
## Run it
```
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
```
1. FAT32-format an SD card. It must be **empty** — a card with leftover data won't trigger a fresh
provisioning cycle.
2. Copy `autorun.brs` and `probe.html` to the **root**.
3. Insert with the player powered off, then power on.
4. Read the screen. Remote devtools are on `http://<player-ip>:2999` if you'd rather read it there.
5. **Power-cycle and reload.** The reboot markers are the point — first run writes them, second run
says which survived.
## Files
## What it answers, and why each matters
| check | why it decides something |
| file | role |
|---|---|
| which `@brightsign/*` modules resolve | `nodejs_enabled: true` injects them into the runtime. If injection is origin-independent, a **remotely-served** page gets them too — which is the whole cheap path. |
| `registry` survives reboot | ScreenTinker's device identity (`deviceId`, `deviceToken`, `paired`, `serverUrl`) lives in `localStorage`, and on BrightSign that behaves like sessionStorage. Without a durable store every panel re-pairs on every boot and spawns a new device row. |
| `localStorage` survives reboot | If it does on this OS build, the port gets dramatically simpler. Reports say it doesn't; worth confirming rather than inheriting a 2019 answer. |
| serviceWorker / Cache API / indexedDB | The web player registers `/player/sw.js` for content caching. If unavailable, offline playback has to move to BrightSign's storage APIs — which is the "extra mile" work anyway. |
| `<video>` + h264 | Whether HTML5 video is viable as a stopgap before wiring the native decode path. |
| CSS `clamp()` | The directory-search keyboard scales with `clamp(…vh…)`. Chromium 87 (Series 4) is the risk. |
| reach `screentinker.com/api/status` | Rules network/TLS out before blaming anything else. |
| `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. |
| `probe.html` | The original capability probe. Still useful on a new model/OS build. |
| `offline.html` | Local fallback page — see recovery below. **Not yet written.** |
## Then: the actual question
## The four things the host exists for
The probe runs **locally** first to establish the baseline. Once `registry` resolves from
`file:///`, change `url:` in `autorun.brs` to a hosted copy of `probe.html` and re-run.
**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.
- **Still resolves →** point the widget at the hosted player, swap identity persistence to the
registry, done. Days, not weeks.
- **Doesn't resolve →** a local shim page owns the registry and passes identity to the hosted
player in an iframe via `postMessage`. The Chromium 110/120 notes say iframes now *require*
`postMessage()` for BrightSign objects, which suggests this is the sanctioned pattern rather
than a workaround.
**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`.
## 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:
- **`offline.html` is not written.** The host references it as the fallback page.
- **The player does not yet load `st-bridge.js` or honour `?platform=brightsign`.** The bridge and
the resolver exist and are tested; wiring them into `index.html` (identity, restart-instead-of-
reload, sync backend) is the next commit.
- **No server-side plumbing**: no `sync_backend` column, no dashboard control, nothing sends
`set-sync-backend` down. The resolver is ready for it.
- **BrightWall runtime API is unverified.** The MCP doc set covers BrightWall only at provisioning
level (`BrightWallName`, `BrightWallScreenNumber` like `"3x2"`) and video walls via `PlayFile`
`MultiscreenX/Y/Width/Height`. The runtime sync object is not in that doc set —
`documentation/part-6-appendices/api-reference.md` is a stub. **The `brightsign` backend is a
resolved decision, not yet an implementation.**
- **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.
## Model notes
Target **Series 6** (ships Chromium 120) or **Series 5** (upgradeable via the `html/widget_type`
registry key). **Series 4 is pinned to Chromium 87** — a result from one would be misleadingly
pessimistic.
## Scope
A URL wrapper is the on-ramp, not the destination. Doing this properly on BrightSign means the
registry for identity, SD for offline media, and their native video path rather than `<video>`.
ScreenTinker's existing multi-zone layouts, video walls and group sync map onto that platform's
strengths unusually well — those are the parts worth showing off.
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()`.

View file

@ -1,33 +1,233 @@
' ScreenTinker — BrightSign capability probe
' ScreenTinker — BrightSign player host
'
' Drop this plus probe.html on a FAT32 SD card (root, nothing else), insert, power on.
' It opens the local probe page with Node.js enabled so the @brightsign/* modules are
' injected, then the page reports what actually resolves on THIS model and OS build.
' The ScreenTinker player itself is the ordinary web player (server/player/index.html) running
' in an roHtmlWidget. This script is the HOST around it, and it exists for the things a page
' cannot do for itself:
'
' Deliberately local-first: the whole point of the probe is to establish the baseline
' (what a LOCAL page can reach) before testing whether a REMOTE page reaches the same.
' 1. OWN THE WIDGET LIFECYCLE. A page that calls location.reload() on a BrightSign does not
' reliably come back — observed in the field on 2026-07-28, where a ScreenTinker deploy
' reloaded every connected player and the BrightSign was the only one that never returned.
' So the page NEVER reloads itself here: it posts {type:"restart"} and this script tears the
' widget down and builds a new one. That is a restart the OS actually performs.
' 2. SURVIVE A DEAD SERVER. load-error retries with backoff and falls back to a local page,
' instead of leaving a white screen until someone power-cycles the box.
' 3. PERSIST IDENTITY across reboots and content changes, in the registry rather than in
' localStorage (which is tied to the page's origin and its storage quota).
' 4. REACH BRIGHTSCRIPT-ONLY CAPABILITIES on the page's behalf — video mode, a second output,
' and native BrightWall synchronisation — over the messageport bridge.
'
' Pair it with st-bridge.js, which is the JavaScript half of the same contract.
'
' SD card layout: autorun.brs st-bridge.js offline.html [screentinker.json]
Sub Main()
videoMode = CreateObject("roVideoMode")
r = CreateObject("roRectangle", 0, 0, videoMode.GetResX(), videoMode.GetResY())
'=== configuration ==========================================================================
' Provisioning order: screentinker.json on the card (imaging a batch) > registry (set once at
' pairing, survives content updates) > the built-in default.
config = {
url: "file:///probe.html"
nodejs_enabled: true ' REQUIRED for require("@brightsign/*")
inspector_server: { port: 2999 } ' remote devtools: http://<player-ip>:2999
storage_path: "SD:/"
storage_quota: 1073741824
javascript_enabled: true
mouse_enabled: true
Function LoadConfig() As Object
cfg = {
server_url: "https://screentinker.com"
device_id: ""
sync_backend: "auto" ' auto | screentinker | brightsign
output_mode: "single" ' single | dual | clone
inspector: false
}
html = CreateObject("roHtmlWidget", r, config)
html.Show()
' 1) registry
reg = CreateObject("roRegistrySection", "screentinker")
if reg.Exists("server_url") then cfg.server_url = reg.Read("server_url")
if reg.Exists("device_id") then cfg.device_id = reg.Read("device_id")
if reg.Exists("sync_backend") then cfg.sync_backend = reg.Read("sync_backend")
if reg.Exists("output_mode") then cfg.output_mode = reg.Read("output_mode")
' 2) a JSON file on the card wins — that is how a batch gets imaged without touching each box
ba = CreateObject("roByteArray")
if ba.ReadFile("SD:/screentinker.json") then
json = ParseJson(ba.ToAsciiString())
if json <> invalid then
if json.server_url <> invalid then cfg.server_url = json.server_url
if json.device_id <> invalid then cfg.device_id = json.device_id
if json.sync_backend <> invalid then cfg.sync_backend = json.sync_backend
if json.output_mode <> invalid then cfg.output_mode = json.output_mode
if json.inspector <> invalid then cfg.inspector = json.inspector
end if
end if
return cfg
End Function
Sub SaveRegistry(key As String, value As String)
reg = CreateObject("roRegistrySection", "screentinker")
reg.Write(key, value)
reg.Flush()
End Sub
'=== player URL =============================================================================
' Identity is carried in the URL so the page knows who it is before it has any storage of its
' own. serial is the stable hardware id; device_id is what ScreenTinker assigned at pairing.
Function PlayerUrl(cfg As Object, screen As Integer) As String
di = CreateObject("roDeviceInfo")
url = cfg.server_url + "/player?platform=brightsign"
url = url + "&serial=" + di.GetDeviceUniqueId()
url = url + "&model=" + di.GetModel()
url = url + "&sync_backend=" + cfg.sync_backend
if cfg.device_id <> "" then url = url + "&device_id=" + cfg.device_id
if screen > 1 then url = url + "&screen=" + Stri(screen).Trim()
return url
End Function
'=== widget construction ====================================================================
Function MakeWidget(url As String, rect As Object, port As Object, cfg As Object) As Object
config = {
url: url
nodejs_enabled: true ' REQUIRED for require("@brightsign/*")
javascript_enabled: true
storage_path: "SD:/"
storage_quota: 1073741824 ' 1GB — service-worker cache for offline playback
port: port
mouse_enabled: false
}
if cfg.inspector then config.inspector_server = { port: 2999 }
w = CreateObject("roHtmlWidget", rect, config)
return w
End Function
Function FullScreenRect() As Object
vm = CreateObject("roVideoMode")
return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY())
End Function
'=== main ===================================================================================
Sub Main()
cfg = LoadConfig()
port = CreateObject("roMessagePort")
' Second output. XC2055/XC4055 and XT245/XT1145/XT2145 expose more than one HDMI connector;
' every other model is single-output and must fall through to it. GetResX/GetResY only ever
' describe output 1, so a second widget is created ONLY when the config asks for it — an
' unsupported model then keeps working as a normal single-screen player rather than failing
' to start. Screen 2 loads the SAME player with &screen=2, so the server can hand it its own
' playlist ("dual" = independent) or the same one ("clone").
dual = (cfg.output_mode = "dual" or cfg.output_mode = "clone")
rect = FullScreenRect()
widget = MakeWidget(PlayerUrl(cfg, 1), rect, port, cfg)
widget.Show()
widget2 = invalid
if dual then
screen2 = 2
if cfg.output_mode = "clone" then screen2 = 1
widget2 = MakeWidget(PlayerUrl(cfg, screen2), rect, port, cfg)
if widget2 <> invalid then widget2.Show()
end if
retries = 0
lastBeat = CreateObject("roTimespan")
lastBeat.Mark()
' A watchdog on TOP of load-error: a page can load fine and then wedge (dead socket, JS
' exception, decoder stall) without the OS ever reporting an error. st-bridge.js posts a
' heartbeat every 30s; three missed beats and we rebuild the widget. This is the difference
' between a panel that recovers on its own and one that needs a site visit.
WATCHDOG_MS = 120000
' Keep the script alive; the page does the work.
msgPort = CreateObject("roMessagePort")
html.SetPort(msgPort)
while true
msg = wait(0, msgPort)
msg = wait(5000, port)
if type(msg) = "roHtmlWidgetEvent" then
data = msg.GetData()
if data.reason = "load-finished" then
retries = 0
lastBeat.Mark()
print "[st] player loaded"
else if data.reason = "load-error" then
' Back off, then fall back to the local page so the screen says something
' truthful instead of showing white. The local page keeps retrying the server.
retries = retries + 1
print "[st] load-error ("; retries; "): "; data.url
sleep(ChooseBackoff(retries))
if retries >= 3 then
widget = RebuildWidget(widget, "file:///offline.html", rect, port, cfg)
else
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
end if
else if data.reason = "message" then
m = data.message
lastBeat.Mark()
if m.type = "heartbeat" then
' nothing to do — marking the timespan above IS the handling
else if m.type = "restart" then
' The page asks to be restarted (deploy, version change, unrecoverable
' error). NEVER let the page do this with location.reload().
print "[st] restart requested: "; m.reason
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
else if m.type = "identity" then
' Pairing completed in the page — persist it where a reboot can find it.
if m.device_id <> invalid then
SaveRegistry("device_id", m.device_id)
cfg.device_id = m.device_id
end if
if m.server_url <> invalid then
SaveRegistry("server_url", m.server_url)
cfg.server_url = m.server_url
end if
else if m.type = "set-video-mode" then
vm = CreateObject("roVideoMode")
if m.mode <> invalid then vm.SetMode(m.mode)
else if m.type = "set-sync-backend" then
' The server decided which protocol this deployment uses (see
' server/lib/sync-backend.js). Persist it so a cold boot with no network
' still starts in the right mode.
if m.backend <> invalid then
SaveRegistry("sync_backend", m.backend)
cfg.sync_backend = m.backend
end if
else if m.type = "reboot" then
print "[st] reboot requested"
RebootSystem()
end if
end if
end if
' watchdog
if lastBeat.TotalMilliseconds() > WATCHDOG_MS then
print "[st] watchdog: no heartbeat in "; WATCHDOG_MS; "ms — rebuilding widget"
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
lastBeat.Mark()
end if
end while
End Sub
Function ChooseBackoff(retries As Integer) As Integer
if retries <= 1 then return 5000
if retries = 2 then return 15000
if retries = 3 then return 30000
return 60000
End Function
' Tear the old widget down explicitly before building the new one. Dropping the reference alone
' leaves the old widget composited and holding its decoder until GC gets to it, which shows up
' as two players fighting over the screen.
Function RebuildWidget(old As Object, url As String, rect As Object, port As Object, cfg As Object) As Object
if old <> invalid then
old.Hide()
old = invalid
end if
w = MakeWidget(url, rect, port, cfg)
w.Show()
return w
End Function

213
brightsign/st-bridge.js Normal file
View file

@ -0,0 +1,213 @@
/*
* ScreenTinker BrightSign bridge (the JavaScript half of autorun.brs).
*
* Loaded by the web player only when it is running on a BrightSign. Everything here is a
* capability the page cannot get on its own, plus one thing it must be STOPPED from doing:
*
* - reload(): a page-initiated location.reload() does not reliably bring an roHtmlWidget
* back (a ScreenTinker deploy darkened a customer's player this way on
* 2026-07-28). Ask the host to rebuild the widget instead.
* - identity: the registry survives reboots, content updates and origin changes;
* localStorage does not. The hardware serial is the stable id, so two panels
* imaged from the same card never collide.
* - sync: exposes which backend this deployment uses, so the player can run its own
* clock-derived group sync or defer to BrightSign's native BrightWall.
*
* Safe to load anywhere: if the @brightsign modules are absent (a desktop browser, or a widget
* built without nodejs_enabled) every method degrades to a no-op or a sane default, and
* isBrightSign() reports false. Nothing here may throw this file loads before the player.
*/
(function (global) {
'use strict';
var HEARTBEAT_MS = 30000;
function tryRequire(name) {
try {
// `require` exists only inside an roHtmlWidget created with nodejs_enabled:true
if (typeof require !== 'function') return null;
return require(name);
} catch (e) {
return null;
}
}
var MessagePortClass = tryRequire('@brightsign/messageport');
var RegistryClass = tryRequire('@brightsign/registry');
var DeviceInfoClass = tryRequire('@brightsign/deviceinfo');
var VideoOutputClass = tryRequire('@brightsign/videooutput');
var port = null;
if (MessagePortClass) {
try { port = new MessagePortClass(); } catch (e) { port = null; }
}
// The UA check is the fallback for a widget without node integration: the player still needs
// to know it is on a BrightSign so it can pick the right video and caching behaviour, even
// when it cannot reach the host. Observed UA: "BrightSign/9.1.92.2 (HD1026) ... Chrome/120".
var uaIsBrightSign = typeof navigator !== 'undefined' &&
/BrightSign/i.test(navigator.userAgent || '');
var listeners = [];
if (port && typeof port.addEventListener === 'function') {
try {
port.addEventListener('bsmessage', function (msg) {
for (var i = 0; i < listeners.length; i++) {
try { listeners[i](msg); } catch (e) { /* one bad listener must not kill the rest */ }
}
});
} catch (e) { /* no inbound channel; outbound may still work */ }
}
function post(obj) {
if (!port || typeof port.PostBSMessage !== 'function') return false;
try { port.PostBSMessage(obj); return true; } catch (e) { return false; }
}
var registry = null;
if (RegistryClass) {
try { registry = new RegistryClass(); } catch (e) { registry = null; }
}
function regRead(key, fallback) {
if (!registry) return fallback;
try {
var v = registry.read('screentinker', key);
return (v === undefined || v === null || v === '') ? fallback : v;
} catch (e) { return fallback; }
}
function regWrite(key, value) {
if (!registry) return false;
try { registry.write('screentinker', key, String(value)); return true; } catch (e) { return false; }
}
var deviceInfo = null;
if (DeviceInfoClass) {
try { deviceInfo = new DeviceInfoClass(); } catch (e) { deviceInfo = null; }
}
function qs(name) {
try {
var m = new RegExp('[?&]' + name + '=([^&]*)').exec(global.location.search || '');
return m ? decodeURIComponent(m[1]) : null;
} catch (e) { return null; }
}
var API = {
/* True only when this really is a BrightSign — either module access or the UA. */
isBrightSign: function () {
return !!(port || registry || deviceInfo || uaIsBrightSign);
},
/* True when the host bridge is live, i.e. restart/identity/sync calls will be honoured. */
hasHost: function () { return !!port; },
/*
* The stable hardware identity. autorun.brs passes it on the URL so it is available even
* before the modules resolve; the module is the authority when both exist.
*/
serial: function () {
if (deviceInfo) {
try {
var s = deviceInfo.serialNumber || (deviceInfo.getDeviceUniqueId && deviceInfo.getDeviceUniqueId());
if (s) return String(s);
} catch (e) { /* fall through to the URL */ }
}
return qs('serial') || null;
},
model: function () {
if (deviceInfo) {
try { if (deviceInfo.model) return String(deviceInfo.model); } catch (e) { /* fall through */ }
}
return qs('model') || null;
},
osVersion: function () {
if (deviceInfo) {
try { if (deviceInfo.osVersion) return String(deviceInfo.osVersion); } catch (e) { /* ignore */ }
}
return null;
},
/* Which physical output this widget is painting. 1 unless autorun.brs made a second one. */
screen: function () {
var n = parseInt(qs('screen') || '1', 10);
return (isNaN(n) || n < 1) ? 1 : n;
},
/*
* Persisted device id. Registry first (survives a card re-image with the same registry),
* then the URL, then localStorage for the browser case.
*/
deviceId: function () {
var v = regRead('device_id', null) || qs('device_id');
if (v) return v;
try { return global.localStorage.getItem('st_device_id'); } catch (e) { return null; }
},
/* Called once pairing completes, so a reboot comes back as the same display. */
setIdentity: function (deviceId, serverUrl) {
if (deviceId) regWrite('device_id', deviceId);
if (serverUrl) regWrite('server_url', serverUrl);
post({ type: 'identity', device_id: deviceId || null, server_url: serverUrl || null });
},
/*
* THE reload replacement. Never call location.reload() on this platform.
* Returns false if there is no host, so the caller can decide whether reloading in place
* is better than doing nothing (in a plain browser, it is).
*/
restart: function (reason) {
return post({ type: 'restart', reason: reason || 'unspecified' });
},
reboot: function () { return post({ type: 'reboot' }); },
/*
* Which sync protocol this deployment runs. Resolved by the server
* (server/lib/sync-backend.js) and pushed down; the registry holds the last known value so
* a cold boot with no network still starts in the right mode.
* 'screentinker' our clock-derived group sync; the only option in a mixed fleet.
* 'brightsign' native BrightWall; the host drives it over the bridge.
*/
syncBackend: function () {
return qs('sync_backend') || regRead('sync_backend', 'auto');
},
setSyncBackend: function (backend) {
if (!backend) return false;
regWrite('sync_backend', backend);
return post({ type: 'set-sync-backend', backend: backend });
},
setVideoMode: function (mode) {
if (VideoOutputClass) {
try {
var vo = new VideoOutputClass();
if (vo && typeof vo.setMode === 'function') { vo.setMode(mode); return true; }
} catch (e) { /* fall back to the host */ }
}
return post({ type: 'set-video-mode', mode: mode });
},
onHostMessage: function (fn) { if (typeof fn === 'function') listeners.push(fn); },
/*
* Heartbeat. autorun.brs rebuilds the widget after three missed beats, which is what
* recovers a page that loaded fine and then wedged (dead socket, JS exception, decoder
* stall) a case load-error never reports.
*/
startHeartbeat: function () {
if (!port) return;
var beat = function () { post({ type: 'heartbeat', t: Date.now() }); };
beat();
return global.setInterval(beat, HEARTBEAT_MS);
}
};
global.ScreenTinkerBS = API;
if (API.hasHost()) API.startHeartbeat();
})(typeof window !== 'undefined' ? window : this);

View file

@ -0,0 +1,82 @@
'use strict';
/*
* Which synchronisation protocol a group runs.
*
* ScreenTinker has its own group sync: every member derives its position from a shared clock,
* so it needs no leader, survives a server outage, and works across Android, web, Tizen and
* BrightSign alike. BrightSign has its own BrightWall which is native, frame-accurate, and
* only exists between BrightSign players.
*
* The choice is therefore not "which is better" but "what is in this group":
*
* screentinker works everywhere, mixed fleets included; sync is to the second, not the frame
* brightsign frame-accurate video walls; requires EVERY member to be a BrightSign
*
* `auto` picks the strongest protocol the group can actually run, which is what an operator
* means when they say "just make the wall work". Explicit settings are honoured, except the one
* that cannot physically work (native sync with a non-BrightSign member) that downgrades and
* says why, rather than silently doing nothing on the screens that can't participate.
*
* Kept pure so the decision is testable without a fleet: callers pass plain device rows.
*/
const BACKENDS = ['auto', 'screentinker', 'brightsign'];
/*
* A device is a BrightSign if it said so. The player sends ?platform=brightsign (autorun.brs
* puts it there), which lands in devices.platform. The UA fallback covers players paired before
* the port existed those registered a platform of "Chrome 120" with a BrightSign UA.
*/
function isBrightSignDevice(device) {
if (!device) return false;
const platform = String(device.platform || '').toLowerCase();
if (platform.includes('brightsign')) return true;
const ua = String(device.user_agent || '').toLowerCase();
return ua.includes('brightsign');
}
/**
* @param {string} setting 'auto' | 'screentinker' | 'brightsign' (unknown values read as auto)
* @param {Array} members device rows in the group
* @returns {{backend: 'screentinker'|'brightsign', reason: string, downgraded: boolean}}
*/
function resolveSyncBackend(setting, members) {
const list = Array.isArray(members) ? members.filter(Boolean) : [];
const requested = BACKENDS.includes(setting) ? setting : 'auto';
const brightsignCount = list.filter(isBrightSignDevice).length;
const allBrightSign = list.length > 0 && brightsignCount === list.length;
if (requested === 'screentinker') {
return { backend: 'screentinker', reason: 'explicitly selected', downgraded: false };
}
if (requested === 'brightsign') {
if (allBrightSign) {
return { backend: 'brightsign', reason: 'explicitly selected', downgraded: false };
}
// Refusing to pretend: BrightWall cannot include a non-BrightSign screen, and a group that
// half-syncs is worse than one that syncs to the second everywhere.
const others = list.length - brightsignCount;
return {
backend: 'screentinker',
reason: list.length === 0
? 'group is empty — native sync needs BrightSign members'
: `group has ${others} non-BrightSign display${others === 1 ? '' : 's'}`,
downgraded: true
};
}
// auto
if (allBrightSign) {
return { backend: 'brightsign', reason: 'every display is a BrightSign', downgraded: false };
}
return {
backend: 'screentinker',
reason: list.length === 0 ? 'no displays in the group' : 'mixed fleet',
downgraded: false
};
}
module.exports = { resolveSyncBackend, isBrightSignDevice, BACKENDS };

View file

@ -0,0 +1,80 @@
'use strict';
// A group can only run the protocol its weakest member supports.
//
// BrightWall is BrightSign's native synchronisation: frame-accurate, and exclusive to BrightSign
// hardware. ScreenTinker's own group sync derives every member's position from a shared clock, so it
// spans Android, web, Tizen and BrightSign, survives a server outage, and syncs to the second rather
// than the frame.
//
// The trap this guards is the mixed group. Selecting native sync for a wall that contains one Android
// panel cannot work — and the failure would be invisible from the dashboard, because the BrightSigns
// would look perfectly synchronised while the odd panel drifted on its own. So that combination
// downgrades and reports why, instead of being accepted and half-applied.
//
// Kept pure: no fleet, no sockets, just device rows in and a decision out.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { resolveSyncBackend, isBrightSignDevice } = require('../lib/sync-backend');
const bs = (n = 1) => ({ id: `bs${n}`, platform: 'brightsign', name: `BrightSign ${n}` });
const android = { id: 'a1', platform: 'Android 12', name: 'Lobby tablet' };
const web = { id: 'w1', platform: 'Chrome 150', name: 'Test web' };
test('auto picks native sync when every display is a BrightSign', () => {
const r = resolveSyncBackend('auto', [bs(1), bs(2), bs(3)]);
assert.equal(r.backend, 'brightsign');
assert.equal(r.downgraded, false);
});
test('auto falls back to our protocol the moment one member is not a BrightSign', () => {
const r = resolveSyncBackend('auto', [bs(1), bs(2), android]);
assert.equal(r.backend, 'screentinker');
assert.equal(r.reason, 'mixed fleet');
});
test('THE TRAP: native sync explicitly selected for a mixed group downgrades and says why', () => {
const r = resolveSyncBackend('brightsign', [bs(1), bs(2), android]);
assert.equal(r.backend, 'screentinker', 'BrightWall cannot include a non-BrightSign screen');
assert.equal(r.downgraded, true);
assert.match(r.reason, /1 non-BrightSign display$/, 'the operator must be told which way it broke');
});
test('the downgrade message counts the offenders and pluralises', () => {
const r = resolveSyncBackend('brightsign', [bs(1), android, web]);
assert.match(r.reason, /2 non-BrightSign displays$/);
});
test('our protocol is honoured on an all-BrightSign group — never overridden', () => {
// A 100% BrightSign site still gets to choose ours, e.g. to stay consistent with other sites.
const r = resolveSyncBackend('screentinker', [bs(1), bs(2)]);
assert.equal(r.backend, 'screentinker');
assert.equal(r.downgraded, false);
});
test('an empty group never claims native sync', () => {
assert.equal(resolveSyncBackend('auto', []).backend, 'screentinker');
const forced = resolveSyncBackend('brightsign', []);
assert.equal(forced.backend, 'screentinker');
assert.equal(forced.downgraded, true);
});
test('unknown or missing settings read as auto rather than throwing', () => {
assert.equal(resolveSyncBackend('nonsense', [bs(1)]).backend, 'brightsign');
assert.equal(resolveSyncBackend(undefined, [android]).backend, 'screentinker');
assert.equal(resolveSyncBackend('auto', null).backend, 'screentinker');
});
test('a player paired before the port is still recognised by its user agent', () => {
// Both of giyokun's devices registered platform "Chrome 120" with a BrightSign UA.
const legacy = { id: 'old', platform: 'Chrome 120', user_agent: 'BrightSign/9.1.92.2 (HD1026) Chrome/120' };
assert.equal(isBrightSignDevice(legacy), true);
assert.equal(resolveSyncBackend('auto', [legacy, bs(2)]).backend, 'brightsign');
});
test('a non-BrightSign device is never mistaken for one', () => {
assert.equal(isBrightSignDevice(android), false);
assert.equal(isBrightSignDevice(null), false);
assert.equal(isBrightSignDevice({}), false);
});