Merge p20-theater-visualizer-flash into dev
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user