Files
deepdrft/DeepDrftPublic/Interop/layout/spacer.ts
T

122 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}