feat(visualizer): Phase 15 control-deck rework
Centered tinted MudOverlay (NowPlayingCard chrome) replaces the anchored popover; eight dials become a deterministic three-row LAVA/WAVE layout; lava + waveform lamp toggles drive a genuine per-subsystem draw-skip; scroll/zoom becomes a slider; playful tooltips; green=interactive/light=static.
This commit is contained in:
@@ -527,6 +527,19 @@ export interface WaveformVisualizerHandle {
|
||||
setCollisionStrength(value: number): void;
|
||||
/** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
|
||||
setWaveformWidth(value: number): void;
|
||||
/**
|
||||
* Enable/disable the LAVA subsystem (Phase 15). When disabled the wax is genuinely NOT rendered:
|
||||
* the per-frame physics step is skipped and zero blobs are uploaded (uBlobCount = 0), so the
|
||||
* shader's blob loop unions nothing — no render cost, not a dimmed/visible=false uniform (§10.1).
|
||||
*/
|
||||
setLavaEnabled(enabled: boolean): void;
|
||||
/**
|
||||
* Enable/disable the WAVEFORM-ribbon subsystem (Phase 15). When disabled the ribbon SDF is skipped
|
||||
* in the shader (uWaveformEnabled = 0 makes waveformSdf return "fully outside") and its CPU
|
||||
* collision boundary is dropped (sampleLoudnessAt reads 0), so the ribbon contributes nothing to
|
||||
* the surface and the wax stops bouncing off an invisible wall — a genuine skip, not a dim (§10.1).
|
||||
*/
|
||||
setWaveformEnabled(enabled: boolean): void;
|
||||
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
|
||||
refreshTheme(): void;
|
||||
dispose(): void;
|
||||
@@ -613,6 +626,8 @@ uniform float uPlayheadSeconds; // current playback position (per-frame)
|
||||
uniform float uTimeSeconds; // monotonic clock (per-frame) — drives field morph
|
||||
uniform float uVisibleSeconds; // zoom: window time-span (per change)
|
||||
uniform float uWaveformWidth; // [0,1] R2: scales the ribbon half-width (narrow the band for lava room)
|
||||
uniform float uWaveformEnabled; // [0,1] Phase 15: 1 = ribbon drawn, 0 = ribbon subsystem skipped (no
|
||||
// contribution to the surface — see waveformSdf's early-out)
|
||||
uniform float uCohesion; // [0,1] Phase 10: fluid viscosity/cohesion — high = crisp spheres,
|
||||
// low = gooey/deformed (drives the smin blend width + wobble below)
|
||||
// NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms
|
||||
@@ -877,6 +892,10 @@ vec3 anchorAtPhase(float phase) {
|
||||
// distance to that vertical ribbon band. Loudness at neighbour rows is NOT re-stacked
|
||||
// here (the per-row geometry from Wave 1 is already smooth); the band is the ribbon.
|
||||
float waveformSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight) {
|
||||
// Phase 15: ribbon subsystem off → return "fully outside" so the smin union ignores it entirely
|
||||
// (a far positive distance never pulls the surface toward the centre line). This is the genuine
|
||||
// skip — the ribbon contributes nothing, rather than being drawn-then-hidden.
|
||||
if (uWaveformEnabled < 0.5) return 1e9;
|
||||
// Mix-time at this row: rows below the now-line are future audio, above are past.
|
||||
float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight;
|
||||
float amp = sampleAt(t); // loudness 0..1 at this row
|
||||
@@ -1072,6 +1091,8 @@ function noopHandle(): WaveformVisualizerHandle {
|
||||
setFluidViscosity() {},
|
||||
setCollisionStrength() {},
|
||||
setWaveformWidth() {},
|
||||
setLavaEnabled() {},
|
||||
setWaveformEnabled() {},
|
||||
refreshTheme() {},
|
||||
dispose() {},
|
||||
};
|
||||
@@ -1129,6 +1150,7 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
|
||||
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
|
||||
waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'),
|
||||
waveformEnabled: gl.getUniformLocation(program, 'uWaveformEnabled'),
|
||||
cohesion: gl.getUniformLocation(program, 'uCohesion'),
|
||||
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
|
||||
colorNavy: gl.getUniformLocation(program, 'uColorNavy'),
|
||||
@@ -1167,6 +1189,12 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
|
||||
// LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1).
|
||||
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
|
||||
// Phase 15 — subsystem on/off. Default ON (mirrors C# DefaultLavaEnabled / DefaultWaveformEnabled),
|
||||
// so out of the box both subsystems run exactly as before. "Off" is a genuine draw-skip: lava off
|
||||
// skips stepPhysics + uploads zero blobs; waveform off skips the ribbon SDF (uWaveformEnabled) and
|
||||
// its CPU collision boundary. With both off, draw() short-circuits to a clear — no SDF eval at all.
|
||||
let lavaEnabled = true;
|
||||
let waveformEnabled = true;
|
||||
|
||||
/** Effective ribbon-width fraction for the current width knob (Phase 10 §3.7): the knob's [0,1]
|
||||
* travel maps onto the useful 10%–95% band (full-width 100% read too wide; sub-10% vanished).
|
||||
@@ -1365,6 +1393,9 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
* boundary matches the rendered waveform exactly. Reads the retained datum.samples.
|
||||
*/
|
||||
function sampleLoudnessAt(timeSeconds: number): number {
|
||||
// Phase 15: waveform off → no ribbon boundary. Reporting zero loudness collapses the collision
|
||||
// half-width to 0, so wax never bounces off an invisible wall (matches the skipped ribbon draw).
|
||||
if (!waveformEnabled) return 0;
|
||||
const d = datum;
|
||||
if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0;
|
||||
const n = d.sampleCount;
|
||||
@@ -1731,6 +1762,14 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// Phase 15 — both subsystems off: there is nothing to draw. Short-circuit past the physics
|
||||
// step, the blob upload, and the full-screen SDF evaluation entirely — a genuine no-render-cost
|
||||
// empty field (§10.1), not a shader that runs and outputs transparent. The cleared (transparent)
|
||||
// buffer above is the result. The gradient/playhead clocks are not advanced while fully off;
|
||||
// they resume from their held value when a subsystem is turned back on (no visible snap, since
|
||||
// an off field shows nothing to snap).
|
||||
if (!lavaEnabled && !waveformEnabled) return;
|
||||
|
||||
gl.useProgram(program);
|
||||
gl.bindVertexArray(vao);
|
||||
|
||||
@@ -1756,6 +1795,7 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
// separate dirty-tracking needed for scalars/vec3s).
|
||||
gl.uniform1f(u.visibleSeconds, visibleSeconds);
|
||||
gl.uniform1f(u.waveformWidth, effectiveWaveformWidth());
|
||||
gl.uniform1f(u.waveformEnabled, waveformEnabled ? 1 : 0);
|
||||
gl.uniform1f(u.cohesion, fluidViscosity);
|
||||
gl.uniform1f(u.gradientPhase, gradientPhase);
|
||||
gl.uniform3fv(u.colorNavy, theme.navy);
|
||||
@@ -1769,8 +1809,15 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
const nowMs = performance.now();
|
||||
const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000);
|
||||
lastPhysicsMs = nowMs;
|
||||
stepPhysics(physicsDt);
|
||||
const liveCount = packBlobs();
|
||||
// Phase 15 — lava off: skip the CPU physics step AND upload zero blobs. The shader's blob loop
|
||||
// (`for … if (i >= uBlobCount) break;`) then unions nothing, so no wax is drawn and no physics
|
||||
// runs — a genuine subsystem skip (§10.1), not a hidden-but-simulated field. The wax keeps its
|
||||
// last positions for free (we just stop integrating); turning lava back on resumes from there.
|
||||
let liveCount = 0;
|
||||
if (lavaEnabled) {
|
||||
stepPhysics(physicsDt);
|
||||
liveCount = packBlobs();
|
||||
}
|
||||
gl.uniform4fv(u.blobs, blobUpload);
|
||||
gl.uniform1i(u.blobCount, liveCount);
|
||||
|
||||
@@ -2156,6 +2203,22 @@ export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
// Phase 15 — subsystem enables. "Off" is a genuine draw-skip (§10.1): lava off stops the physics
|
||||
// step + uploads zero blobs (handled in draw()); waveform off skips the ribbon SDF + collision
|
||||
// boundary. redrawOnce guards the fully-stopped (tab-hidden) case so the toggle lands a still
|
||||
// frame when the loop resumes — including the both-off → cleared empty field.
|
||||
setLavaEnabled(enabled: boolean): void {
|
||||
lavaEnabled = enabled;
|
||||
debugLog(`setLavaEnabled → ${enabled}.`);
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
setWaveformEnabled(enabled: boolean): void {
|
||||
waveformEnabled = enabled;
|
||||
debugLog(`setWaveformEnabled → ${enabled}.`);
|
||||
if (rafId === null) redrawOnce();
|
||||
},
|
||||
|
||||
refreshTheme(): void {
|
||||
theme = readTheme();
|
||||
if (rafId === null) redrawOnce();
|
||||
|
||||
@@ -376,49 +376,144 @@ h2, h3, h4, h5, h6,
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
WAVEFORM VISUALIZER CONTROL PANEL (Phase 12 §3d-revised / §3g)
|
||||
The eight-knob panel hosted inside WaveformVisualizerControlPopover. MudPopover
|
||||
PORTALS its content out of the component's DOM subtree, so Blazor CSS isolation
|
||||
never reaches the rendered panel — its chrome must live here in the global sheet,
|
||||
not in the scoped WaveformVisualizerControls.razor.css. (The scoped file keeps only
|
||||
the inline-bar fallback Mix's legacy TopRowCenter mount uses, which is not portaled.)
|
||||
WAVEFORM VISUALIZER CONTROL PANEL (Phase 12 §3d-revised / §3g → Phase 15 re-layout)
|
||||
The control deck hosted inside WaveformVisualizerControlPopover, now a screen-centered
|
||||
tinted MudOverlay (Phase 15 §4). MudOverlay — like the former MudPopover — PORTALS its
|
||||
content out of the component's DOM subtree, so Blazor CSS isolation never reaches the
|
||||
rendered panel: its chrome, the three-row/section LAYOUT, the section labels, the slider,
|
||||
and the toggles all live here in the global sheet, not in the scoped
|
||||
WaveformVisualizerControls.razor.css. (The scoped file keeps only the legacy inline-bar
|
||||
fallback Mix's old TopRowCenter mount used, which is not portaled.)
|
||||
|
||||
The waveform-visualizer-control-panel class is applied ONLY when the component's
|
||||
PanelChrome="true" parameter is set — which WaveformVisualizerControlPopover does
|
||||
and Mix's inline mount does NOT. This prevents the chrome from leaking onto Mix's
|
||||
inline controls bar.
|
||||
PanelChrome="true" parameter is set — which the popover host does and Mix's inline mount
|
||||
does NOT — so the chrome never leaks onto an inline bar.
|
||||
|
||||
The NowPlaying Hero look (§3g): dark-navy ground, green-accent knobs, light icons,
|
||||
muted-navy filler — all from the deepdrft-* token source of truth, no hardcoded hex.
|
||||
The RadialKnob reads --mud-palette-* for its arc/track/center/label colours; we pin
|
||||
those palette vars to the Hero tokens ON THE PANEL so the panel reads the same
|
||||
navy/green/off-white regardless of the page's light/dark theme.
|
||||
CHROME (Phase 15 §5 — NowPlayingCard treatment): SQUARE corners, lighter-navy ground
|
||||
(navy-mid), a thin LIGHT border (--deepdrft-border-light, the NowPlayingCard 0.12-alpha
|
||||
light-on-dark idiom as a token). All token-sourced; no hardcoded hex.
|
||||
|
||||
COLOUR PRINCIPLE (§5 — green = interactive, light = non-interactive): the RadialKnob reads
|
||||
--mud-palette-* for its arc/pointer/center/label; we pin --mud-palette-primary to the green
|
||||
accent (interactive arcs/pointers) and --mud-palette-text-primary to light. Caption icons and
|
||||
section labels are LIGHT (static). The slider track/thumb and the lamp toggles are green.
|
||||
============================================================================= */
|
||||
.waveform-visualizer-control-panel.mix-visualizer-controls-bar {
|
||||
/* Dark-navy elevated panel ground (§3g: navy-mid for the elevated surface). */
|
||||
/* Lighter-navy elevated panel ground (§5: navy-mid). */
|
||||
background: var(--deepdrft-navy-mid);
|
||||
border: 1px solid var(--deepdrft-border-green);
|
||||
border-radius: 8px;
|
||||
/* Square corners + thin light border — NowPlayingCard chrome (§5). */
|
||||
border: 1px solid var(--deepdrft-border-light);
|
||||
border-radius: 0;
|
||||
/* Optional backdrop blur — cheap on a small modal panel, nice over the visualizer (§5). */
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 1rem 1.25rem;
|
||||
/* Popover panel: cap width so eight 64px knobs wrap to a tidy grid rather than one long bar.
|
||||
This OVERRIDES the inline-bar min-height reserve (which only matters for Mix's non-popover mount). */
|
||||
/* Three-row sectioned deck: stack the rows top-to-bottom; conditional rows reserve no permanent
|
||||
height (§3 reflow discipline). This OVERRIDES the inline-bar min-height + flex-wrap (which only
|
||||
matter for Mix's non-portaled legacy mount). */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
max-width: 340px;
|
||||
/* Pin the MudBlazor palette vars the portaled RadialKnob consumes to the Hero tokens. */
|
||||
--mud-palette-primary: var(--deepdrft-green-accent); /* knob value arc / pointer / center stroke */
|
||||
max-width: 420px;
|
||||
/* Pin the MudBlazor palette vars the portaled RadialKnob + slider consume. */
|
||||
--mud-palette-primary: var(--deepdrft-green-accent); /* knob arc/pointer + slider track/thumb (interactive) */
|
||||
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */
|
||||
--mud-palette-surface-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler (§3g) */
|
||||
--mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light (§3g) */
|
||||
--mud-palette-surface-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler */
|
||||
--mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light */
|
||||
}
|
||||
|
||||
/* Green-accent caption icons (§3g: light/green icons). MudIcon is portaled here too, so this is a
|
||||
plain global descendant selector — no ::deep, no scope attribute (CSS isolation does not reach
|
||||
inside the popover). */
|
||||
.waveform-visualizer-control-panel .waveform-visualizer-control-icon {
|
||||
color: var(--deepdrft-green-accent);
|
||||
/* ── Row layout (§3). Each row is a horizontal band; row 1 + row 3 space-between so the right-pinned
|
||||
control (color / width) hugs the right edge and never reflows when an inner control hides. ── */
|
||||
.waveform-visualizer-control-panel .wvc-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.waveform-visualizer-control-panel .wvc-row-mode,
|
||||
.waveform-visualizer-control-panel .wvc-row-section {
|
||||
/* space-between pushes the right-pinned control to the far edge; the left group holds the rest. */
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* The left group of row 1 (toggles + conditional collisions) flows left; the color knob is the
|
||||
space-between right sibling, so it stays put when collisions hides (§3). */
|
||||
.waveform-visualizer-control-panel .wvc-row-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
/* The right-pinned control (color in row 1, width in row 3) — sits as the space-between right sibling. */
|
||||
.waveform-visualizer-control-panel .wvc-row-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Section label "LAVA:" / "WAVE:" (§3, §5). NowPlayingCard .np-label TYPOGRAPHY (mono, uppercase,
|
||||
tracked), recoloured LIGHT — labels are static, so light by the colour principle (§5, §10.3). ── */
|
||||
.waveform-visualizer-control-panel .wvc-section-label {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-white);
|
||||
align-self: center;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── The lamp toggles (§3 row 1). Iconographic lit/unlit lamp glyph, GREEN because interactive (§5).
|
||||
Color="Color.Primary" already drives the glyph currentColor to the pinned green --mud-palette-primary;
|
||||
this just sizes the hit-target to read as a row-1 peer of the knobs. ── */
|
||||
.waveform-visualizer-control-panel .wvc-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── The scroll SLIDER (§8). Track/thumb green (the pinned --mud-palette-primary, interactive). Give it
|
||||
a sensible width so it reads as "position along a continuum" next to the rotary width knob. ── */
|
||||
.waveform-visualizer-control-panel .wvc-slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
min-width: 160px;
|
||||
flex: 1 1 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.waveform-visualizer-control-panel .wvc-slider .mud-slider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Caption icons + section labels render LIGHT (§5/§9 colour principle: static/decorative = light). MudIcon
|
||||
is portaled here too, so this is a plain global descendant selector — no ::deep, no scope attribute (CSS
|
||||
isolation does not reach inside the overlay). The knob arcs/pointers + slider stay green (interactive). */
|
||||
.waveform-visualizer-control-panel .waveform-visualizer-control-icon {
|
||||
color: var(--deepdrft-white);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── The modal overlay (Phase 15 §4). MudOverlay is already a full-viewport flex scrim that centers its
|
||||
content (.mud-overlay { display:flex; align-items:center; justify-content:center }), which gives the
|
||||
screen-centered panel on every host for free — we do NOT fight that positioning. We only (a) set the
|
||||
mild modal tint from the SINGLE --deepdrft-modal-scrim-alpha token (§10.5, one point of change) and
|
||||
(b) cap the centered content's height so a tall both-on deck scrolls inside the modal rather than
|
||||
overflowing the viewport. The overlay portals to the body, so these are plain global rules (no scope
|
||||
attribute). The doubled .mud-overlay-scrim.mud-overlay-dark selector (0,2,0) outranks MudBlazor's own
|
||||
.mud-overlay-dark (0,1,0), so the tint wins regardless of stylesheet load order. ── */
|
||||
.waveform-visualizer-control-overlay .mud-overlay-scrim.mud-overlay-dark {
|
||||
background-color: rgba(13, 27, 42, var(--deepdrft-modal-scrim-alpha));
|
||||
}
|
||||
|
||||
.waveform-visualizer-control-overlay .mud-overlay-content {
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 419.98px) {
|
||||
.deepdrft-track-detail-meta {
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user