From 670eaab34d2b6a0170dca15d4080eb8d6de54dc6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 22 Jun 2026 08:19:53 -0400 Subject: [PATCH] fix(visualizer): coalesce --player-height publish so Theater ease doesn't thrash the WebGL backing store --- DeepDrftPublic/Interop/layout/spacer.ts | 95 ++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 10 deletions(-) diff --git a/DeepDrftPublic/Interop/layout/spacer.ts b/DeepDrftPublic/Interop/layout/spacer.ts index 72173be..0a640f9 100644 --- a/DeepDrftPublic/Interop/layout/spacer.ts +++ b/DeepDrftPublic/Interop/layout/spacer.ts @@ -10,14 +10,81 @@ * read it. One observer at a time, re-pointed on each `observe` call; the var * resets to 0 on `unobserve` (player minimized / disposed) so the spacer * collapses. + * + * COALESCING (Phase 20 theater-flash fix). `--player-height` has two consumers: + * the layout spacer div AND the ambient WaveformVisualizer backdrop, whose + * `bottom` inset is this var (WaveformVisualizer.razor.css `.mix-waveform-bg`). + * Moving that inset changes the visualizer canvas's CSS box, which fires the + * renderer's own canvas ResizeObserver — and a GL resize CLEARS the backing + * store. That is correct and cheap for a discrete bar-height change (breakpoint + * reflow, minimize/expand, error banner). But Theater Mode eases the player bar's + * "now showing" band open/closed over ~0.45s via a CSS grid-rows transition, so + * the bar height changes EVERY FRAME of the ease. Mirroring each intermediate + * frame here would re-clear the GL backing store ~27×, reading as a flash. + * + * The fix coalesces the publish with a LEADING + TRAILING edge: the first change + * after a quiet period is written immediately (so a discrete jump — the common + * case — has zero added latency and the clip never lags), then a rapid STREAM of + * further changes (an animated transition) is debounced and only its SETTLED + * end-state is written. So a Theater ease resizes the visualizer at most twice + * (leading 1px move + final settle) instead of once per frame. The settled value + * is always the last write, so at-rest sizing/clip stays exact; and this remains + * the SOLE writer of `--player-height`, so the renderer's ResizeObserver stays the + * sole canvas size writer (its invariant is untouched). */ const HEIGHT_VAR = '--player-height'; -let observer: ResizeObserver | null = null; -function writeHeight(px: number): void { +/** + * Quiet window (ms) after which a pending settled height is flushed. One change + * then silence (a discrete reflow) flushes after this delay but was ALSO written + * on the leading edge, so the trailing flush is a no-op — discrete jumps pay no + * latency. A continuous transition keeps resetting this timer until it ends, then + * flushes the final height once. ~80ms comfortably exceeds a frame interval (so a + * mid-ease frame never trips an early flush) yet settles promptly after the ease. + */ +const SETTLE_MS = 80; + +let observer: ResizeObserver | null = null; +let lastWritten = -1; +let pendingHeight = -1; +let settleTimer: number | null = null; + +function setVar(px: number): void { // Round up so sub-pixel heights never leave a hairline of overlap. - document.documentElement.style.setProperty(HEIGHT_VAR, `${Math.ceil(px)}px`); + const rounded = Math.ceil(px); + if (rounded === lastWritten) return; + lastWritten = rounded; + document.documentElement.style.setProperty(HEIGHT_VAR, `${rounded}px`); +} + +/** + * Publish a measured height with leading + trailing coalescing. Leading: if no + * settle is pending, this is the first change after a quiet period — write it now. + * Trailing: (re)arm the settle timer so the final value of a rapid stream lands + * once the stream stops. + */ +function publishHeight(px: number): void { + pendingHeight = px; + if (settleTimer === null) { + // Leading edge — discrete jumps land immediately; the first frame of a + // transition lands too (one resize), then the rest is debounced below. + setVar(px); + } + if (settleTimer !== null) { + clearTimeout(settleTimer); + } + settleTimer = window.setTimeout(() => { + settleTimer = null; + setVar(pendingHeight); + }, SETTLE_MS); +} + +function measure(entry: ResizeObserverEntry): number { + // Prefer the border-box measurement; fall back to contentRect on the + // (older) engines that don't populate borderBoxSize. + const box = entry.borderBoxSize?.[0]; + return box ? box.blockSize : entry.contentRect.height; } export function observe(element: Element): void { @@ -27,20 +94,28 @@ export function observe(element: Element): void { observer = new ResizeObserver(entries => { const entry = entries[0]; if (!entry) return; - // Prefer the border-box measurement; fall back to contentRect on the - // (older) engines that don't populate borderBoxSize. - const box = entry.borderBoxSize?.[0]; - writeHeight(box ? box.blockSize : entry.contentRect.height); + publishHeight(measure(entry)); }); observer.observe(element); // Seed synchronously so the spacer is correct on this frame, before the - // first ResizeObserver callback fires. - writeHeight(element.getBoundingClientRect().height); + // first ResizeObserver callback fires. A fresh observe target is a discrete + // change, so write it straight through (bypassing the debounce) — re-pointing + // the observer (e.g. expanded <-> minimized) must not lag behind a settle. + if (settleTimer !== null) { + clearTimeout(settleTimer); + settleTimer = null; + } + setVar(element.getBoundingClientRect().height); } export function unobserve(): void { observer?.disconnect(); observer = null; - writeHeight(0); + if (settleTimer !== null) { + clearTimeout(settleTimer); + settleTimer = null; + } + pendingHeight = -1; + setVar(0); }