/** * Player-height spacer observer. * * The audio player docks `position: fixed` to the viewport bottom, so it sits * outside normal flow and would overlap page content. A spacer div in the layout * reserves the equivalent space — but the player's height is not constant: it * reflows across the four breakpoints and grows when an error banner appears. A * static height can't track that, so we mirror the player's live border-box * height into the `--player-height` custom property on :root and let the spacer * 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'; /** * 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. 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 { unobserve(); if (!element) return; observer = new ResizeObserver(entries => { const entry = entries[0]; if (!entry) return; publishHeight(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; } setVar(element.getBoundingClientRect().height); } export function unobserve(): void { observer?.disconnect(); observer = null; if (settleTimer !== null) { clearTimeout(settleTimer); settleTimer = null; } pendingHeight = -1; setVar(0); }