From bb6c7597dae6550cd34aa0acf488b1b654e4e345 Mon Sep 17 00:00:00 2001 From: screentinker Date: Fri, 17 Jul 2026 13:42:12 -0500 Subject: [PATCH] fix(web-player): buffered widget swap + schedule-aware solo-board hold (directory-board black flicker) (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- server/player/index.html | 103 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/server/player/index.html b/server/player/index.html index 6ed46a4..a2d3872 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -423,6 +423,15 @@ // playback muted). let userHasInteracted = false; 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 // its own interval, decoupled from the fullscreen advanceTimer/nextItem. let zoneTimers = {}; @@ -2035,8 +2044,16 @@ zoneTimers = {}; } - function teardownCurrentMedia() { - if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; } + function teardownCurrentMedia(keep) { + // 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(); const container = document.getElementById('playerContainer'); if (container) { @@ -2048,7 +2065,13 @@ v.load(); } 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 — // a detached-but-playing