feat(mix): lava-lamp popover with RadialKnob controls + wider Mix detail body (P10 W4)

This commit is contained in:
daniel-c-harvey
2026-06-16 00:19:47 -04:00
parent 26d7a05ba4
commit e59271aa00
7 changed files with 148 additions and 78 deletions
@@ -2,71 +2,71 @@
@using DeepDrftPublic.Client.Services
@inject MixVisualizerControlState ControlState
@* The Mix visualizer controls row (Phase 10, Wave 2). Four continuous sliders — resolution,
bubblyness, detach, color-shift speed — placed above the mix details and below the back button.
This component owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState
and raises its Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event
and pushes the affected uniform 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 (spec §D). *@
@* The Mix visualizer controls (Phase 10, Wave 4). Four continuous RadialKnobs — resolution,
bubblyness, detach, color-shift speed — laid out in a row. This component lives inside the
lava-lamp popover on the Mix detail page (Wave 4 moved it out of the always-visible TopContent row).
It owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState and raises its
Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event and pushes the
affected uniform 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 (spec §D).
RadialKnob has no icon slot (its Label renders as SVG text), so each control's Material icon rides
beside its knob as an adjacent MudIcon caption (spec §7e). HoldValue stays false so the knobs are
live — ValueChanged fires continuously during drag, exactly as the sliders fired before, preserving
the "visibly and continuously affects its target" feel and the Changed/NotifyChanged seam. *@
<div class="mix-visualizer-controls">
<div class="mix-visualizer-control">
@* RadialKnob exposes no aria-label/attribute-capture and is out of scope to modify, so the
accessible name rides on the wrapping group div instead (a plain element accepts it). *@
<div class="mix-visualizer-control" role="group" aria-label="Resolution (visible time-span)">
<RadialKnob Value="@ResolutionFraction"
ValueChanged="@OnResolutionChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ZoomIn" Size="Size.Small" Class="mix-visualizer-control-icon" />
<MudSlider T="double"
Value="@ResolutionFraction"
ValueChanged="@OnResolutionChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Resolution (visible time-span)" />
</div>
<div class="mix-visualizer-control">
<div class="mix-visualizer-control" role="group" aria-label="Bubblyness">
<RadialKnob Value="@ControlState.Bubblyness"
ValueChanged="@OnBubblynessChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="mix-visualizer-control-icon" />
<MudSlider T="double"
Value="@ControlState.Bubblyness"
ValueChanged="@OnBubblynessChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Bubblyness" />
</div>
<div class="mix-visualizer-control">
<div class="mix-visualizer-control" role="group" aria-label="Detach (unleash the lava lamp)">
<RadialKnob Value="@ControlState.Detach"
ValueChanged="@OnDetachChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Air" Size="Size.Small" Class="mix-visualizer-control-icon" />
<MudSlider T="double"
Value="@ControlState.Detach"
ValueChanged="@OnDetachChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Detach (unleash the lava lamp)" />
</div>
<div class="mix-visualizer-control">
<div class="mix-visualizer-control" role="group" aria-label="Color-shift speed">
<RadialKnob Value="@ControlState.ColorShiftSpeed"
ValueChanged="@OnColorShiftSpeedChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="mix-visualizer-control-icon" />
<MudSlider T="double"
Value="@ControlState.ColorShiftSpeed"
ValueChanged="@OnColorShiftSpeedChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Color-shift speed" />
</div>
</div>
@code {
// Resolution rides the log mapping (slider fraction [0,1] ↔ visible seconds); the other three are
// Resolution rides the log mapping (knob fraction [0,1] ↔ visible seconds); the other three are
// already normalized [0,1] and bind to their state properties directly.
private double ResolutionFraction => MixZoomMapping.SecondsToFraction(ControlState.VisibleSeconds);
@@ -1,34 +1,25 @@
/* The controls row sits in the mix-detail foreground, below the back button and above the masthead.
A horizontal row of four icon+slider controls. On narrow viewports it wraps to keep all four
present (spec §3b: wrap is the chosen mobile behaviour — none may drop). */
/* The controls live inside the lava-lamp popover (Wave 4). Four knob+icon stacks laid out in a row.
On a narrow viewport the row wraps to 2×2 so all four stay reachable (spec §7d: none may drop). */
.mix-visualizer-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.5rem;
margin: 0.5rem 0 1.5rem;
align-items: flex-start;
justify-content: center;
gap: 1rem 1.25rem;
padding: 0.25rem;
}
/* One control: a compact label icon followed by the slider. The slider gets a fixed-ish track width
so the four read as a tidy row rather than stretching unevenly. */
/* One control: a RadialKnob with its Material icon as a caption underneath. RadialKnob has no icon
slot, so the icon rides adjacent (spec §7e). Center the pair so the four read as a tidy row. */
.mix-visualizer-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex: 1 1 180px;
min-width: 160px;
max-width: 260px;
gap: 0.35rem;
}
.mix-visualizer-control-icon {
flex: 0 0 auto;
/* The caption icon is a MudIcon (a Razor component), so Blazor CSS isolation does not stamp the scope
attribute onto its element — reach it with ::deep. */
.mix-visualizer-control ::deep .mix-visualizer-control-icon {
opacity: 0.7;
}
/* MudSlider renders a Razor component, so its root is reached with ::deep (a bare scoped selector
would not be stamped onto the child component's element). Let the slider fill the remaining width
of its control so the icon+slider pair lays out cleanly. */
.mix-visualizer-control ::deep .mud-slider {
flex: 1 1 auto;
margin: 0;
}
@@ -7,9 +7,15 @@
<div class="deepdrft-track-detail-container">
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; @BackLabel
</MudLink>
@* Back link top-left, optional medium action top-right, on one SpaceBetween row. The action slot
stays null for media that don't supply it (Track/Session), so they render the back link alone. *@
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; @BackLabel
</MudLink>
@TopRightAction
</MudStack>
@TopContent
@@ -31,6 +31,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// </summary>
[Parameter] public RenderFragment? TopContent { get; set; }
/// <summary>
/// Optional action rendered at the top-right of the container, on the same SpaceBetween row as the
/// back link (back link left, action right). The Mix detail page uses it for the lava-lamp
/// visualizer-settings button; other media leave it null and render the back link alone.
/// </summary>
[Parameter] public RenderFragment? TopRightAction { get; set; }
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
[Parameter] public RenderFragment? Hero { get; set; }