screen_off must tear the video down — a DOM overlay cannot cover a hardware plane

Blanking the screen took three attempts on real hardware, and each failure was
the same lesson from a different angle:

  1. black overlay        -> the video played straight THROUGH it. With hwz
                             enabled the widget decodes onto a hardware plane and
                             the graphics plane sits behind it; z-index is
                             irrelevant across planes.
  2. pause + hide element -> playback stopped and the LAST DECODED FRAME stayed
                             on screen. Hiding a DOM element does nothing to the
                             plane, which is not part of the DOM.
  3. pause + remove src   -> releases the plane. Black.
     + load()

Coming back re-mounts through nextItem(), because a torn-down element cannot be
resurrected. The playlist keeps advancing while the screen is off, so each newly
started item is torn down as well — caught on 'play' in the capture phase, or the
next video lights the panel back up a few seconds later.

CEC is now explicitly not load-bearing. Our XT245 logs "failed to get cec clock"
and does not respond to it at all, which is precisely why blanking cannot depend
on a cooperative display: plenty ignore broadcast CEC or need direct addressing.
displayPower() stays as opportunistic best-effort alongside the teardown.

Verified on hardware: not black, then frozen frame, then black.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-05 09:40:17 -05:00
parent c743aa4b81
commit 141deb97a5
2 changed files with 66 additions and 7 deletions

View file

@ -160,9 +160,29 @@ and the platform APIs:
| `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
### ⚠️ Nothing in the DOM can cover video
With `hwz_default: "on"` the widget decodes video onto a **hardware plane**, and the graphics plane
— everything in the DOM — sits behind it. Blanking the screen took three attempts on real hardware,
and each failure taught the same lesson from a different angle:
1. **Black overlay** → the video played straight *through* it. A `z-index: 9999` div cannot cover a
hardware plane.
2. **Pause + hide the element** → playback stopped, but the **last decoded frame stayed on screen**.
Hiding a DOM element does nothing to the plane; the plane is not part of the DOM.
3. **Pause + `removeAttribute('src')` + `load()`** → releases the plane. Black at last.
Coming back out re-mounts through `nextItem()`, because a torn-down element cannot be resurrected.
The playlist keeps advancing while the screen is off, so each newly started item is torn down too,
caught on the `play` event in the capture phase — otherwise the next video lights the panel back up.
Any feature that assumes an overlay can hide video needs rethinking here: screen blanking, masking,
fades over video.
`displayPower()` (CEC) is best effort and deliberately **not** load-bearing — it returns false when
CEC is unavailable and the media teardown does the real work. Our XT245 reports
`failed to get cec clock` in the kernel log and does not respond to CEC at all, which is exactly why
blanking must not depend on it. Plenty of displays ignore broadcast CEC or 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.

View file

@ -1463,9 +1463,9 @@
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(); setDisplayPower(true); }
if (data.type === 'launch') { document.getElementById('screenOffOverlay')?.remove(); screenIsOff = false; suppressMedia(false); setDisplayPower(true); }
if (data.type === 'screen_off') toggleScreenOff();
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); setDisplayPower(true); }
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); screenIsOff = false; suppressMedia(false); 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') {
@ -3506,16 +3506,55 @@
// 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;
// The playlist advances while the screen is off; hold each new item down as it starts,
// or the next video appears on the hardware plane and the screen lights back up.
if (screenIsOff) {
try { el.pause(); el.removeAttribute('src'); el.load(); el.style.visibility = 'hidden'; } catch (err) {}
return;
}
if (mediaVolume == null) 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);
// Screen-off state, tracked because the playlist keeps advancing while the screen is "off"
// and every newly mounted item has to be held down too.
let screenIsOff = false;
// Stop media rather than cover it. On BrightSign the widget runs with hardware z-order
// (hwz), so video decodes onto a HARDWARE PLANE and a DOM overlay — which lives in the
// graphics plane — cannot hide it. The overlay goes up and the video plays straight through
// it. Pausing and hiding the element is what actually blanks the screen there, and it is
// harmless everywhere else.
function suppressMedia(off) {
if (!off) {
// Coming back: re-mount current content rather than trying to resurrect a torn-down
// element. nextItem() rebuilds from scratch, which is the same path a normal advance uses.
document.querySelectorAll('video, audio').forEach((el) => { try { el.style.visibility = ''; } catch (e) {} });
try { if (typeof nextItem === 'function') nextItem(); } catch (e) { /* fall back to whatever is on screen */ }
return;
}
document.querySelectorAll('video, audio').forEach((el) => {
try {
el.pause();
// Pausing is not enough on a hardware plane: the last decoded frame STAYS on screen,
// which is why the panel showed a frozen image rather than going black. Tearing the
// source down releases the plane. Hiding the element does nothing to it either — the
// plane is not part of the DOM.
el.removeAttribute('src');
el.load();
el.style.visibility = 'hidden';
} catch (e) { /* element torn down mid-call */ }
});
}
function toggleScreenOff() {
let overlay = document.getElementById('screenOffOverlay');
if (overlay) { overlay.remove(); setDisplayPower(true); return; }
if (overlay) { overlay.remove(); screenIsOff = false; suppressMedia(false); setDisplayPower(true); return; }
screenIsOff = true;
suppressMedia(true);
setDisplayPower(false);
overlay = document.createElement('div');
overlay.id = 'screenOffOverlay';