Anchor ambient visualizer to viewport bottom; occlude via z-index not clip

Drop the --player-height bottom inset so the fixed visualizer fills the
viewport; the inset player bar no longer leaves a page-background gap. The
spacer now occludes via opaque page-surface + z-index. Visualizer no longer
reads --player-height, so spacer.ts coalescing is removed.
This commit is contained in:
daniel-c-harvey
2026-06-24 09:06:45 -04:00
parent 1e063d95f4
commit cb899a2913
4 changed files with 31 additions and 82 deletions
+10 -67
View File
@@ -11,44 +11,20 @@
* 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).
* SOLE CONSUMER (post visualizer-viewport-framing). `--player-height` now feeds
* ONLY the layout spacer div (MainLayout.razor.css `.player-spacer.expanded`).
* The ambient WaveformVisualizer backdrop is anchored `inset: 0` and no longer
* reads this var, so a height change here only resizes the spacer's `height` — a
* cheap, side-effect-free layout write. There is no GL backing store to clear and
* no theater-flash to debounce against, so we publish every observed frame
* directly: the spacer tracks the bar exactly through the Theater-Mode ease with
* no settle lag, and this stays the SOLE writer of `--player-height`.
*/
const HEIGHT_VAR = '--player-height';
/**
* 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.
@@ -58,28 +34,6 @@ function setVar(px: number): void {
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.
@@ -94,28 +48,17 @@ export function observe(element: Element): void {
observer = new ResizeObserver(entries => {
const entry = entries[0];
if (!entry) return;
publishHeight(measure(entry));
setVar(measure(entry));
});
observer.observe(element);
// Seed synchronously so the spacer is correct on this frame, before the
// 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;
}
// first ResizeObserver callback fires.
setVar(element.getBoundingClientRect().height);
}
export function unobserve(): void {
observer?.disconnect();
observer = null;
if (settleTimer !== null) {
clearTimeout(settleTimer);
settleTimer = null;
}
pendingHeight = -1;
setVar(0);
}