mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
The dashboard offered every control to every display. This makes the
BrightSign player answer for itself, at runtime, rather than from a
per-platform table.
The table cannot work here: the same XT245 supports remote screenshots
with an SSD fitted and not without, because the DWS snapshot endpoint
writes the full-size capture to disk before returning a thumbnail and
answers "No primary storage found" on a flash-booted unit. So the bridge
asks the host.
- autorun.brs gains StorageProbe()/SendProbeResult(): walks SSD:, SD:,
USB1: via roStorageHotplug.GetStorageStatus().mounted and reads real
capacity through roStorageInfo. FLASH: is excluded deliberately — it is
where the player boots from, not a volume the DWS accepts. Neither API
has a JS equivalent, which is why this has to cross the bridge.
- st-bridge.js posts the probe during boot and folds the answer into the
existing readiness gate, with its own 3s timeout so a widget built
without nodejs_enabled still becomes ready. computeCapabilities() then
gates remote.screenshot/remote.stream/system.self_update on a mounted
volume, the lifecycle and display commands on a live host, sync.native
on the module AND OS >= 8.2.10, and display.power on CEC module
presence.
Unknown is treated as NO throughout: an unanswered probe declares
nothing storage-gated. A control that appears once a disk is fitted is
a smaller problem than one that silently fails.
Never declared: kiosk, brightness, screen_timeout, install_apk, shell
(no BrightSign equivalent) and time (BrightScript can, this host does
not implement it — the same lie in the other direction).
- Telemetry now reports the real drive from the probe instead of the
widget's storage_quota, which it had been presenting as if it were the
disk.
Two declarations are knowingly optimistic and documented as such:
transitions/pip composite DOM over a hardware plane and may be invisible
over video (the roVideoMode.SetGraphicsZOrder("front") fix wants a
hardware experiment, not a guess), and display.power rides module
presence on a unit whose kernel logs "failed to get cec clock". Neither
is load-bearing — transitions degrade to a hard cut, blanking works by
tearing the media down.
Tests cover the storage split, the hostless case, the sync floor, the
never-declared set, and that every declared string is in the server's
vocabulary — a typo there would silently disable a control fleet-wide.
738 lines
32 KiB
JavaScript
738 lines
32 KiB
JavaScript
/*
|
|
* 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 CecClass = tryRequire('@brightsign/cec');
|
|
|
|
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 screenNumber() {
|
|
try {
|
|
var m = new RegExp('[?&]screen=([^&]*)').exec(global.location.search || '');
|
|
var n = m ? parseInt(decodeURIComponent(m[1]), 10) : 1;
|
|
return (isNaN(n) || n < 1) ? 1 : n;
|
|
} catch (e) { return 1; }
|
|
}
|
|
|
|
/*
|
|
* Registry keys are namespaced per output. On a dual-output player autorun.brs runs TWO
|
|
* widgets against the same registry, the same SD storage_path and the same origin — so an
|
|
* un-namespaced "device_id" would have both outputs adopt one identity and collapse into a
|
|
* single device row. Screen 1 keeps the bare key so existing single-output panels are
|
|
* unaffected.
|
|
*/
|
|
function key(name) {
|
|
var s = screenNumber();
|
|
return s > 1 ? name + '_s' + s : name;
|
|
}
|
|
|
|
/*
|
|
* The registry API is ASYNCHRONOUS and section-oriented:
|
|
* registry.read(section, key) -> Promise<string>
|
|
* registry.write(section, {k: v}) -> Promise
|
|
* (per @brightsign/registry in the dev-cookbook enable-ldws example and the trace-event docs).
|
|
*
|
|
* The player needs identity synchronously during boot, so the values are prefetched once into
|
|
* a cache and every accessor reads the cache. Callers wait on whenReady() before trusting it.
|
|
* Both shapes are tolerated — a Promise or a bare value — so a firmware that returns
|
|
* synchronously still works rather than caching a Promise object as if it were a device id,
|
|
* which would register a "[object Promise]" display.
|
|
*/
|
|
/*
|
|
* What the HOST told us about the hardware. Empty until the probe answers, and it may never
|
|
* answer — a widget built without nodejs_enabled has no host at all. Every consumer treats
|
|
* absence as "unknown", never as "no".
|
|
*/
|
|
var probe = null;
|
|
|
|
var SECTION = 'screentinker';
|
|
// device_token belongs here as much as device_id: the server authenticates the claim to an
|
|
// existing display with the token, so an id presented without one reads as a NEW display and
|
|
// gets a fresh row. Persisting the id alone looked correct and still spawned a duplicate on
|
|
// every boot — found on hardware, not in a test.
|
|
var CACHED_KEYS = ['device_id', 'device_token', 'server_url', 'sync_backend'];
|
|
var cache = {};
|
|
var ready = false;
|
|
var readyWaiters = [];
|
|
|
|
function markReady() {
|
|
if (ready) return;
|
|
ready = true;
|
|
var waiters = readyWaiters;
|
|
readyWaiters = [];
|
|
for (var i = 0; i < waiters.length; i++) {
|
|
try { waiters[i](); } catch (e) { /* one bad waiter must not block the rest */ }
|
|
}
|
|
}
|
|
|
|
function normalise(v) {
|
|
return (v === undefined || v === null || v === '') ? null : String(v);
|
|
}
|
|
|
|
/*
|
|
* Ask the host what the hardware can do. Folded into the SAME readiness gate as the registry
|
|
* prefetch, because the player declares its capabilities at registration — and registration
|
|
* happens once readiness fires. A probe that resolved afterwards would mean the first
|
|
* registration of every boot carried the wrong capability set, and the dashboard would show
|
|
* controls for a disk that is not there until the display happened to re-register.
|
|
*
|
|
* Never blocks: settle() runs on the answer, and the 5s cap in the boot path fires markReady
|
|
* regardless, so a host that says nothing costs a slower boot rather than a dead player.
|
|
*/
|
|
function probeHost(settle) {
|
|
if (!port) { settle(); return; }
|
|
var answered = false;
|
|
listeners.push(function (msg) {
|
|
if (answered || !msg || msg.type !== 'probe-result') return;
|
|
answered = true;
|
|
probe = msg;
|
|
settle();
|
|
});
|
|
if (!post({ type: 'probe' })) { settle(); return; }
|
|
// Independent of the global cap: if the host is alive but this one message is lost, readiness
|
|
// must not wait the full 5s for it.
|
|
if (global.setTimeout) global.setTimeout(function () {
|
|
if (answered) return;
|
|
answered = true;
|
|
settle();
|
|
}, 3000);
|
|
}
|
|
|
|
function prefetch() {
|
|
// The probe still runs without a registry: a widget can have a host bridge and no registry
|
|
// module, and the capability set matters more than the identity cache in that case.
|
|
var pending = (registry ? CACHED_KEYS.length : 0) + 1; // +1 = the host probe
|
|
var settle = function () { if (--pending <= 0) markReady(); };
|
|
|
|
probeHost(settle);
|
|
if (!registry) return;
|
|
|
|
for (var i = 0; i < CACHED_KEYS.length; i++) {
|
|
(function (name) {
|
|
var result;
|
|
try { result = registry.read(SECTION, key(name)); } catch (e) { settle(); return; }
|
|
if (result && typeof result.then === 'function') {
|
|
result.then(
|
|
function (v) { cache[name] = normalise(v); settle(); },
|
|
function () { settle(); }
|
|
);
|
|
} else {
|
|
cache[name] = normalise(result);
|
|
settle();
|
|
}
|
|
})(CACHED_KEYS[i]);
|
|
}
|
|
}
|
|
|
|
function regGet(name, fallback) {
|
|
var v = cache[name];
|
|
return (v === undefined || v === null) ? fallback : v;
|
|
}
|
|
|
|
/* values: { device_id: 'x', ... } using UNPREFIXED names; the screen suffix is applied here. */
|
|
function regSet(values) {
|
|
var payload = {};
|
|
for (var name in values) {
|
|
if (!Object.prototype.hasOwnProperty.call(values, name)) continue;
|
|
var v = values[name];
|
|
payload[key(name)] = v === null || v === undefined ? '' : String(v);
|
|
cache[name] = normalise(v);
|
|
}
|
|
if (!registry) return false;
|
|
try {
|
|
var r = registry.write(SECTION, payload);
|
|
// A rejected write must not surface as an unhandled rejection on a signage player.
|
|
if (r && typeof r.catch === 'function') r.catch(function () {});
|
|
return true;
|
|
} catch (e) { return false; }
|
|
}
|
|
|
|
var cec = null;
|
|
var cecTried = false;
|
|
|
|
function getCec() {
|
|
if (cecTried) return cec;
|
|
cecTried = true;
|
|
if (!CecClass) return null;
|
|
try {
|
|
// Connector names are HDMI-1..HDMI-4. Screen 2 lives on the second connector, so a
|
|
// dual-output player powers the display it actually paints rather than always output 1.
|
|
cec = new CecClass('HDMI-' + screenNumber());
|
|
} catch (e) { cec = null; }
|
|
return cec;
|
|
}
|
|
|
|
// Telemetry cache. Starts EMPTY rather than pre-filled with nulls: the player spreads this over
|
|
// its own telemetry object, and a null here would overwrite a value another player family had
|
|
// legitimately supplied. Absent means "nothing to say", which is not the same as "zero".
|
|
var telemetry = {};
|
|
var TELEMETRY_REFRESH_MS = 60000;
|
|
|
|
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; }
|
|
}
|
|
|
|
/*
|
|
* Compare dotted versions. Returns -1/0/1. Missing or unparseable reads as OLDEST, so a feature
|
|
* with a firmware floor is withheld when we cannot prove the floor is met — the safe direction
|
|
* for a capability declaration.
|
|
*/
|
|
function compareVersions(a, b) {
|
|
var pa = String(a || '').split('.');
|
|
var pb = String(b || '').split('.');
|
|
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
var na = parseInt(pa[i], 10); if (isNaN(na)) na = -1;
|
|
var nb = parseInt(pb[i], 10); if (isNaN(nb)) nb = -1;
|
|
if (na > nb) return 1;
|
|
if (na < nb) return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// SyncManager is documented from BrightSignOS 8.2.10. Below it the module may resolve and do
|
|
// nothing, which is the worst outcome for a video wall: every panel reports healthy and drifts.
|
|
var SYNCMANAGER_MIN_OS = '8.2.10';
|
|
|
|
/*
|
|
* WHAT THIS PLAYER CAN ACTUALLY DO — computed, never assumed.
|
|
*
|
|
* Declared to the server at registration and used by the dashboard to decide which controls to
|
|
* offer. The whole point is that a static per-platform table cannot know any of this: the same
|
|
* XT245 supports remote.screenshot with an SSD fitted and not without, and native sync only
|
|
* above a firmware floor.
|
|
*
|
|
* The bias is deliberate. A capability is declared only when the thing it gates will actually
|
|
* work; anything uncertain is withheld. A control that appears later, when a disk is fitted, is
|
|
* a far smaller problem than a button that silently does nothing — which is the bug this whole
|
|
* mechanism exists to remove.
|
|
*/
|
|
function computeCapabilities() {
|
|
var caps = [];
|
|
var add = function (c) { caps.push(c); };
|
|
|
|
// ---- always true on this platform -----------------------------------------------------
|
|
// The player IS the web player; these are properties of the renderer, not of the hardware.
|
|
add('playback.video'); add('playback.image'); add('playback.widget'); add('playback.youtube');
|
|
add('playback.zones');
|
|
add('audio.mute'); add('audio.volume');
|
|
add('sync.clock'); // clock-derived group sync is pure JS and needs nothing
|
|
add('remote.input'); // synthesised DOM events; needs no host and no mouse_enabled
|
|
|
|
/*
|
|
* ⚠️ Both of these composite DOM content over video, and with hwz the video is on a hardware
|
|
* plane the DOM sits behind. They work over images and widgets and may be INVISIBLE over
|
|
* video. Declared anyway because the failure is benign — a transition degrades to a hard cut,
|
|
* which the engine already does on any failure — and withholding them would remove a feature
|
|
* that genuinely works for the non-video majority of content.
|
|
*
|
|
* The likely fix is roVideoMode.SetGraphicsZOrder("front"), deliberately NOT applied here:
|
|
* changing the z-order blind risks hiding video entirely on a player that currently works.
|
|
* See the README — it wants a hardware experiment, not a guess.
|
|
*/
|
|
add('playback.transitions'); add('playback.pip');
|
|
|
|
// Service-worker content caching. The quota is configured in autorun.brs (storage_path +
|
|
// storage_quota); without a service worker there is no offline story at all.
|
|
try {
|
|
if (global.navigator && global.navigator.serviceWorker) add('offline.cache');
|
|
} catch (e) { /* no SW in this widget */ }
|
|
|
|
// ---- needs the host bridge --------------------------------------------------------------
|
|
// Each of these is a BrightScript call. Without a host the page can only reload itself, and a
|
|
// page-initiated reload does not reliably bring an roHtmlWidget back — the failure that
|
|
// darkened a customer's panel on 2026-07-28. So none of them are declared without one.
|
|
if (port) {
|
|
add('system.restart_player'); // host rebuilds the widget
|
|
add('system.reboot'); // RebootSystem
|
|
add('display.rotation'); // roVideoMode transform — the ONLY way video rotates here
|
|
add('display.resolution'); // roVideoMode SetMode
|
|
} else if (VideoOutputClass) {
|
|
// No host, but the JS video-output module resolved: resolution alone is still reachable.
|
|
add('display.resolution');
|
|
}
|
|
|
|
/*
|
|
* Storage-gated. The DWS snapshot endpoint writes the full-size capture to disk before
|
|
* returning a thumbnail, so with no card or SSD it answers "No primary storage found" —
|
|
* verified on our XT245, which boots from internal flash and is refused. Self-update needs a
|
|
* volume to stage autorun.zip onto for the same reason.
|
|
*
|
|
* Unknown (no probe answer) is treated as NO. Claiming a disk we could not confirm is exactly
|
|
* the button-that-does-nothing case.
|
|
*/
|
|
if (port && probe && probe.storage_present) {
|
|
add('remote.screenshot');
|
|
add('remote.stream');
|
|
add('system.self_update');
|
|
}
|
|
|
|
/*
|
|
* CEC. Module presence is a weak signal and we know it: our XT245 resolves @brightsign/cec
|
|
* perfectly while the kernel logs "failed to get cec clock" and the display never responds.
|
|
* There is no reliable way to distinguish "sent" from "received" without a cooperating
|
|
* display, so this is declared on module presence and the README states the limitation.
|
|
*
|
|
* Blanking does NOT depend on this — the player tears the media down, which is what actually
|
|
* works — so a display that ignores CEC still goes dark.
|
|
*/
|
|
if (CecClass) add('display.power');
|
|
|
|
/*
|
|
* Native sync needs the module AND the firmware floor. Below 8.2.10 the module may exist and
|
|
* silently do nothing, which on a video wall means every panel reports healthy while drifting
|
|
* apart — strictly worse than falling back to our own clock-derived protocol.
|
|
*/
|
|
var osVer = probe && probe.os_version ? probe.os_version : null;
|
|
if (!osVer && deviceInfo) {
|
|
try { osVer = deviceInfo.osVersion ? String(deviceInfo.osVersion) : null; } catch (e) { osVer = null; }
|
|
}
|
|
var syncManagerPresent = !!tryRequire('@brightsign/syncmanager');
|
|
if (syncManagerPresent && osVer && compareVersions(osVer, SYNCMANAGER_MIN_OS) >= 0) {
|
|
add('sync.native');
|
|
}
|
|
|
|
/*
|
|
* NEVER declared, because BrightSign has no equivalent — this is the half of parity that is
|
|
* about removing controls rather than adding features:
|
|
*
|
|
* system.kiosk there is no lock-task or device-owner concept; the player is the
|
|
* only application on the box, so "kiosk" is not a mode to enter
|
|
* system.brightness no per-window or system brightness control
|
|
* system.screen_timeout no OS screen timeout; blanking is scheduled content, not a setting
|
|
* system.install_apk not Android
|
|
* system.shell no remote shell exposed to the player
|
|
* system.time BrightScript CAN set time and timezone, but this host does not
|
|
* implement it — declaring an unimplemented capability is the same
|
|
* lie in the opposite direction
|
|
*/
|
|
return caps;
|
|
}
|
|
|
|
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: screenNumber,
|
|
|
|
/*
|
|
* Suffix callers should append to any per-display storage key. Two widgets on one player
|
|
* share an origin and therefore share localStorage, so the config, playlist cache and
|
|
* install salt all need separating or the second output silently becomes the first.
|
|
*/
|
|
storageSuffix: function () {
|
|
var s = screenNumber();
|
|
return s > 1 ? '_s' + s : '';
|
|
},
|
|
|
|
/*
|
|
* 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 = regGet('device_id', null) || qs('device_id');
|
|
if (v) return v;
|
|
try { return global.localStorage.getItem('st_device_id'); } catch (e) { return null; }
|
|
},
|
|
|
|
/* The credential that proves this player IS that display. Useless without deviceId, and
|
|
deviceId is useless without it. */
|
|
deviceToken: function () { return regGet('device_token', null); },
|
|
|
|
/* Called once pairing completes, so a reboot comes back as the same display. */
|
|
setIdentity: function (deviceId, serverUrl, deviceToken) {
|
|
var values = {};
|
|
if (deviceId) values.device_id = deviceId;
|
|
if (serverUrl) values.server_url = serverUrl;
|
|
if (deviceToken) values.device_token = deviceToken;
|
|
regSet(values);
|
|
post({ type: 'identity', device_id: deviceId || null, server_url: serverUrl || null });
|
|
},
|
|
|
|
/*
|
|
* Forget this display. Required for the operator reset to mean anything: the registry
|
|
* outlives localStorage, so clearing local storage alone would leave the panel re-adopting
|
|
* the same identity on its next boot — a reset that resets nothing.
|
|
*/
|
|
clearIdentity: function () {
|
|
regSet({ device_id: '', device_token: '' });
|
|
return post({ type: 'identity', clear: true });
|
|
},
|
|
|
|
/*
|
|
* 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') || regGet('sync_backend', 'auto');
|
|
},
|
|
|
|
setSyncBackend: function (backend) {
|
|
if (!backend) return false;
|
|
regSet({ sync_backend: backend });
|
|
return post({ type: 'set-sync-backend', backend: backend });
|
|
},
|
|
|
|
/*
|
|
* Identity readiness. The registry is async, so a caller that registers with the server
|
|
* before this resolves would pair as a NEW display and leave a duplicate row behind. The
|
|
* callback always runs — on success, on failure, or off-platform — so nothing can hang the
|
|
* player waiting for hardware that isn't there.
|
|
*/
|
|
isReady: function () { return ready; },
|
|
|
|
onReady: function (fn) {
|
|
if (typeof fn !== 'function') return;
|
|
if (ready) { try { fn(); } catch (e) { /* ignore */ } return; }
|
|
readyWaiters.push(fn);
|
|
},
|
|
|
|
/*
|
|
* Real display power over CEC, which is the difference between a signage player and a browser
|
|
* tab: the web player can only paint the screen black, leaving the panel lit, drawing power
|
|
* and burning in. This actually tells the display to sleep.
|
|
*
|
|
* on = Image View On (0x0D)
|
|
* off = Standby (0x36)
|
|
*
|
|
* 0x4f is a broadcast header. Returns false when CEC is unavailable so the caller still
|
|
* applies the black overlay and something visible happens either way. Some displays ignore
|
|
* broadcast and need direct addressing — hence "best effort", not "guaranteed".
|
|
*/
|
|
displayPower: function (on) {
|
|
var c = getCec();
|
|
if (!c || typeof c.send !== 'function') return false;
|
|
try {
|
|
var packet = new Uint8Array(2);
|
|
packet[0] = 0x4f;
|
|
packet[1] = on ? 0x0d : 0x36;
|
|
var r = c.send(Array.prototype.slice.call(packet));
|
|
if (r && typeof r.catch === 'function') r.catch(function () {});
|
|
return true;
|
|
} catch (e) { return false; }
|
|
},
|
|
|
|
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 });
|
|
},
|
|
|
|
/*
|
|
* Ask the HOST to capture what is actually on screen, and resolve with a data URL.
|
|
*
|
|
* This exists because an in-page capture cannot work here: with hwz enabled the video decodes
|
|
* onto a hardware plane the DOM cannot read, so drawImage() returns a transparent frame and
|
|
* throws nothing — a screenshot that reports success and shows a dead screen. The host uses
|
|
* the player's own DWS, which captures the real framebuffer including video.
|
|
*
|
|
* Rejects rather than hanging: without a host, or if the player has no primary storage (the
|
|
* DWS writes the full capture to disk before returning a thumbnail), the caller gets a reason
|
|
* it can show instead of a spinner that never resolves.
|
|
*/
|
|
requestSnapshot: function (opts) {
|
|
var o = opts || {};
|
|
return new Promise(function (resolve, reject) {
|
|
if (!port) { reject(new Error('no host bridge')); return; }
|
|
|
|
var settled = false;
|
|
var timer = global.setTimeout(function () {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(new Error('host did not answer in time'));
|
|
}, o.timeoutMs || 15000);
|
|
|
|
listeners.push(function handler(msg) {
|
|
if (settled || !msg || msg.type !== 'snapshot-result') return;
|
|
settled = true;
|
|
try { global.clearTimeout(timer); } catch (e) { /* ignore */ }
|
|
if (msg.ok && msg.image) resolve(msg.image);
|
|
else reject(new Error(msg.error || 'snapshot failed'));
|
|
});
|
|
|
|
post({ type: 'snapshot', width: o.width || 640, height: o.height || 360 });
|
|
});
|
|
},
|
|
|
|
/*
|
|
* Rotate the physical output. Resolves true when the host rotated the screen itself, which is
|
|
* the only way video rotates on this platform: a CSS transform cannot touch the hardware plane
|
|
* the video decodes onto, so it would turn the images and widgets and leave the video alone.
|
|
*
|
|
* Resolves FALSE rather than rejecting when the host cannot do it — the caller then applies its
|
|
* CSS transform, which rotates most of the content instead of none of it.
|
|
*/
|
|
setOrientation: function (orientation, timeoutMs) {
|
|
var self = this;
|
|
return new Promise(function (resolve) {
|
|
if (!port) { resolve(false); return; }
|
|
var settled = false;
|
|
var timer = global.setTimeout(function () {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(false);
|
|
}, timeoutMs || 8000);
|
|
|
|
listeners.push(function (msg) {
|
|
if (settled || !msg || msg.type !== 'orientation-result') return;
|
|
settled = true;
|
|
try { global.clearTimeout(timer); } catch (e) { /* ignore */ }
|
|
resolve(!!msg.ok);
|
|
});
|
|
|
|
post({ type: 'set-orientation', orientation: orientation });
|
|
});
|
|
},
|
|
|
|
/*
|
|
* The capability list to send at registration.
|
|
*
|
|
* Call AFTER onReady() — the host probe resolves inside the same readiness gate, and calling
|
|
* earlier returns a set computed without it, which would under-report a display that does
|
|
* have a disk. Cheap enough to call every registration rather than caching, so a display that
|
|
* gains an SSD declares it at its next reconnect instead of at its next reboot.
|
|
*/
|
|
capabilities: computeCapabilities,
|
|
|
|
/*
|
|
* The raw host probe, for diagnostics. Null until the host answers, and null forever on a
|
|
* widget with no bridge — callers must treat that as "unknown", not as "nothing".
|
|
*/
|
|
hostProbe: function () { return probe; },
|
|
|
|
onHostMessage: function (fn) { if (typeof fn === 'function') listeners.push(fn); },
|
|
|
|
/*
|
|
* Telemetry, read synchronously from a cache.
|
|
*
|
|
* The heartbeat builds its payload synchronously every 15s, but the only real number this
|
|
* platform exposes — temperature — arrives from a PROMISE (deviceInfo.getTemperature()).
|
|
* Awaiting it inside the heartbeat would either block the beat or, worse, serialise a pending
|
|
* Promise into the telemetry object, which is exactly how device_id once became
|
|
* "[object Promise]". So the values are refreshed on a timer and the beat reads whatever
|
|
* landed last.
|
|
*
|
|
* Returns an EMPTY object off-platform, so the caller can spread it unconditionally and a
|
|
* browser's telemetry is unchanged.
|
|
*/
|
|
telemetrySnapshot: function () { return telemetry; },
|
|
|
|
/*
|
|
* Refresh the cache. Safe to call repeatedly; each source fails independently so one missing
|
|
* API cannot take the others down with it.
|
|
*/
|
|
refreshTelemetry: function () {
|
|
// Temperature: documented on @brightsign/deviceinfo, resolves { celsius }.
|
|
if (deviceInfo && typeof deviceInfo.getTemperature === 'function') {
|
|
try {
|
|
var t = deviceInfo.getTemperature();
|
|
if (t && typeof t.then === 'function') {
|
|
t.then(function (v) {
|
|
var c = v && (v.celsius !== undefined ? v.celsius : v.Celsius);
|
|
if (typeof c === 'number' && isFinite(c)) telemetry.temperature_c = Math.round(c * 10) / 10;
|
|
}, function () { /* sensor unavailable on this model */ });
|
|
}
|
|
} catch (e) { /* older OS without the call */ }
|
|
}
|
|
|
|
/*
|
|
* REAL device storage, when the host could see a volume.
|
|
*
|
|
* There is no JavaScript API for this — @brightsign/storage formats and ejects but does not
|
|
* enumerate — which is why this previously reported the widget's cache quota instead. The
|
|
* host has roStorageInfo and answers with the actual free/total of the mounted volume, so
|
|
* "storage" in the dashboard now means the disk rather than a browser budget.
|
|
*
|
|
* Set BEFORE the quota estimate below so the real numbers win: the estimate only fills in
|
|
* when the host had nothing to report.
|
|
*/
|
|
if (probe && probe.storage_present) {
|
|
var total = Number(probe.storage_total_mb);
|
|
var free = Number(probe.storage_free_mb);
|
|
if (isFinite(total) && total > 0) telemetry.storage_total_mb = Math.round(total);
|
|
if (isFinite(free) && free >= 0) telemetry.storage_free_mb = Math.round(free);
|
|
}
|
|
|
|
/*
|
|
* Fallback: the WIDGET'S storage quota (storage_path/storage_quota in autorun.brs), used
|
|
* only when the host reported no volume. It is the budget the player has for cached content
|
|
* and it is what fills up, so it is worth reporting — but it is not the disk, and it must
|
|
* never overwrite a real figure from the host.
|
|
*/
|
|
if (!telemetry.storage_total_mb) {
|
|
try {
|
|
var s = global.navigator && global.navigator.storage;
|
|
if (s && typeof s.estimate === 'function') {
|
|
var e = s.estimate();
|
|
if (e && typeof e.then === 'function') {
|
|
e.then(function (est) {
|
|
if (!est) return;
|
|
// Re-checked inside the callback: a host probe can land while this is in flight,
|
|
// and the disk figure must not be overwritten by the cache budget afterwards.
|
|
if (telemetry.storage_total_mb) return;
|
|
var quota = Number(est.quota), usage = Number(est.usage);
|
|
if (isFinite(quota) && quota > 0) {
|
|
telemetry.storage_total_mb = Math.round(quota / 1048576);
|
|
if (isFinite(usage)) telemetry.storage_free_mb = Math.round((quota - usage) / 1048576);
|
|
}
|
|
}, function () { /* estimate refused */ });
|
|
}
|
|
}
|
|
} catch (e) { /* no storage manager */ }
|
|
}
|
|
},
|
|
|
|
/*
|
|
* 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;
|
|
|
|
// Kick the registry prefetch immediately, and never let a silent module hold boot: the player
|
|
// stops waiting after this and carries on with whatever identity it has.
|
|
prefetch();
|
|
if (global.setTimeout) global.setTimeout(markReady, 5000);
|
|
|
|
// Only worth polling where a sensor exists. A browser has neither the temperature API nor a
|
|
// meaningful storage quota to report, and an interval that can only ever produce nothing is
|
|
// just a timer burning a wakeup every minute on a device that runs for months.
|
|
if (API.isBrightSign()) {
|
|
API.refreshTelemetry();
|
|
if (global.setInterval) global.setInterval(API.refreshTelemetry, TELEMETRY_REFRESH_MS);
|
|
}
|
|
|
|
if (API.hasHost()) API.startHeartbeat();
|
|
})(typeof window !== 'undefined' ? window : this);
|