/* * 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'); /* * ⚠️ @brightsign/videooutput does NOT set a video mode. Its surface is read-only plus power * (getVideoResolution / getEdid / isAttached / setPowerSaveMode / setBackgroundColor); there is * no setMode on it at all. Mode setting lives on @brightsign/videomodeconfiguration, whose * setMode() returns a Promise<{restartRequired}>. * * The two were conflated here, and the cost was not a broken call — the call was guarded — it was * a LIE: a widget with no host bridge declared display.resolution purely because videooutput * resolved, and the dashboard grew a resolution control that could never do anything. */ var VideoModeConfigClass = tryRequire('@brightsign/videomodeconfiguration'); 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 * 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 = {}; /* * Facts pushed by the host, merged into the same cache the heartbeat reads. * * Registered at load, directly on the listener list rather than behind the readiness gate: the * host starts sending these the moment the widget exists, and anything attached later would miss * the boot report — the one that says which volume the player came up from and whether a package * applied. * * The host's numbers WIN over the page's where they overlap. navigator.storage.estimate() * describes the widget's cache quota, not the disk: a panel can report gigabytes free while the * volume holding them is full, and only the host can tell the difference. */ /* * Host diagnostics arrive BEFORE anyone is listening, and that is not an edge case — it is the * normal order of events and the whole reason they are worth carrying. * * The host buffers its pre-widget boot lines and posts them the moment the page says hello. The * player, correctly, does not subscribe until its socket is connected, because a line forwarded * before that has nowhere to go. Between those two facts every boot line was dropped: the host * spoke into a page with no listener, and the listener arrived after the words had gone. The * player's own comment says wiring earlier "would drop the host's boot report on the floor" — * which was true, and left the report on the floor anyway. * * So the bridge holds them. Messages land in these queues from the moment the file loads, and are * replayed to each consumer as it registers. Bounded, because a host stuck in a reboot loop must * not grow this without limit on a player that runs for months. */ var PENDING_MAX = 200; var logSinks = []; var eventSinks = []; var pendingLogs = []; var pendingEvents = []; function drain(queue, fn) { // Copied first: fn is free to register another sink, and iterating a live array while it is // being appended to is how a replay turns into a loop. var items = queue.slice(); for (var i = 0; i < items.length; i++) { try { fn(items[i]); } catch (e) { /* one bad consumer must not eat the rest of the boot log */ } } } function fanout(sinks, queue, payload) { if (sinks.length === 0) { if (queue.length < PENDING_MAX) queue.push(payload); return; } for (var i = 0; i < sinks.length; i++) { try { sinks[i](payload); } catch (e) { /* ignore */ } } } listeners.push(function (msg) { if (!msg) return; if (msg.type === 'host-log') { fanout(logSinks, pendingLogs, { tag: String(msg.tag || 'host').slice(0, 64), level: String(msg.level || 'i').slice(0, 8), message: String(msg.message || '').slice(0, 2000) }); return; } if (msg.type === 'host-event' && msg.event) { fanout(eventSinks, pendingEvents, { event: String(msg.event), reason: String(msg.reason || '').slice(0, 64), detail: String(msg.detail || '').slice(0, 500) }); } }); listeners.push(function (msg) { if (msg && msg.type === 'host-telemetry') { var keys = ['uptime_seconds', 'local_ip', 'model', 'os_version', 'video_mode', 'storage_volume', 'storage_free_mb', 'storage_total_mb', 'boot_volume', 'package_version']; for (var i = 0; i < keys.length; i++) { var v = msg[keys[i]]; if (v !== undefined && v !== null && v !== '') telemetry[keys[i]] = v; } } }); 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 — and this platform does not have it. * * `navigator.serviceWorker` EXISTS on a BrightSign widget and is not usable: our XT245 on alpha * passes this exact check, then never even fetches sw.js. Presence was therefore the one signal * that could not distinguish "caches offline" from "cannot", and it answered yes to both — the * player advertised offline.cache to the whole fleet while being unable to hold a single byte * through an outage. The web player already learned this (it waits for a worker that is in * CONTROL, see declareCapabilities in server/player/index.html); this copy had not. * * A controller is proof, not a promise: something is actually intercepting this page's fetches. */ try { var sw = global.navigator && global.navigator.serviceWorker; if (sw && sw.controller) 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 (VideoModeConfigClass) { // No host, but the JS mode-configuration 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 { // `serialNumber` is the whole answer. There is no getDeviceUniqueId() on // @brightsign/deviceinfo — that is the BrightScript roDeviceInfo method name, and // BrightSign's own migration note maps it to this attribute. `deviceUniqueId` is the // legacy BSDeviceInfo global's spelling, also an attribute rather than a call, and is // read here only so a very old widget build still answers with something. var s = deviceInfo.serialNumber || deviceInfo.deviceUniqueId; 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 (VideoModeConfigClass) { try { var vmc = new VideoModeConfigClass(); if (vmc && typeof vmc.setMode === 'function') { // Promise<{restartRequired}>. Nothing here awaits it — a mode change that restarts the // application takes this page with it, so there is no "after" to report into. Rejection // is swallowed rather than left as an unhandled rejection on a signage player. var r = vmc.setMode(mode); if (r && typeof r.catch === 'function') r.catch(function () {}); 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. */ /* * Capture the screen using BrightSign's OWN screenshot API — the composite of the video and * graphics layers, which is the whole point: an in-page canvas cannot read the hardware video * plane, so a DOM composite returns a frame with the content missing. * * Entirely page-side, and that is what makes it work here. The obvious route was to ask the * host (BrightScript) to capture via the player's DWS, but page->host messaging is dead after * load on this platform, so the request never arrived. `@brightsign/screenshot` needs no host, * no DWS, no messageport — just the Node `require` the widget already has (the same one that * makes `module` visible to classic scripts). * * The API writes a FILE rather than returning bytes, so it is read straight back with Node's * fs — available for exactly the same reason require() is. */ captureScreen: function (opts) { var o = opts || {}; return new Promise(function (resolve, reject) { var ScreenshotClass = tryRequire('@brightsign/screenshot'); var fs = tryRequire('fs'); if (!ScreenshotClass) { reject(new Error('no @brightsign/screenshot module')); return; } if (!fs) { reject(new Error('no fs module')); return; } // RAM FIRST, deliberately. The remote-control view drives this once a second, and a // screenshot per second written to the boot flash is a wear-out mechanism with no upside — // the file is read back and deleted microseconds later, so it never needs to be durable. // BrightSign exposes tmp as a RAM volume alongside the storage ones. Real storage is only // a fallback for a unit that does not present tmp, and the directory must already exist or // the capture fails, so each candidate is checked rather than assumed. var dirs = ['/storage/tmp', '/tmp', '/storage/ssd', '/storage/usb1', '/storage/sd', '/storage/flash']; var dir = null; for (var i = 0; i < dirs.length; i++) { try { if (fs.existsSync(dirs[i])) { dir = dirs[i]; break; } } catch (e) { /* keep looking */ } } if (!dir) { reject(new Error('no writable volume for the capture')); return; } var path = dir + '/st-capture.jpg'; try { fs.unlinkSync(path); } catch (e) { /* first run, or already gone */ } var params = { destinationFileName: path, fileName: path, // deprecated alias, still honoured on older firmware fileType: 'JPEG', width: o.width || 960, height: o.height || 540, quality: o.quality || 70, rotation: 0, }; var shot; try { shot = new ScreenshotClass(); } catch (e) { reject(new Error('screenshot object: ' + e.message)); return; } try { // syncCapture may interrupt on-screen operations, which the docs flag as a debugging // trait — but it guarantees the file exists when it returns, and an operator asking for // one screenshot is worth a single frame of interruption. The stream path uses async. if (o.async && typeof shot.asyncCapture === 'function') shot.asyncCapture(params); else if (typeof shot.syncCapture === 'function') shot.syncCapture(params); else if (typeof shot.asyncCapture === 'function') shot.asyncCapture(params); else { reject(new Error('screenshot object exposes neither capture method')); return; } } catch (e) { reject(new Error('capture failed: ' + e.message)); return; } // Poll for the file rather than trusting a return value: sync and async differ, and the // documented contract is "a file appears", not "a promise settles". var waited = 0; var tick = function () { var st = null; try { st = fs.statSync(path); } catch (e) { st = null; } if (st && st.size > 512) { var b64; try { b64 = fs.readFileSync(path).toString('base64'); } catch (e) { reject(new Error('could not read the capture: ' + e.message)); return; } try { fs.unlinkSync(path); } catch (e) { /* best-effort: never let cleanup fail a good capture */ } resolve('data:image/jpeg;base64,' + b64); return; } waited += 150; if (waited > (o.timeoutMs || 8000)) { reject(new Error('capture produced no file in ' + waited + 'ms')); return; } global.setTimeout(tick, 150); }; global.setTimeout(tick, 150); }); }, 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); }, /* * Host diagnostics, routed into the channels the player already speaks. * * The host sees things the page has no API for — the uptime, the wired IP, the video mode * actually in force, which volume it booted from, whether a staged package applied — and until * now it printed all of it to a serial console. On a panel on a wall that is the same as not * reporting it. A bad string literal once stopped this script compiling and the only evidence * anywhere was on a cable; the server just saw a player that never appeared. * * These are deliberately thin: the bridge does not decide what a log line or an incident MEANS, * it just carries them to the player, which sends them the same way it sends its own. */ onHostLog: function (fn) { if (typeof fn !== 'function') return; logSinks.push(fn); drain(pendingLogs, fn); }, onHostEvent: function (fn) { if (typeof fn !== 'function') return; eventSinks.push(fn); drain(pendingEvents, 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);