diff --git a/brightsign/README.md b/brightsign/README.md index 44a64eb..67d150c 100644 --- a/brightsign/README.md +++ b/brightsign/README.md @@ -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. +## 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 Stated plainly so nobody reads this as finished: diff --git a/brightsign/st-bridge.js b/brightsign/st-bridge.js index d2704fd..f962340 100644 --- a/brightsign/st-bridge.js +++ b/brightsign/st-bridge.js @@ -36,6 +36,7 @@ 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) { @@ -170,6 +171,21 @@ } 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; if (DeviceInfoClass) { try { deviceInfo = new DeviceInfoClass(); } catch (e) { deviceInfo = null; } @@ -308,6 +324,31 @@ 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 { diff --git a/server/player/index.html b/server/player/index.html index 06e426f..18d2939 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -684,7 +684,8 @@ // gesture-driven start rather than the unmute of an autoplaying video. const t = video.currentTime; 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(); const p = video.play(); if (p && typeof p.then === 'function') { @@ -1462,9 +1463,21 @@ socket.on('device:command', (data) => { console.log('Command:', data.type); 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_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; @@ -3467,9 +3480,43 @@ 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() { let overlay = document.getElementById('screenOffOverlay'); - if (overlay) { overlay.remove(); return; } + if (overlay) { overlay.remove(); setDisplayPower(true); return; } + setDisplayPower(false); overlay = document.createElement('div'); overlay.id = 'screenOffOverlay'; overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999;cursor:pointer'; diff --git a/server/test/brightsign-bridge.test.js b/server/test/brightsign-bridge.test.js index d69ac84..d8d4b24 100644 --- a/server/test/brightsign-bridge.test.js +++ b/server/test/brightsign-bridge.test.js @@ -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 = {} } = {}) { const posted = []; const registryStore = new Map(Object.entries(seed)); + const cec = { sent: [] }; + const cecConnectors = []; const sandbox = { 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), Promise, Object, + Array, + Uint8Array, Date, RegExp, 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') { return function () { 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; // onReady always fires, so this resolves off-platform too. 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', () => { @@ -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.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']); +});