mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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:
parent
88f2c63229
commit
6f5907a1d4
|
|
@ -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()`.
|
||||
|
|
|
|||
|
|
@ -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
213
brightsign/st-bridge.js
Normal 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);
|
||||
82
server/lib/sync-backend.js
Normal file
82
server/lib/sync-backend.js
Normal 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 };
|
||||
80
server/test/sync-backend.test.js
Normal file
80
server/test/sync-backend.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue