diff --git a/DeepDrftPublic.Client/Controls/NowPlaying.razor b/DeepDrftPublic.Client/Controls/NowPlaying.razor index 06e2168..9c8b7a4 100644 --- a/DeepDrftPublic.Client/Controls/NowPlaying.razor +++ b/DeepDrftPublic.Client/Controls/NowPlaying.razor @@ -15,13 +15,12 @@ TrackEntryKey="@Player?.CurrentTrack?.EntryKey" /> - @* The lava-lamp popover trigger lands in the panel's top-right corner (full parity, §8e). Above the - canvas and pointer-enabled so the icon is clickable even though the visualizer layer is - pointer-events:none. *@ + @* The lava-lamp trigger lands in the panel's top-right corner (full parity, §8e). Above the canvas + and pointer-enabled so the icon is clickable even though the visualizer layer is + pointer-events:none. The panel itself opens screen-centered (Phase 15 §4 — no per-host anchor), + so only the icon size is host-specific now. *@
- +
@* Pulsing rings *@ diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs index f86171b..5fb5abd 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs @@ -265,9 +265,10 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable return; } - // Seed the module with the current state now that it exists. All seven control values - // come from the shared (session-persisted) state, so a mix opened mid-session seeds the - // module with the knob positions the listener left them at. + // Seed the module with the current state now that it exists. All control values (the eight + // dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state, + // so a mix opened mid-session seeds the module with the knob/toggle positions the listener + // left them at. await PushControlsAsync(); await PushDatumAsync(); await PushPlaybackAsync(); @@ -279,10 +280,11 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable await PushThemeIfChangedAsync(); } - // The controls bar mutated a knob on the shared state and raised Changed. Push all seven control - // values (cheap scalar interop). Each control now drives its own dedicated dial in the JS handle - // (lava reframe Wave R4) — scroll speed → visible-time-span, plus the six lava/colour dials; see - // PushControlsAsync. The bridge stays the sole owner of the JS module handle. + // The controls bar mutated a knob on the shared state and raised Changed. Push all ten control + // values (cheap scalar interop): the eight continuous dials plus the two subsystem enables. Each + // dial drives its own dedicated uniform in the JS handle (lava reframe Wave R4) — scroll speed → + // visible-time-span, plus the six lava/colour dials; see PushControlsAsync. The bridge stays the + // sole owner of the JS module handle. private void OnControlStateChanged() => InvokeAsync(async () => { await PushControlsAsync(); @@ -291,8 +293,9 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable // ── Bridge pushes. Each is a no-op until the module handle exists. ─────────────────────────── /// - /// Push the eight control values to the module from the shared state. Used to seed on first render - /// and to re-push when the controls bar signals a change. Each value is its own dedicated dial: + /// Push the control values to the module from the shared state — the eight continuous dials plus the + /// two Phase 15 subsystem enables. Used to seed on first render and to re-push when the controls + /// panel signals a change. Each value is its own dedicated dial / enable: /// /// scroll speed [0,1] is mapped onto the useful zoom band via /// and pushed through setScrollSpeed @@ -302,7 +305,9 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable /// fluid amount → setFluidAmount (blob count + volume); fluid viscosity → /// setFluidViscosity (cohesion / sphere-restoration) — the Phase 10 split of the /// former single density knob; - /// waveform width → the ribbon-extent uniform. + /// waveform width → the ribbon-extent uniform; + /// lava / waveform enabled → setLavaEnabled / setWaveformEnabled, the genuine + /// per-subsystem draw-skip (no physics / no blob upload, ribbon SDF skipped — §10.1). /// /// private async Task PushControlsAsync() @@ -319,6 +324,11 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable await _handle.InvokeVoidAsync("setFluidViscosity", ControlState.FluidViscosity); await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength); await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth); + // Phase 15 — the two subsystem enables. "Off" is a genuine draw-skip in the module (no physics, + // no blob upload / ribbon SDF skipped), not a dim. Pushed through the same Changed seam as the + // dials, so a toggle re-reads here exactly as a knob does. + await _handle.InvokeVoidAsync("setLavaEnabled", ControlState.LavaEnabled); + await _handle.InvokeVoidAsync("setWaveformEnabled", ControlState.WaveformEnabled); } /// diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor b/DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor index 14e9c59..e7289a2 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor @@ -1,56 +1,56 @@ @namespace DeepDrftPublic.Client.Controls @using DeepDrftShared.Client.Common -@* The single controls affordance, placed by an icon anywhere (Phase 12 §3d-revised). Closed state is - just the lava-lamp icon; clicking it floats the eight-knob WaveformVisualizerControls panel over the - surface. One panel, one popover host, reused on every host (Mix, Cut, Session, NowPlaying card) — the - SOLID seam: variance is the per-host anchor (§8e), never a forked popover. +@* The single controls affordance, placed by an icon anywhere (Phase 12 §3d-revised, re-primitived + Phase 15 §4). Closed state is just the lava-lamp icon; clicking it floats the control panel as a + SCREEN-CENTERED, tinted MODAL over the whole surface. One panel, one host, reused on every host (Mix, + Cut, Session, NowPlaying) — the SOLID seam. - Anchoring follows the SharePopover precedent: Fixed so the panel reads the trigger's bounding rect - rather than fighting CSS container tricks. AnchorOrigin/TransformOrigin are per-host - parameters (§8e) defaulted to bottom-right open-down — the cleanest case (Mix's TopRightAction corner); - tight hosts (the NowPlaying card) override to open away from the card body. + PRIMITIVE (Phase 15 §4): a centered MudOverlay, NOT an anchored MudPopover. The panel must read as + screen-centered regardless of where the lava-lamp icon sits (Mix corner, Cut/Session ambient, + NowPlaying corner). An anchored popover positions off the trigger's bounding rect — the wrong model + for "centered on the screen." So the icon is just an opener; the overlay hosts the panel in its centre + (the overlay is a full-viewport flex container — its content is centered by .waveform-visualizer-control- + overlay in the GLOBAL sheet, since the overlay portals out of this subtree). DarkBackground gives the + mild modal tint (alpha from the single --deepdrft-modal-scrim-alpha token, §10.5). There is therefore + no AnchorOrigin/TransformOrigin: a centered modal has no anchor (Phase 15 drops those parameters). - The popover owns NO control state and NO JS interop. The hosted WaveformVisualizerControls panel mutates - the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This host - only toggles open/closed and places the panel — it stays purely presentational. *@ + KNOB-DRAG SAFETY (Phase 15 §4, highest-risk detail): RadialKnob mounts its own full-viewport + position:fixed; z-index:9999 mouse-capture div WHILE dragging (RadialKnob.razor lines 5–9). That capture + div sits ABOVE the overlay scrim, so a knob drag's pointer-up lands on the capture div, never the scrim + — the overlay's OnClick does not fire mid-drag, so releasing the mouse outside the panel does NOT close + the modal. AutoClose is left OFF (the default) and dismissal is via the explicit scrim OnClick only, + carrying the SharePopover idiom forward under the new primitive. The panel stops click propagation so a + click INSIDE it never bubbles to the scrim's close handler. -@* Backdrop dismissal mirrors SharePopover: a viewport overlay closes on outside click. AutoClose stays - off so a knob drag (which can land pointer-up outside the panel's DOM subtree) does not dismiss. *@ - + The host owns NO control state and NO JS interop. The hosted WaveformVisualizerControls panel mutates + the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This + host only toggles open/closed and centers the panel — it stays purely presentational. *@ -@* Activator and popover share this wrapper so MudPopover anchors off the trigger's bounding rect. *@ -
- - - + + + - +@* The tinted modal scrim that also HOLDS the panel. DarkBackground = the mild tint; OnClick on the scrim + dismisses (knob-drag-safe, see header). The panel is the overlay's centered child; it stops click + propagation so an inside click is not a dismissal. Modal so focus/scroll stay on the panel. *@ + +
- -
+
+
@code { - /// - /// Where the panel anchors relative to the trigger icon (§8e). Defaults to opening down-from the - /// icon's bottom-right — fits Mix's top-right corner and the ambient hosts. Tight hosts (the - /// NowPlaying card) override to open away from the card body. - /// - [Parameter] public Origin AnchorOrigin { get; set; } = Origin.BottomRight; - - /// The panel's own corner that pins to (§8e). - [Parameter] public Origin TransformOrigin { get; set; } = Origin.TopRight; - /// Trigger-icon size. Defaults Large to match the Phase 10 Mix lava-lamp button. [Parameter] public Size IconSize { get; set; } = Size.Large; diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor b/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor index 8325e04..8f7bb35 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor @@ -1,139 +1,221 @@ @namespace DeepDrftPublic.Client.Controls +@using DeepDrftShared.Client.Common @using DeepDrftPublic.Client.Services @inject WaveformVisualizerControlState ControlState -@* The waveform visualizer control PANEL (Phase 12 §3d-revised). EIGHT continuous RadialKnobs — scroll - speed, gradient rotation speed, lava gravity, lava heat, fluid amount, fluid viscosity, collision - strength, waveform width — each its own dedicated control with a Material-icon caption. The single - "bubbles" knob is split into fluid-amount + fluid-viscosity (Phase 10 §5). +@* The waveform visualizer control PANEL (Phase 12 §3d-revised → Phase 15 re-layout). The control deck for + the lava-lamp visualizer: a deterministic THREE-ROW, sectioned layout that encodes what the visualizer + composes — a LAVA field and a WAVEFORM ribbon, each independently toggleable (Phase 15 §3): - This component is the PANEL CONTENT hosted inside WaveformVisualizerControlPopover. It no longer rides - an inline TopRowCenter bar; it lays out as a wrapped grid sized for a popover, styled to the NowPlaying - Hero look (§3g — dark-navy ground, green-accent knobs, light icons) from the deepdrft-* tokens. + Row 1 (MODE, always): lava lamp-toggle, waveform lamp-toggle, [collisions knob — only when BOTH on], + then the color knob pinned far-right (applies to the whole field, so always visible). + Row 2 (LAVA, only when lava on): "LAVA:" label + gravity / heat / fluid-amount / fluid-viscosity. + Row 3 (WAVE, only when waveform on): "WAVE:" label + scroll SLIDER + width knob pinned far-right. - Because MudPopover PORTALS its content out of this component's DOM subtree, Blazor CSS isolation does - not reach the rendered panel — so panel chrome lives in the GLOBAL deepdrft-styles.css - (.waveform-visualizer-control-panel*), not in the scoped .razor.css. The scoped .razor.css carries only - the inline-bar fallback (the .mix-visualizer-controls-bar reserved-height row) Mix's existing - TopRowCenter mount still uses, which is NOT portaled and so resolves under isolation. + The two lamp toggles are iconographic (lit LavaLampFilled / unlit LavaLamp glyph), green because they + are INTERACTIVE (the §5 colour principle: green = interactive, light = non-interactive). The eight + continuous dials are unchanged in tuning; the one widget-type change is scroll/zoom → a MudSlider (§8, + bound to ScrollSpeed alone). None of these is a seek surface (read-only contract §D). - Visibility: the popover host always shows the panel when open (Visible defaults true). Mix's legacy - inline mount still feeds its lava-lamp toggle into Visible (Phase 10 §4): the knobs @if-gate while the - container holds a reserved min-height so content below never pops on toggle. + This is the PANEL CONTENT hosted inside WaveformVisualizerControlPopover, now a screen-centered tinted + MudOverlay (Phase 15 §4). Because the overlay PORTALS its content out of this component's DOM subtree, + Blazor CSS isolation does not reach the rendered panel — so panel chrome AND the row/section layout live + in the GLOBAL deepdrft-styles.css (.waveform-visualizer-control-panel*), not the scoped .razor.css. The + scoped .razor.css carries only the legacy inline-bar fallback (Mix's old non-portaled mount), which may + now be dead post-Phase-12 but is left in place — flagged, not cut (out of Phase 15 scope). + + COLOUR PRINCIPLE (§5): the lamp toggles + knob arcs/pointers + the slider track/thumb are green-accent + (interactive); the "LAVA:"/"WAVE:" section labels and the knob caption icons are LIGHT (static). All + colours are token-sourced (deepdrft-tokens.css) — no hardcoded hex. It owns NO JS interop: it mutates the shared, session-scoped WaveformVisualizerControlState and raises - its Changed event. The visualizer bridge (WaveformVisualizer) subscribes to that event and pushes the - affected dial to the WebGL module. That keeps the JS module handle single-owned by the bridge and this - component purely presentational. None of these is a seek surface (read-only contract §D). - - RadialKnob has no icon slot (its Label renders as SVG text) and no aria attribute-capture, so each - control's Material icon rides beside its knob as an adjacent MudIcon caption and the accessible name - rides on the wrapping group div (§7d). HoldValue stays false so the knobs are live — ValueChanged fires - continuously during drag, preserving the Changed/NotifyChanged seam. *@ + its Changed event. The visualizer bridge (WaveformVisualizer) subscribes and pushes the affected dial / + subsystem-enable to the WebGL module. RadialKnob has no icon slot (its Label renders as SVG text) and no + aria capture, so each control's Material icon rides beside its knob as a caption and the accessible name + rides on the wrapping group div (§7); the playful MudTooltip rides alongside for sighted hover. *@
@if (Visible) { -
- - + @* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + @* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one + present, so visible only when BOTH are on (§3 truth table). *@ + @if (ControlState.LavaEnabled && ControlState.WaveformEnabled) + { + +
+ + +
+
+ } +
+ + @* Color applies to the whole field regardless of which subsystems are on, so it is pinned + far-right of row 1 and never reflows when collisions hides (§3). *@ + +
+ + +
+
-
- - -
+ @* ── Row 2 — LAVA section (only when lava on). ── *@ + @if (ControlState.LavaEnabled) + { +
+ -
- - -
+ +
+ + +
+
-
- - -
+ +
+ + +
+
-
- - -
+ +
+ + +
+
-
- - -
+ +
+ + +
+
+
+ } -
- - -
+ @* ── Row 3 — WAVE section (only when waveform on). Scroll is a SLIDER (§8); width pinned right. ── *@ + @if (ControlState.WaveformEnabled) + { +
+ -
- - -
+ +
+ + +
+
+ + +
+ + +
+
+
+ } }
@code { /// - /// Whether the knob band is shown. The popover host shows the panel whenever it is open, so the - /// default is true. Mix's legacy inline mount still feeds its lava-lamp toggle into this — that - /// mount always renders the component, and THIS component decides knob visibility (Phase 10 §4): when - /// false the knobs are @if-gated out but the container holds its reserved height (CSS min-height), so - /// content below the inline bar never pops as the lamp toggles. Inside the popover the host owns + /// Whether the control deck is shown. The overlay host shows the panel whenever it is open, so the + /// default is true. Mix's legacy inline mount (if it survives) still feeds its lava-lamp toggle + /// into this — that mount always renders the component, and THIS component decides deck visibility + /// (Phase 10 §4): when false the rows are @if-gated out but the container holds its reserved height + /// (CSS min-height) so content below the inline bar never pops. Inside the overlay the host owns /// open/closed, so the default keeps the panel populated. /// [Parameter] public bool Visible { get; set; } = true; /// /// When true, applies the waveform-visualizer-control-panel class to the root element, - /// enabling the global panel-chrome rules (dark-navy ground, border, max-width cap, pinned palette - /// tokens). Set by ; Mix's inline mount leaves this - /// false so the chrome never leaks onto the inline bar. + /// enabling the global panel-chrome rules (NowPlayingCard chrome — square corners, lighter-navy + /// ground, thin light border — plus the row/section layout and pinned palette tokens). Set by + /// ; Mix's inline mount leaves this false so the + /// chrome never leaks onto the inline bar. /// [Parameter] public bool PanelChrome { get; set; } = false; private string _panelChromeClass => PanelChrome ? "waveform-visualizer-control-panel" : string.Empty; - // Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and - // pushes the affected dial. All values are already normalized [0,1]; the bridge maps scroll speed - // to a visible time-span and routes the rest straight to the lava/colour dials. + // Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and pushes + // the affected dial / subsystem-enable. All dial values are already normalized [0,1]; the bridge maps + // scroll speed to a visible time-span and routes the rest straight to the lava/colour dials. The two + // toggles flip a boolean (no value), driving the genuine per-subsystem draw-skip in the module (§6). + + private void ToggleLava() + { + ControlState.LavaEnabled = !ControlState.LavaEnabled; + ControlState.NotifyChanged(); + } + + private void ToggleWaveform() + { + ControlState.WaveformEnabled = !ControlState.WaveformEnabled; + ControlState.NotifyChanged(); + } private void OnScrollSpeedChanged(double value) { diff --git a/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs b/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs index 65b6470..36659b9 100644 --- a/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs +++ b/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs @@ -1,8 +1,8 @@ namespace DeepDrftPublic.Client.Services; /// -/// Holds the waveform visualizer's eight continuous-control positions for the lifetime of the WASM app -/// instance. Scoped in DI, so it lives across SPA navigations within one listening session — open a +/// Holds the waveform visualizer's eight continuous-control positions plus two subsystem on/off +/// toggles for the lifetime of the WASM app instance. Scoped in DI, so it lives across SPA navigations within one listening session — open a /// second mix and the knobs keep where you left them — but a fresh page load (F5) constructs a new /// instance, resetting to defaults. That matches the spec's "persist within session, reset on fresh /// load" without any cookie/localStorage round-trip (lava reframe §7c). @@ -84,6 +84,19 @@ public sealed class WaveformVisualizerControlState /// public const double DefaultWaveformWidth = 0.5; + /// + /// Default lava-subsystem on-state. true so the lava field is on out of the box — the + /// current behavior. Backs the row-1 lava lamp toggle (Phase 15 §6). Has no TS-side anchor: the + /// bridge pushes it as an enable/disable, not a tuning dial. + /// + public const bool DefaultLavaEnabled = true; + + /// + /// Default waveform-subsystem on-state. true so the waveform ribbon is on out of the box. + /// Backs the row-1 waveform lamp toggle (Phase 15 §6). + /// + public const bool DefaultWaveformEnabled = true; + /// Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible /// time-span via ; the standalone resolution/zoom control is gone. public double ScrollSpeed { get; set; } = DefaultScrollSpeed; @@ -110,6 +123,20 @@ public sealed class WaveformVisualizerControlState /// Waveform-band horizontal extent, normalized [0,1]. Narrowing clears room for the lava. public double WaveformWidth { get; set; } = DefaultWaveformWidth; + /// + /// Whether the lava field is drawn. When false the lava subsystem is genuinely not rendered + /// (the bridge skips its physics + uploads no blobs — no render cost, Phase 15 §6/§10.1), not dimmed. + /// Also gates the row-1/row-2 control visibility (§3). + /// + public bool LavaEnabled { get; set; } = DefaultLavaEnabled; + + /// + /// Whether the waveform ribbon is drawn. When false the ribbon subsystem is genuinely not + /// rendered (the bridge disables the ribbon SDF + drops its collision boundary — no render cost, + /// Phase 15 §6/§10.1), not dimmed. Also gates the row-1/row-3 control visibility (§3). + /// + public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled; + /// /// Raised whenever any control value changes. The visualizer bridge subscribes to push the /// affected dial(s). Mutators set the property then raise this; subscribers re-read the values. diff --git a/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts b/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts index ad30fad..c8f978b 100644 --- a/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts @@ -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(); diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css index c50a627..8ff76d0 100644 --- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css +++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css @@ -376,49 +376,151 @@ 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 (MODE) and row 3 (WAVE) use + space-between so the right-pinned control (color / width) hugs the far edge. Row 2 (LAVA) uses + flex-start so its label + four knobs group left rather than spreading edge-to-edge. ── */ +.waveform-visualizer-control-panel .wvc-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 0.85rem 1rem; +} + +/* Row 1 (MODE): two direct flex children — the left toggle group and the color knob tooltip wrapper. + space-between pins the color knob to the far right and keeps it there when collisions hides. */ +.waveform-visualizer-control-panel .wvc-row-mode { + justify-content: space-between; +} + +/* Row 2 (LAVA): label + four knobs group left — no right-pinned control. */ +.waveform-visualizer-control-panel .wvc-row-section { + justify-content: flex-start; +} + +/* Row 3 (WAVE): label + scroll-slider + width-knob tooltip wrappers are direct flex children. + space-between pins the width knob to the far right while the label + slider sit left. */ +.waveform-visualizer-control-panel .wvc-row-wave { + 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; +} + +/* ── 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(var(--deepdrft-scrim-rgb), 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; diff --git a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css index 952ef4c..a4ee762 100644 --- a/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css +++ b/DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css @@ -17,6 +17,16 @@ --deepdrft-white: #FAFAF8; --deepdrft-border: rgba(13, 27, 42, 0.10); --deepdrft-border-green: rgba(26, 60, 52, 0.20); + /* Thin light-on-dark border, NowPlayingCard spirit (Phase 15 §5). One token instead of scattering + the rgba(250,250,248,0.12) literal NowPlayingCard uses inline. */ + --deepdrft-border-light: rgba(250, 250, 248, 0.12); + /* Modal scrim base colour (RGB triple for use in rgba()) — panel dark-ground (#0D1B2A). + Deliberately NOT --deepdrft-navy (#112338); tokenised here so the scrim rule in + deepdrft-styles.css has no hardcoded literals. Change here once. */ + --deepdrft-scrim-rgb: 13, 27, 42; + /* Modal scrim opacity — the SINGLE point of truth for the visualizer-controls overlay tint + (Phase 15 §4/§10.5). Mild so the panel reads as modal without a blackout. Change here once. */ + --deepdrft-modal-scrim-alpha: 0.3; /* Wireframe font stack */ --deepdrft-font-display: "Cormorant Garamond", Georgia, serif;