screentinker/server/lib/player-media-health.js
ScreenTinker 95b8d1b293 Export shared modules to the browser even when Node is in the page
Transitions have never run on BrightSign, and it was never a GPU problem.

`transitionRuntimeReady()` is a presence check on three globals and touches no
WebGL at all. A BrightSign roHtmlWidget is created with `nodejs_enabled: true`,
which puts Node's `module` into classic-script scope — so every shared module
that exported with an `else` took the CommonJS branch and never assigned its
browser global. The runtime was absent before WebGL was ever asked a question.

This is deducible from the fleet without touching the hardware: the player
pushes system.reboot / display.power / display.resolution / system.self_update
only behind BS.hasHost(), which needs require('@brightsign/messageport') to
resolve. Our XT245's stored capability row carries all four, so Node
integration was live in that page, so the CommonJS branch was taken.

Transitions are the least of it. schedule-eval.js had the same shape, and the
player falls back to "always active" when ScheduleEval is missing — so per-item
DAYPARTING silently stopped applying on that platform and scheduled content
played outside its window with nothing in any log. player-media-health.js the
same. Four files, all fixed by exporting to BOTH targets rather than either/or.

media-mute.js, orientation-style.js and wall-geometry.js already assigned their
globals in a separate unconditional block and were never affected; the audit
that reached me claimed all seven, and reading them is what separated the four
from the three.

THE GUARD, WITHOUT WHICH THE ABOVE IS A REGRESSION.

Restore the globals alone and BrightSign starts attempting video wipes it
cannot supply. On a hardware video plane drawImage(video) succeeds, throws
nothing, and paints a fully TRANSPARENT frame — so the wipe fades from nothing,
behind a video plane that is still lit. Worse than the hard cut it replaces.

The discriminator already existed: videoFrameIsCapturable() probes ALPHA, so a
genuine fade-to-black still reads as captured. It was wired into the screenshot
path and not this one, which asked isMediaReadable() — a CORS question, "am I
allowed to read this", not "did any pixels arrive". Both the outgoing frame and
the incoming warm-play snapshot now consult it, cached per platform, defaulting
to available while undetermined so a cold start is not crippled.

Net effect on BrightSign: image-to-image transitions light up, anything
involving video hard-cuts honestly, and dayparting starts working.

Full video transitions are reachable later — BrightSign documents that video
"captured as a canvas for WebGL processing must be routed to the GPU" via a
per-element hwz="off", which keeps hardware decode at an 8-bit/1080p ceiling.
That needs the hardware to validate and is not in this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 10:36:50 -05:00

64 lines
3.5 KiB
JavaScript

// Player media-surface health decision (#146 web-player fix).
//
// THE BUG (hypothesis A): a NO-NEW-CONTENT refresh in handlePlaylistUpdate returned early
// ("Playlist unchanged") without verifying the media surface is still attached. If the
// <video> element had been detached from the DOM while still decoding (audio keeps playing,
// video surface gone), the re-attach — which lived ONLY in the content-changed branch —
// never ran, so the video never came back. This module is the branch-selection decision the
// no-change path now consults: re-attach ONLY when playback should be happening but the
// surface is actually lost, so a healthy poll stays a no-op (no flicker every refresh).
//
// Pure + dependency-free so it is unit-testable without a DOM: the caller extracts the DOM
// facts (is the <video> in the document? ended? errored?) into a plain state object.
//
// Dependency-free UMD: Node (require) + browser/Tizen (window.PlayerMediaHealth).
(function (root, factory) {
// BOTH, not either/or — see schedule-eval.js. Node integration in a BrightSign widget made the
// browser branch unreachable, so the player ran without its media-health decision there.
var api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.PlayerMediaHealth = api;
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// state = {
// isPlaying: boolean // the player believes an item is playing
// hasCurrentItem: boolean // playlist[currentIndex] exists
// itemKind: 'video' | 'youtube' | 'image' | 'widget' | 'other'
// videoEl: { attached, ended, errored } | null // for a plain <video> item
// surfaceAttached:boolean // for non-video: a rendered surface is present in the DOM
// }
// Returns true iff the no-change refresh must re-render/re-attach the current item.
function needsReattach(state) {
var s = state || {};
// Idle or no content: nothing to re-attach — leave the idle/waiting screen alone.
if (!s.isPlaying || !s.hasCurrentItem) return false;
if (s.itemKind === 'video') {
// The exact bug: a <video> that is gone or detached from the DOM (its element may
// still be emitting audio) — or one that ended/errored — must be re-attached.
if (!s.videoEl) return true;
if (!s.videoEl.attached) return true;
if (s.videoEl.ended || s.videoEl.errored) return true;
return false; // attached + live -> healthy, do NOT re-render (avoids flicker)
}
// Non-video surfaces (image / youtube iframe / widget): healthy iff a surface is mounted.
return !s.surfaceAttached;
}
// Whether the idle "Waiting for content..." screen should be shown, given player state.
// THE RECONNECT BUG: the server re-emits device:paired on every re-register of an already-
// paired device, and the player showed the idle overlay UNCONDITIONALLY — covering live
// content (audio kept playing underneath), and the following "Playlist unchanged" left it
// up. Rule: only fall to idle when nothing is playing AND there is genuinely no content to
// play. Already playing, or content present and about to render, is NEVER idle.
function shouldShowIdle(state) {
var s = state || {};
if (s.isPlaying) return false; // something is playing -> never cover it with idle
return !s.hasContent; // idle only when there's genuinely no content
}
return { needsReattach: needsReattach, shouldShowIdle: shouldShowIdle };
});