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:
@@ -36,10 +36,9 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
// error banner.
|
// error banner.
|
||||||
//
|
//
|
||||||
// _miniDock is the minimized FAB container. We observe it in minimized state so
|
// _miniDock is the minimized FAB container. We observe it in minimized state so
|
||||||
// --player-height stays non-zero (the FAB's actual height) and the WaveformVisualizer
|
// --player-height stays non-zero (the FAB's actual height). The player-spacer's
|
||||||
// clips to the top of the FAB rather than extending to the viewport bottom (fix §1).
|
// .minimized class uses a hardcoded 60px and ignores the var, so this is belt-and-
|
||||||
// The player-spacer's .minimized class uses a hardcoded 60px and ignores the var,
|
// braces; the var's sole live consumer is the spacer's .expanded height.
|
||||||
// so publishing the FAB height here does not regress the spacer.
|
|
||||||
private ElementReference _playerRoot;
|
private ElementReference _playerRoot;
|
||||||
private ElementReference _miniDock;
|
private ElementReference _miniDock;
|
||||||
private ElementReference _lastObservedElement;
|
private ElementReference _lastObservedElement;
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
/* Full-viewport fixed backdrop. Sits behind the detail content (.mix-detail-foreground is z-index:1)
|
/* Full-viewport fixed backdrop. Sits behind the detail content (.mix-detail-foreground is z-index:1)
|
||||||
and never intercepts pointer events — except the zoom slider, which re-enables them on itself.
|
and never intercepts pointer events — except the zoom slider, which re-enables them on itself.
|
||||||
|
|
||||||
Footer clip (Phase 10 W1, spec §2c): the backdrop must stop cleanly ABOVE the audio player bar so
|
Anchored to the viewport bottom (`inset: 0` — fills the whole screen). The chrome that must occlude
|
||||||
no lava/waveform pixel paints over or under it. `overflow: hidden` clips the canvas to this box, and
|
it paints OVER it on z-index, not by clipping the box: the app bar (z 100), the docked player bar
|
||||||
`bottom` is inset by `--player-height`, which AudioPlayerBar publishes on :root via its ResizeObserver
|
(z 1200/1300), the site footer (z 1 stacking context), and the layout spacer (z 1 stacking context,
|
||||||
(Interop/layout/spacer.ts). The observer now points at whichever element is live:
|
`--deepdrft-page-surface` background) all sit above this z-0 layer. Where those elements are inset
|
||||||
expanded → the full player dock (tracks breakpoint reflow + error-banner growth)
|
from the screen edges or scrolled below the fold, the visualizer fills the gap continuously — no
|
||||||
minimized → the minimized-dock FAB container (~56–60 px)
|
page-background strip around the inset player bar. `overflow: hidden` clips the canvas to this box.
|
||||||
so --player-height is always non-zero while the player is mounted and the clip line follows the bar in
|
|
||||||
BOTH states (fix §1 / p10-reframe-w1-fix). The 0px fallback keeps the backdrop full-height on any
|
NOTE: this box is decoupled from `--player-height` (it no longer reads that var). The renderer's own
|
||||||
page that does not host the player. */
|
ResizeObserver therefore never fires on a player-bar height change, so an eased Theater-Mode collapse
|
||||||
|
can no longer clear the GL backing store mid-ease (the former theater-flash source). See
|
||||||
|
Interop/layout/spacer.ts. */
|
||||||
.mix-waveform-bg {
|
.mix-waveform-bg {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
bottom: var(--player-height, 0px);
|
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
/* Spacer to prevent content overlap */
|
/* Spacer to prevent content overlap. position:relative + z-index:1 establishes a stacking context
|
||||||
|
that paints above the WaveformVisualizer backdrop (fixed, z-index:0), and the opaque page-surface
|
||||||
|
background makes the spacer read as solid page — occluding the visualizer where it sits in flow,
|
||||||
|
the same way the app bar covers the top. Theme-aware alias, so it inverts for free. */
|
||||||
.player-spacer {
|
.player-spacer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--deepdrft-page-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-spacer.expanded {
|
.player-spacer.expanded {
|
||||||
|
|||||||
@@ -11,44 +11,20 @@
|
|||||||
* resets to 0 on `unobserve` (player minimized / disposed) so the spacer
|
* resets to 0 on `unobserve` (player minimized / disposed) so the spacer
|
||||||
* collapses.
|
* collapses.
|
||||||
*
|
*
|
||||||
* COALESCING (Phase 20 theater-flash fix). `--player-height` has two consumers:
|
* SOLE CONSUMER (post visualizer-viewport-framing). `--player-height` now feeds
|
||||||
* the layout spacer div AND the ambient WaveformVisualizer backdrop, whose
|
* ONLY the layout spacer div (MainLayout.razor.css `.player-spacer.expanded`).
|
||||||
* `bottom` inset is this var (WaveformVisualizer.razor.css `.mix-waveform-bg`).
|
* The ambient WaveformVisualizer backdrop is anchored `inset: 0` and no longer
|
||||||
* Moving that inset changes the visualizer canvas's CSS box, which fires the
|
* reads this var, so a height change here only resizes the spacer's `height` — a
|
||||||
* renderer's own canvas ResizeObserver — and a GL resize CLEARS the backing
|
* cheap, side-effect-free layout write. There is no GL backing store to clear and
|
||||||
* store. That is correct and cheap for a discrete bar-height change (breakpoint
|
* no theater-flash to debounce against, so we publish every observed frame
|
||||||
* reflow, minimize/expand, error banner). But Theater Mode eases the player bar's
|
* directly: the spacer tracks the bar exactly through the Theater-Mode ease with
|
||||||
* "now showing" band open/closed over ~0.45s via a CSS grid-rows transition, so
|
* no settle lag, and this stays the SOLE writer of `--player-height`.
|
||||||
* 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';
|
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 observer: ResizeObserver | null = null;
|
||||||
let lastWritten = -1;
|
let lastWritten = -1;
|
||||||
let pendingHeight = -1;
|
|
||||||
let settleTimer: number | null = null;
|
|
||||||
|
|
||||||
function setVar(px: number): void {
|
function setVar(px: number): void {
|
||||||
// Round up so sub-pixel heights never leave a hairline of overlap.
|
// 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`);
|
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 {
|
function measure(entry: ResizeObserverEntry): number {
|
||||||
// Prefer the border-box measurement; fall back to contentRect on the
|
// Prefer the border-box measurement; fall back to contentRect on the
|
||||||
// (older) engines that don't populate borderBoxSize.
|
// (older) engines that don't populate borderBoxSize.
|
||||||
@@ -94,28 +48,17 @@ export function observe(element: Element): void {
|
|||||||
observer = new ResizeObserver(entries => {
|
observer = new ResizeObserver(entries => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
publishHeight(measure(entry));
|
setVar(measure(entry));
|
||||||
});
|
});
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
|
|
||||||
// Seed synchronously so the spacer is correct on this frame, before the
|
// Seed synchronously so the spacer is correct on this frame, before the
|
||||||
// first ResizeObserver callback fires. A fresh observe target is a discrete
|
// first ResizeObserver callback fires.
|
||||||
// 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);
|
setVar(element.getBoundingClientRect().height);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unobserve(): void {
|
export function unobserve(): void {
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
observer = null;
|
observer = null;
|
||||||
if (settleTimer !== null) {
|
|
||||||
clearTimeout(settleTimer);
|
|
||||||
settleTimer = null;
|
|
||||||
}
|
|
||||||
pendingHeight = -1;
|
|
||||||
setVar(0);
|
setVar(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user