fix(web-player): buffered widget swap + schedule-aware solo-board hold (directory-board black flicker) (#202)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

* fix(web-player): buffered widget swap + solo-board hold to end directory-board black flicker

A fullscreen widget (e.g. a solo directory board) re-rendered on the advance timer:
renderContent tore the container down to black (innerHTML='') BEFORE the replacement
iframe finished loading, and a single/only-active widget re-advanced to itself every
duration_sec — so the board cycled black every few seconds. That reload was ALSO the
only thing refreshing the board's static, server-rendered data, so simply holding it
in place would freeze the data.

- Buffered swap: build the new widget iframe hidden OVER the current content and reveal
  it on 'load', then tear down the outgoing content — no black frame on any widget
  transition. On a load timeout, keep the last-good board and discard the dead hidden
  frame via a shared cleanup path (don't reveal a blank frame); a transient server blip
  self-heals on the next refresh.
- Solo/held widget (nextActiveIndex === currentIndex): hold in place and refresh its
  DATA on a decoupled interval (WIDGET_SOLO_REFRESH_MS = 60s) via the buffered swap,
  instead of re-querying the DB + re-rendering full HTML every duration_sec, fleet-wide.

Scoped to non-wall fullscreen widgets; wall+widget keeps the legacy path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web-player): route held directory-board refresh through nextItem (schedule-aware)

Follow-up to the buffered widget swap: the solo/held board refreshed via a bespoke
self-rescheduling loop that never re-evaluated the schedule — so a board could outlive
its daypart, and a newly-active sibling item was never picked up (the player stuck on
the board). Delete the duplicate loop entirely and advance via nextItem in both the
held (WIDGET_SOLO_REFRESH_MS cadence) and rotating (duration) cases: nextItem
re-evaluates the schedule every cycle and re-renders the held board through the buffered
swap (still no flash), and drops the duplicate code path that caused the bug.

Verified: the timer-lifecycle harness (6 scenarios / 68 assertions) still passes,
including widget->video transition and the leak/timer-count checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-17 13:42:12 -05:00 committed by GitHub
parent 5c1cb4b992
commit bb6c7597da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -423,6 +423,15 @@
// playback muted). // playback muted).
let userHasInteracted = false; let userHasInteracted = false;
let advanceTimer = null; let advanceTimer = null;
// Buffered widget swap (#directory-board black-cycle): build the next widget iframe
// behind the current content and reveal it only on 'load', so a widget reload never
// blanks the screen. WIDGET_SWAP_TIMEOUT_MS reveals anyway if 'load' never fires (a
// network hang must not leave a frozen/blank board). A solo/held widget re-fetches its
// data every WIDGET_SOLO_REFRESH_MS — decoupled from duration_sec, because a static
// board re-querying the DB + re-rendering every few seconds, fleet-wide, is pure waste.
const WIDGET_SWAP_TIMEOUT_MS = 8000;
const WIDGET_SOLO_REFRESH_MS = 60000;
let pendingWidgetSwap = null; // { iframe, timer } while a new widget iframe loads
// Per-zone rotation timers (multi-zone). Each zone advances independently on // Per-zone rotation timers (multi-zone). Each zone advances independently on
// its own interval, decoupled from the fullscreen advanceTimer/nextItem. // its own interval, decoupled from the fullscreen advanceTimer/nextItem.
let zoneTimers = {}; let zoneTimers = {};
@ -2035,8 +2044,16 @@
zoneTimers = {}; zoneTimers = {};
} }
function teardownCurrentMedia() { function teardownCurrentMedia(keep) {
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; } // On a buffered widget reveal (keep set) the widget's advance/refresh timer was just
// armed by renderContent and must survive; only a real teardown cancels it.
if (!keep && advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
// Cancel any in-flight buffered widget swap so its deferred reveal can't fire after
// we've torn down (which would drop the incoming content). reveal() nulls this before
// calling teardownCurrentMedia(iframe), so preserving `keep` is safe.
if (pendingWidgetSwap && pendingWidgetSwap.iframe !== keep) {
clearTimeout(pendingWidgetSwap.timer); pendingWidgetSwap = null;
}
clearZoneTimers(); clearZoneTimers();
const container = document.getElementById('playerContainer'); const container = document.getElementById('playerContainer');
if (container) { if (container) {
@ -2048,7 +2065,13 @@
v.load(); v.load();
} catch (e) { /* element may already be detached */ } } catch (e) { /* element may already be detached */ }
}); });
container.innerHTML = ''; if (keep) {
// Buffered widget swap: drop the outgoing content but keep the freshly-loaded
// iframe we're swapping in.
Array.from(container.children).forEach(ch => { if (ch !== keep) { try { ch.remove(); } catch (e) {} } });
} else {
container.innerHTML = '';
}
} }
// #146 fix: also release currentVideoEl even if it was DETACHED from the container — // #146 fix: also release currentVideoEl even if it was DETACHED from the container —
// a detached-but-playing <video> keeps emitting audio and the querySelectorAll above // a detached-but-playing <video> keeps emitting audio and the querySelectorAll above
@ -2064,7 +2087,81 @@
currentVideoEl = null; currentVideoEl = null;
} }
// Discard the in-flight buffered swap — superseded by a newer render, OR timed out (the
// new iframe never loaded, e.g. server unreachable). Remove the still-hidden frame and
// drop its timer so a late 'load' can't fire against stale state; the last-good board
// already on screen is left untouched. Shared by the supersede and timeout paths.
function discardPendingSwap() {
if (!pendingWidgetSwap) return;
clearTimeout(pendingWidgetSwap.timer);
try { pendingWidgetSwap.iframe.remove(); } catch (e) {}
pendingWidgetSwap = null;
}
// Buffered widget render (#directory-board black-cycle): build the new widget iframe
// BEHIND the current content (hidden) and reveal it only once it fires 'load' — then tear
// down the outgoing content. Kills the black flash on every widget transition, and lets a
// solo board refresh for freshness without ever blanking. On a load timeout we keep the
// last-good board and discard the dead frame (see below) rather than reveal a blank one.
function renderWidgetBuffered(item) {
const container = document.getElementById('playerContainer');
container.style.display = 'block';
// A newer render supersedes a still-loading swap (rapid re-render / playlist churn).
discardPendingSwap();
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render`;
// Positioned + sized by the `#playerContainer > iframe` CSS rule. Hidden while it
// loads so its black background never shows over the outgoing content.
iframe.style.background = '#000';
iframe.style.visibility = 'hidden';
iframe.allow = 'autoplay; fullscreen';
// Sandbox into a unique origin so widget scripts can't read window.parent state.
iframe.setAttribute('sandbox', 'allow-scripts');
const reveal = () => {
if (!pendingWidgetSwap || pendingWidgetSwap.iframe !== iframe) return; // superseded / discarded
clearTimeout(pendingWidgetSwap.timer);
pendingWidgetSwap = null;
iframe.style.visibility = 'visible';
teardownCurrentMedia(iframe); // remove the outgoing content, keep this iframe
if (PREVIEW_MODE && item.widget_type === 'webpage') addWebpageNote(container); // #104
};
iframe.addEventListener('load', reveal);
container.appendChild(iframe);
// Timeout: the new iframe never loaded (network hang). DON'T reveal a maybe-blank frame —
// keep the last-good board visible and discard the dead hidden iframe through the shared
// cleanup (so it can't leak or fire a late 'load'). The solo refresh / next advance retries,
// so a transient server blip self-heals without ever showing black.
pendingWidgetSwap = { iframe, timer: setTimeout(discardPendingSwap, WIDGET_SWAP_TIMEOUT_MS) };
}
function renderContent(item) { function renderContent(item) {
// Cancel any pending advance/refresh timer up front so a prior item's timer (incl. a
// self-rescheduling widget refresh) can't fire against the new content.
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
// Fullscreen (non-wall) widget: buffered swap — never blank on reload. Runs BEFORE the
// generic teardown (which would black the screen), and owns its own refresh/advance
// timer below. Multi-zone widgets go through renderZones; wall+widget keeps the legacy
// path.
const isZones = !!(layout && layout.zones && layout.zones.length > 1 && !wallConfig);
if (item && item.widget_id && !isZones && !wallConfig) {
renderWidgetBuffered(item);
// Group members run no local timer (their schedule tick drives the index).
if (!groupSync) {
// Advance via nextItem in BOTH cases so the schedule is re-evaluated every cycle
// (a closed daypart / newly-active sibling is honored) — never a bespoke loop that
// re-renders blind to the schedule. A solo/held board (nextActiveIndex === current)
// just does it on a slow cadence: nextItem re-selects this same item and re-renders
// it through the buffered swap (data refresh, no flash). A rotating playlist advances
// on the item's duration.
const held = nextActiveIndex(currentIndex) === currentIndex;
advanceTimer = setTimeout(nextItem, held ? WIDGET_SOLO_REFRESH_MS : (item.duration_sec || 30) * 1000);
}
return;
}
teardownCurrentMedia(); teardownCurrentMedia();
const container = document.getElementById('playerContainer'); const container = document.getElementById('playerContainer');