mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
BrightSign: real display power, reboot and volume — command parity
The web player handles four of the ~20 fleet commands, because a browser tab genuinely cannot do more. A BrightSign can, and was inheriting the browser's limits for no reason. screen_on/screen_off now send CEC Image View On (0x0D) / Standby (0x36) so the display actually sleeps. The overlay only painted the screen black: the panel stayed lit, drawing power and at risk of burn-in. Best effort by design — some displays ignore broadcast CEC and need direct addressing — so displayPower() returns false when unavailable and the overlay is applied either way, meaning something visible always happens. reboot was silently ignored: the dashboard button did nothing on a web player. It now goes through the host to RebootSystem, and still logs a clear "not supported" off-platform rather than failing quietly. set_volume applies to whatever is playing AND is re-applied on every subsequent 'play' event, caught in the capture phase because media events do not bubble. Media elements are created per item across fullscreen, zone and preload paths, so setting volume once would otherwise last only until the playlist advanced. Wall followers stay silent throughout — that is deliberate, not an oversight. A dual-output player addresses HDMI-N for the screen it actually paints, so output 2 sleeps its own display rather than output 1's. 954 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
cb4376d9cd
commit
c743aa4b81
|
|
@ -147,6 +147,29 @@ because the BrightSigns would look perfectly synchronised while the odd panel dr
|
||||||
|
|
||||||
A player paired before this port is still recognised, by its BrightSign user agent.
|
A player paired before this port is still recognised, by its BrightSign user agent.
|
||||||
|
|
||||||
|
## Command parity
|
||||||
|
|
||||||
|
The web player handles four of the ~20 fleet commands — `launch`, `refresh`, `screen_on`,
|
||||||
|
`screen_off` — because a browser tab genuinely cannot do more. A BrightSign can, through the host
|
||||||
|
and the platform APIs:
|
||||||
|
|
||||||
|
| command | web player | BrightSign |
|
||||||
|
|---|---|---|
|
||||||
|
| `screen_on` / `screen_off` | black overlay; panel stays lit | **CEC** Image View On / Standby — the display actually sleeps |
|
||||||
|
| `reboot` | ignored | **real reboot** via `RebootSystem` in the host |
|
||||||
|
| `set_volume` | — | applied to current and future media |
|
||||||
|
| `refresh` | `location.reload()` | widget rebuilt by the host (reload is unreliable here) |
|
||||||
|
|
||||||
|
`displayPower()` is best effort and returns false when CEC is unavailable, so the overlay is
|
||||||
|
applied either way and something visible always happens — some displays ignore broadcast CEC and
|
||||||
|
need direct addressing. Volume is re-applied on every `play` event in the capture phase, because
|
||||||
|
media elements are created per item across several code paths and setting it once would otherwise
|
||||||
|
last only until the playlist advanced.
|
||||||
|
|
||||||
|
Still Android-only, and correctly inert here: the Tier-2 device-owner commands (`kiosk_lock`,
|
||||||
|
`install_apk`, `shell`, `block_uninstall`, …) and `set_brightness` / `set_screen_timeout`, which
|
||||||
|
have no BrightSign equivalent — a signage player has no per-window brightness or screen timeout.
|
||||||
|
|
||||||
## What is NOT done yet
|
## What is NOT done yet
|
||||||
|
|
||||||
Stated plainly so nobody reads this as finished:
|
Stated plainly so nobody reads this as finished:
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
var RegistryClass = tryRequire('@brightsign/registry');
|
var RegistryClass = tryRequire('@brightsign/registry');
|
||||||
var DeviceInfoClass = tryRequire('@brightsign/deviceinfo');
|
var DeviceInfoClass = tryRequire('@brightsign/deviceinfo');
|
||||||
var VideoOutputClass = tryRequire('@brightsign/videooutput');
|
var VideoOutputClass = tryRequire('@brightsign/videooutput');
|
||||||
|
var CecClass = tryRequire('@brightsign/cec');
|
||||||
|
|
||||||
var port = null;
|
var port = null;
|
||||||
if (MessagePortClass) {
|
if (MessagePortClass) {
|
||||||
|
|
@ -170,6 +171,21 @@
|
||||||
} catch (e) { return false; }
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
var deviceInfo = null;
|
var deviceInfo = null;
|
||||||
if (DeviceInfoClass) {
|
if (DeviceInfoClass) {
|
||||||
try { deviceInfo = new DeviceInfoClass(); } catch (e) { deviceInfo = null; }
|
try { deviceInfo = new DeviceInfoClass(); } catch (e) { deviceInfo = null; }
|
||||||
|
|
@ -308,6 +324,31 @@
|
||||||
readyWaiters.push(fn);
|
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) {
|
setVideoMode: function (mode) {
|
||||||
if (VideoOutputClass) {
|
if (VideoOutputClass) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -684,7 +684,8 @@
|
||||||
// gesture-driven start rather than the unmute of an autoplaying video.
|
// gesture-driven start rather than the unmute of an autoplaying video.
|
||||||
const t = video.currentTime;
|
const t = video.currentTime;
|
||||||
video.muted = false;
|
video.muted = false;
|
||||||
video.volume = 1.0;
|
// Honour an operator-set level if there is one; otherwise full, as before.
|
||||||
|
video.volume = (mediaVolume != null) ? mediaVolume : 1.0;
|
||||||
video.pause();
|
video.pause();
|
||||||
const p = video.play();
|
const p = video.play();
|
||||||
if (p && typeof p.then === 'function') {
|
if (p && typeof p.then === 'function') {
|
||||||
|
|
@ -1462,9 +1463,21 @@
|
||||||
socket.on('device:command', (data) => {
|
socket.on('device:command', (data) => {
|
||||||
console.log('Command:', data.type);
|
console.log('Command:', data.type);
|
||||||
if (data.type === 'refresh') restartPlayer('operator refresh');
|
if (data.type === 'refresh') restartPlayer('operator refresh');
|
||||||
if (data.type === 'launch') { document.getElementById('screenOffOverlay')?.remove(); }
|
if (data.type === 'launch') { document.getElementById('screenOffOverlay')?.remove(); setDisplayPower(true); }
|
||||||
if (data.type === 'screen_off') toggleScreenOff();
|
if (data.type === 'screen_off') toggleScreenOff();
|
||||||
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); }
|
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); setDisplayPower(true); }
|
||||||
|
// A browser tab cannot reboot its host, so the web player has always ignored this and the
|
||||||
|
// dashboard button did nothing on it. A BrightSign can, through the host script.
|
||||||
|
if (data.type === 'reboot') {
|
||||||
|
if (BS && BS.reboot()) console.log('[bs] reboot requested via host');
|
||||||
|
else console.log('reboot: not supported on this player');
|
||||||
|
}
|
||||||
|
// Media volume, 0-100 from the dashboard. Applies to whatever is playing now and is
|
||||||
|
// remembered for items mounted later.
|
||||||
|
if (data.type === 'set_volume') {
|
||||||
|
const pct = Number(data.payload?.value ?? data.value);
|
||||||
|
if (isFinite(pct)) setMediaVolume(Math.max(0, Math.min(100, pct)) / 100);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// #129: real-time mute. Apply immediately if the toggled item is the one playing now;
|
// #129: real-time mute. Apply immediately if the toggled item is the one playing now;
|
||||||
|
|
@ -3467,9 +3480,43 @@
|
||||||
document.getElementById('statusOverlay').style.display = 'none';
|
document.getElementById('statusOverlay').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real display power, where the platform has it. The overlay only paints the screen black:
|
||||||
|
// the panel stays lit, drawing power and at risk of burn-in. On BrightSign we can tell the
|
||||||
|
// display itself to sleep over CEC. Best effort — some displays ignore broadcast CEC — so the
|
||||||
|
// overlay is applied regardless and something visible always happens.
|
||||||
|
function setDisplayPower(on) {
|
||||||
|
if (!BS) return false;
|
||||||
|
try { return BS.displayPower(on); } catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume that survives the next item. Media elements are created per item, so remembering the
|
||||||
|
// level is what makes a volume command stick rather than lasting until the playlist advances.
|
||||||
|
let mediaVolume = null;
|
||||||
|
function setMediaVolume(v) {
|
||||||
|
mediaVolume = v;
|
||||||
|
// Wall followers stay silent by design; don't override that.
|
||||||
|
try { if (typeof isWallFollower === 'function' && isWallFollower()) return; } catch (e) { /* not a wall */ }
|
||||||
|
document.querySelectorAll('video, audio').forEach((el) => {
|
||||||
|
try { el.volume = v; el.muted = v === 0; } catch (e) { /* element torn down mid-call */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media elements are created per item across several code paths — fullscreen, zones, the
|
||||||
|
// preloader — so setting volume once only lasts until the playlist advances. Catching 'play'
|
||||||
|
// in the CAPTURE phase applies it to every element that ever starts, from one place. Media
|
||||||
|
// events do not bubble, which is why capture is required rather than a plain listener.
|
||||||
|
document.addEventListener('play', (e) => {
|
||||||
|
if (mediaVolume == null) return;
|
||||||
|
const el = e.target;
|
||||||
|
if (!el || (el.tagName !== 'VIDEO' && el.tagName !== 'AUDIO')) return;
|
||||||
|
try { if (typeof isWallFollower === 'function' && isWallFollower()) return; } catch (err) { /* not a wall */ }
|
||||||
|
try { el.volume = mediaVolume; el.muted = mediaVolume === 0; } catch (err) { /* gone */ }
|
||||||
|
}, true);
|
||||||
|
|
||||||
function toggleScreenOff() {
|
function toggleScreenOff() {
|
||||||
let overlay = document.getElementById('screenOffOverlay');
|
let overlay = document.getElementById('screenOffOverlay');
|
||||||
if (overlay) { overlay.remove(); return; }
|
if (overlay) { overlay.remove(); setDisplayPower(true); return; }
|
||||||
|
setDisplayPower(false);
|
||||||
overlay = document.createElement('div');
|
overlay = document.createElement('div');
|
||||||
overlay.id = 'screenOffOverlay';
|
overlay.id = 'screenOffOverlay';
|
||||||
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999;cursor:pointer';
|
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999;cursor:pointer';
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'brightsign', 'st-b
|
||||||
function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed = {} } = {}) {
|
function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed = {} } = {}) {
|
||||||
const posted = [];
|
const posted = [];
|
||||||
const registryStore = new Map(Object.entries(seed));
|
const registryStore = new Map(Object.entries(seed));
|
||||||
|
const cec = { sent: [] };
|
||||||
|
const cecConnectors = [];
|
||||||
|
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
console: { log() {}, warn() {}, error() {} },
|
console: { log() {}, warn() {}, error() {} },
|
||||||
|
|
@ -34,6 +36,8 @@ function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed =
|
||||||
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||||||
Promise,
|
Promise,
|
||||||
Object,
|
Object,
|
||||||
|
Array,
|
||||||
|
Uint8Array,
|
||||||
Date,
|
Date,
|
||||||
RegExp,
|
RegExp,
|
||||||
parseInt,
|
parseInt,
|
||||||
|
|
@ -72,6 +76,13 @@ function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed =
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (name === '@brightsign/cec') {
|
||||||
|
return function (connector) {
|
||||||
|
cecConnectors.push(connector);
|
||||||
|
return { send: (bytes) => { cec.sent.push(Array.from(bytes)); return Promise.resolve(); },
|
||||||
|
addEventListener: () => {} };
|
||||||
|
};
|
||||||
|
}
|
||||||
if (name === '@brightsign/deviceinfo') {
|
if (name === '@brightsign/deviceinfo') {
|
||||||
return function () {
|
return function () {
|
||||||
return { model: 'XT1145', osVersion: '9.1.92.2', serialNumber: 'SN-TEST-1' };
|
return { model: 'XT1145', osVersion: '9.1.92.2', serialNumber: 'SN-TEST-1' };
|
||||||
|
|
@ -86,7 +97,7 @@ function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed =
|
||||||
const api = sandbox.ScreenTinkerBS;
|
const api = sandbox.ScreenTinkerBS;
|
||||||
// onReady always fires, so this resolves off-platform too.
|
// onReady always fires, so this resolves off-platform too.
|
||||||
const ready = new Promise((resolve) => api.onReady(resolve));
|
const ready = new Promise((resolve) => api.onReady(resolve));
|
||||||
return { api, sandbox, posted, registryStore, ready };
|
return { api, sandbox, posted, registryStore, ready, cec, cecConnectors };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('in a plain browser it loads without throwing and reports not-BrightSign', () => {
|
test('in a plain browser it loads without throwing and reports not-BrightSign', () => {
|
||||||
|
|
@ -245,3 +256,27 @@ test('clearIdentity forgets the token too, or the reset leaks a credential', asy
|
||||||
assert.equal(api.deviceId(), null);
|
assert.equal(api.deviceId(), null);
|
||||||
assert.equal(api.deviceToken(), null, 'a stale token must not outlive the identity it belongs to');
|
assert.equal(api.deviceToken(), null, 'a stale token must not outlive the identity it belongs to');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('displayPower sends the CEC power codes, not just a black overlay', async () => {
|
||||||
|
// The overlay only paints the screen black — the panel stays lit, drawing power and at risk of
|
||||||
|
// burn-in. This is the difference between a signage player and a browser tab.
|
||||||
|
const { api, ready, cec } = load({ mods: true });
|
||||||
|
await ready;
|
||||||
|
assert.equal(api.displayPower(true), true);
|
||||||
|
assert.deepEqual(cec.sent.at(-1), [0x4f, 0x0d], 'Image View On');
|
||||||
|
assert.equal(api.displayPower(false), true);
|
||||||
|
assert.deepEqual(cec.sent.at(-1), [0x4f, 0x36], 'Standby');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displayPower reports false with no CEC, so the caller still draws the overlay', async () => {
|
||||||
|
const { api, ready } = load(); // plain browser
|
||||||
|
await ready;
|
||||||
|
assert.equal(api.displayPower(false), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('output 2 addresses HDMI-2 — a dual-output player must sleep the screen it paints', async () => {
|
||||||
|
const { api, ready, cecConnectors } = load({ mods: true, search: '?screen=2' });
|
||||||
|
await ready;
|
||||||
|
api.displayPower(true);
|
||||||
|
assert.deepEqual(cecConnectors, ['HDMI-2']);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue