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:
daniel-c-harvey
2026-06-17 14:28:15 -04:00
parent fe481d0417
commit dd4f8ddded
8 changed files with 465 additions and 184 deletions
@@ -15,13 +15,12 @@
TrackEntryKey="@Player?.CurrentTrack?.EntryKey" />
</div>
@* 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. *@
<div class="np-visualizer-controls">
<WaveformVisualizerControlPopover IconSize="Size.Small"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopLeft" />
<WaveformVisualizerControlPopover IconSize="Size.Small" />
</div>
@* Pulsing rings *@
@@ -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();
@@ -291,8 +292,9 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary>
/// 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:
/// <list type="bullet">
/// <item>scroll speed [0,1] is mapped onto the useful zoom band via
/// <see cref="WaveformZoomMapping.ScrollKnobToSeconds"/> and pushed through <c>setScrollSpeed</c>
@@ -302,7 +304,9 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
/// <item>fluid amount → <c>setFluidAmount</c> (blob count + volume); fluid viscosity →
/// <c>setFluidViscosity</c> (cohesion / sphere-restoration) — the Phase 10 split of the
/// former single density knob;</item>
/// <item>waveform width → the ribbon-extent uniform.</item>
/// <item>waveform width → the ribbon-extent uniform;</item>
/// <item>lava / waveform enabled → <c>setLavaEnabled</c> / <c>setWaveformEnabled</c>, the genuine
/// per-subsystem draw-skip (no physics / no blob upload, ribbon SDF skipped — §10.1).</item>
/// </list>
/// </summary>
private async Task PushControlsAsync()
@@ -319,6 +323,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);
}
/// <summary>
@@ -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 59). 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. *@
<MudOverlay Visible="@_open" OnClick="@Close" />
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. *@
<div>
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Visualizer settings"
aria-expanded="@_open" />
</MudTooltip>
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Visualizer settings"
aria-expanded="@_open" />
</MudTooltip>
<MudPopover Open="@_open"
Fixed="true"
AnchorOrigin="@AnchorOrigin"
TransformOrigin="@TransformOrigin"
Class="waveform-visualizer-control-popover">
@* 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. *@
<MudOverlay Visible="@_open"
DarkBackground="true"
Modal="true"
OnClick="@Close"
Class="waveform-visualizer-control-overlay">
<div class="waveform-visualizer-control-modal" @onclick:stopPropagation="true">
<WaveformVisualizerControls PanelChrome="true" />
</MudPopover>
</div>
</div>
</MudOverlay>
@code {
/// <summary>
/// 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.
/// </summary>
[Parameter] public Origin AnchorOrigin { get; set; } = Origin.BottomRight;
/// <summary>The panel's own corner that pins to <see cref="AnchorOrigin"/> (§8e).</summary>
[Parameter] public Origin TransformOrigin { get; set; } = Origin.TopRight;
/// <summary>Trigger-icon size. Defaults Large to match the Phase 10 Mix lava-lamp button.</summary>
[Parameter] public Size IconSize { get; set; } = Size.Large;
@@ -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. *@
<div class="@($"{_panelChromeClass} mix-visualizer-controls-bar".TrimStart())">
@if (Visible)
{
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
<RadialKnob Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
@* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
<div class="wvc-row wvc-row-mode">
<div class="wvc-row-left">
<MudTooltip Text="Light the lamp — or let it go cold.">
<div class="wvc-toggle" role="group" aria-label="Lava field on or off">
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleLava"
aria-label="Lava field on or off"
aria-pressed="@ControlState.LavaEnabled" />
</div>
</MudTooltip>
<MudTooltip Text="Show the sound, or hide the ribbon.">
<div class="wvc-toggle" role="group" aria-label="Waveform ribbon on or off">
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleWaveform"
aria-label="Waveform ribbon on or off"
aria-pressed="@ControlState.WaveformEnabled" />
</div>
</MudTooltip>
@* 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)
{
<MudTooltip Text="How hard the blobs body-check the beat.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
}
</div>
@* 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). *@
<MudTooltip Text="How fast the lamp drifts through its colors.">
<div class="waveform-visualizer-control mix-visualizer-control wvc-row-right" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</div>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
@* ── Row 2 — LAVA section (only when lava on). ── *@
@if (ControlState.LavaEnabled)
{
<div class="wvc-row wvc-row-section">
<span class="wvc-section-label">LAVA:</span>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="How heavy the wax feels — float, or sink.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="How much goo is in the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="Runny and gooey, or tight little globes.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</div>
}
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
@* ── Row 3 — WAVE section (only when waveform on). Scroll is a SLIDER (§8); width pinned right. ── *@
@if (ControlState.WaveformEnabled)
{
<div class="wvc-row wvc-row-section">
<span class="wvc-section-label">WAVE:</span>
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
<MudTooltip Text="How fast the sound rolls by.">
<div class="wvc-slider" role="group" aria-label="Waveform scroll speed">
<MudSlider T="double"
Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control wvc-row-right" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</div>
}
}
</div>
@code {
/// <summary>
/// Whether the knob band is shown. The popover host shows the panel whenever it is open, so the
/// default is <c>true</c>. 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 <c>true</c>. 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.
/// </summary>
[Parameter] public bool Visible { get; set; } = true;
/// <summary>
/// When <c>true</c>, applies the <c>waveform-visualizer-control-panel</c> class to the root element,
/// enabling the global panel-chrome rules (dark-navy ground, border, max-width cap, pinned palette
/// tokens). Set by <see cref="WaveformVisualizerControlPopover"/>; Mix's inline mount leaves this
/// <c>false</c> 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
/// <see cref="WaveformVisualizerControlPopover"/>; Mix's inline mount leaves this <c>false</c> so the
/// chrome never leaks onto the inline bar.
/// </summary>
[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)
{
@@ -1,8 +1,8 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// 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
/// </summary>
public const double DefaultWaveformWidth = 0.5;
/// <summary>
/// Default lava-subsystem on-state. <c>true</c> 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.
/// </summary>
public const bool DefaultLavaEnabled = true;
/// <summary>
/// Default waveform-subsystem on-state. <c>true</c> so the waveform ribbon is on out of the box.
/// Backs the row-1 waveform lamp toggle (Phase 15 §6).
/// </summary>
public const bool DefaultWaveformEnabled = true;
/// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
/// time-span via <see cref="WaveformZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
@@ -110,6 +123,20 @@ public sealed class WaveformVisualizerControlState
/// <summary>Waveform-band horizontal extent, normalized [0,1]. Narrowing clears room for the lava.</summary>
public double WaveformWidth { get; set; } = DefaultWaveformWidth;
/// <summary>
/// Whether the lava field is drawn. When <c>false</c> 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).
/// </summary>
public bool LavaEnabled { get; set; } = DefaultLavaEnabled;
/// <summary>
/// Whether the waveform ribbon is drawn. When <c>false</c> 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).
/// </summary>
public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled;
/// <summary>
/// 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.