mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 07:13:12 -06:00
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:
parent
c743aa4b81
commit
141deb97a5
|
|
@ -160,9 +160,29 @@ and the platform APIs:
|
||||||
| `set_volume` | — | applied to current and future media |
|
| `set_volume` | — | applied to current and future media |
|
||||||
| `refresh` | `location.reload()` | widget rebuilt by the host (reload is unreliable here) |
|
| `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
|
### ⚠️ Nothing in the DOM can cover video
|
||||||
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
|
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
|
media elements are created per item across several code paths and setting it once would otherwise
|
||||||
last only until the playlist advanced.
|
last only until the playlist advanced.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1463,9 +1463,9 @@
|
||||||
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(); 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_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
|
// 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.
|
// dashboard button did nothing on it. A BrightSign can, through the host script.
|
||||||
if (data.type === 'reboot') {
|
if (data.type === 'reboot') {
|
||||||
|
|
@ -3506,16 +3506,55 @@
|
||||||
// in the CAPTURE phase applies it to every element that ever starts, from one place. Media
|
// 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.
|
// events do not bubble, which is why capture is required rather than a plain listener.
|
||||||
document.addEventListener('play', (e) => {
|
document.addEventListener('play', (e) => {
|
||||||
if (mediaVolume == null) return;
|
|
||||||
const el = e.target;
|
const el = e.target;
|
||||||
if (!el || (el.tagName !== 'VIDEO' && el.tagName !== 'AUDIO')) return;
|
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 { if (typeof isWallFollower === 'function' && isWallFollower()) return; } catch (err) { /* not a wall */ }
|
||||||
try { el.volume = mediaVolume; el.muted = mediaVolume === 0; } catch (err) { /* gone */ }
|
try { el.volume = mediaVolume; el.muted = mediaVolume === 0; } catch (err) { /* gone */ }
|
||||||
}, true);
|
}, 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() {
|
function toggleScreenOff() {
|
||||||
let overlay = document.getElementById('screenOffOverlay');
|
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);
|
setDisplayPower(false);
|
||||||
overlay = document.createElement('div');
|
overlay = document.createElement('div');
|
||||||
overlay.id = 'screenOffOverlay';
|
overlay.id = 'screenOffOverlay';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue