mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Audited rotation across all four players after reports it misbehaves.
Android native rootView.rotation + layout swap — the ExoPlayer surface is
inside the rotated view, so video turns with it.
Tizen CSS for graphics AND AVPlay hardware-plane rotation for video. The
code says why: a CSS-rotated <video> "blacks out" on Tizen.
Web CSS transform. Correct — a browser composites video in the DOM.
BrightSign CSS transform only, inherited from the web player. BROKEN: with hwz
enabled the video decodes onto a hardware plane the DOM cannot
transform, so the images and widgets rotate and the video does not.
A portrait panel plays sideways video.
BrightSign is the platform that does not rotate correctly, and Tizen had already
found the same wall from the other side — any platform compositing video below
the DOM needs rotation done at the output.
roVideoMode takes a transform (normal/90/180/270) and rotating the screen rotates
EVERY layer, because it happens below the compositor rather than above it. The
player now asks the host first and, when the host succeeds, clears its own CSS
transform — otherwise the graphics rotate twice while the video rotates once.
The host reports success rather than assuming it: if it cannot rotate, the CSS
path stands, which turns most of the content instead of none of it, and the
promise resolves false rather than never settling. A portrait panel showing
landscape content with no clue why is the outcome worth avoiding.
1066 pass. The BrightScript needs hardware to verify; the decision path does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
524 lines
21 KiB
JavaScript
524 lines
21 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.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
function prefetch() {
|
|
if (!registry) { markReady(); return; }
|
|
var pending = CACHED_KEYS.length;
|
|
var settle = function () { if (--pending <= 0) markReady(); };
|
|
|
|
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; }
|
|
}
|
|
|
|
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 });
|
|
});
|
|
},
|
|
|
|
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 */ }
|
|
}
|
|
|
|
/*
|
|
* Storage. This is the WIDGET'S storage quota (storage_path/storage_quota in autorun.brs),
|
|
* NOT the device's filesystem — there is no documented JS API for the latter, and reporting
|
|
* eMMC/SD capacity would need the host. It is still the number that matters operationally,
|
|
* because it is the budget the player actually has for cached content, and it is what fills
|
|
* up. The dashboard labels it distinctly for this family so it is never read as "the disk".
|
|
*/
|
|
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;
|
|
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);
|