feat(visualizer): controls row + unified MixVisualizerControlState; 3 inert uniforms wired (P10 W2)

This commit is contained in:
daniel-c-harvey
2026-06-15 23:15:44 -04:00
parent e0f371cda6
commit bf00b7f22f
12 changed files with 332 additions and 94 deletions
@@ -0,0 +1,96 @@
@namespace DeepDrftPublic.Client.Controls
@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). *@
<div class="mix-visualizer-controls">
<div class="mix-visualizer-control">
<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">
<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">
<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">
<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
// already normalized [0,1] and bind to their state properties directly.
private double ResolutionFraction => MixZoomMapping.SecondsToFraction(ControlState.VisibleSeconds);
private void OnResolutionChanged(double fraction)
{
ControlState.VisibleSeconds = MixZoomMapping.FractionToSeconds(fraction);
ControlState.NotifyChanged();
}
private void OnBubblynessChanged(double value)
{
ControlState.Bubblyness = value;
ControlState.NotifyChanged();
}
private void OnDetachChanged(double value)
{
ControlState.Detach = value;
ControlState.NotifyChanged();
}
private void OnColorShiftSpeedChanged(double value)
{
ControlState.ColorShiftSpeed = value;
ControlState.NotifyChanged();
}
}
@@ -0,0 +1,34 @@
/* 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). */
.mix-visualizer-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.5rem;
margin: 0.5rem 0 1.5rem;
}
/* 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. */
.mix-visualizer-control {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 180px;
min-width: 160px;
max-width: 260px;
}
.mix-visualizer-control-icon {
flex: 0 0 auto;
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;
}
@@ -11,23 +11,6 @@
<canvas @ref="_canvas" class="mix-waveform-canvas"></canvas>
</div>
@* Viewing control only — never a seek surface. Hidden until a datum is present.
Deliberately a SIBLING of .mix-waveform-bg, not a child: the backdrop is position:fixed and so
forms its own stacking context, which would trap any descendant below the page's z-index:1
foreground (.mix-detail-foreground) and let that foreground swallow the slider's pointer events.
As a top-level sibling with its own z-index, the slider stacks above the foreground and stays
draggable. *@
@if (_hasDatum)
{
<div class="mix-waveform-zoom">
<MudSlider T="double"
Value="@ZoomFraction"
ValueChanged="@OnZoomFractionChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Waveform zoom" />
</div>
}
@* The viewing controls (resolution + the three Wave 2 controls) live in MixVisualizerControls,
rendered in the mix-detail foreground row below the back button — NOT here. This component is now a
pure backdrop bridge; it pushes uniforms in response to the shared MixVisualizerControlState. *@
@@ -24,7 +24,7 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
{
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
[Inject] public required MixVisualizerZoomState ZoomState { get; set; }
[Inject] public required MixVisualizerControlState ControlState { get; set; }
[Inject] public required ILogger<MixWaveformVisualizer> Logger { get; set; }
// Live playback + the mix duration come from the cascaded streaming player when present. The
@@ -73,7 +73,10 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
private IStreamingPlayerService? _subscribedService;
private WaveformProfileDto? _profile;
private long? _loadedReleaseId;
private bool _hasDatum;
// Whether we are subscribed to the shared control state's Changed event. The controls row (a
// sibling component) mutates ControlState and raises Changed; we push the affected uniforms.
private bool _subscribedToControls;
// The profile reference last sent to the module, plus whether it went with a real duration.
// Tracked so a per-tick playback push never re-decodes the (up to ~1.2 MB) datum in JS — we only
@@ -84,11 +87,18 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// Theme last pushed to the module, so we only re-push on an actual change.
private bool? _lastIsDark;
/// <summary>
/// Slider position in [0, 1]. 0 = most zoomed-out (MaxVisibleSeconds), 1 = most zoomed-in
/// (MinVisibleSeconds). Derived from the session-persisted seconds via the log mapping below.
/// </summary>
private double ZoomFraction => MixZoomMapping.SecondsToFraction(ZoomState.VisibleSeconds);
protected override void OnInitialized()
{
// Subscribe once to the shared control state. The controls row mutates it and raises Changed;
// we are the sole owner of the JS module handle, so we do the uniform pushes here. This keeps
// the handle single-owned (no handle sharing, no service-locator) — the scoped state object is
// the decoupling seam between the foreground controls and this backdrop bridge.
if (!_subscribedToControls)
{
ControlState.Changed += OnControlStateChanged;
_subscribedToControls = true;
}
}
protected override async Task OnParametersSetAsync()
{
@@ -119,7 +129,6 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
{
_profile = profile;
_hasDatum = true;
DebugLog($"datum fetch OK — {profile.BucketCount} buckets, base64 length {profile.Data.Length}.");
}
else
@@ -127,7 +136,6 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still
// renders its content over a plain background.
_profile = null;
_hasDatum = false;
DebugLog(result.Success
? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseId={ReleaseId}) — backdrop stays blank."
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank.");
@@ -165,8 +173,10 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
return;
}
// Seed the module with the current state now that it exists.
await PushZoomAsync();
// Seed the module with the current state now that it exists. All four control values
// come from the shared (session-persisted) state, so a mix opened mid-session seeds the
// module with the slider positions the listener left them at.
await PushControlsAsync();
await PushDatumAsync();
await PushPlaybackAsync();
await PushThemeIfChangedAsync();
@@ -177,16 +187,29 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
await PushThemeIfChangedAsync();
}
private async Task OnZoomFractionChanged(double fraction)
// The controls row mutated a slider on the shared state and raised Changed. Push all four control
// uniforms (cheap scalar interop; the inert three are no-ops in the parity shader until Wave 3).
private void OnControlStateChanged() => InvokeAsync(async () =>
{
ZoomState.VisibleSeconds = MixZoomMapping.FractionToSeconds(fraction);
DebugLog($"zoom slider changed — raw fraction={fraction:F3} → visibleSeconds={ZoomState.VisibleSeconds:F3}s; pushing setZoom (handle={(_handle is null ? "null" : "ready")}).");
await PushZoomAsync();
StateHasChanged();
}
await PushControlsAsync();
});
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary>
/// Push all four control values to the module from the shared state. Used to seed on first render
/// and to re-push when the controls row signals a change. Resolution drives the live render; the
/// other three are inert in the parity shader (Wave 3 consumes them).
/// </summary>
private async Task PushControlsAsync()
{
if (_handle is null) return;
await _handle.InvokeVoidAsync("setZoom", ControlState.VisibleSeconds);
await _handle.InvokeVoidAsync("setBubblyness", ControlState.Bubblyness);
await _handle.InvokeVoidAsync("setDetach", ControlState.Detach);
await _handle.InvokeVoidAsync("setColorShiftSpeed", ControlState.ColorShiftSpeed);
}
/// <summary>
/// Push the datum to the module, but only when it actually changed — a different profile, or the
/// mix duration becoming available for the first time. Idempotent so the per-tick playback path
@@ -242,12 +265,6 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
await _handle.InvokeVoidAsync("setPlayback", CurrentPositionSeconds, IsPlaying);
}
private async Task PushZoomAsync()
{
if (_handle is null) return;
await _handle.InvokeVoidAsync("setZoom", ZoomState.VisibleSeconds);
}
private async Task PushThemeIfChangedAsync()
{
if (_handle is null) return;
@@ -297,6 +314,12 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
_subscribedService = null;
}
if (_subscribedToControls)
{
ControlState.Changed -= OnControlStateChanged;
_subscribedToControls = false;
}
if (_handle is not null)
{
try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { }
@@ -19,27 +19,3 @@
height: 100%;
display: block;
}
/* Zoom slider — a small viewing control pinned to the top-right, clear of the player bar at
the bottom and the nav bar at the top. It is never a seek surface. top: 5rem sits just below the
fixed nav bar (~4.5rem tall) so neither the expanded player bar nor the nav occludes it.
position: fixed (not absolute) because the slider is now a top-level sibling of the backdrop, not
a child of it — see the comment in the .razor. z-index: 10 lifts it above the page foreground
(.mix-detail-foreground, z-index: 1) so the foreground can't intercept its pointer events; that
occlusion was the resolution-slider regression. */
.mix-waveform-zoom {
position: fixed;
right: 1.5rem;
top: 5rem;
z-index: 10;
width: 180px;
max-width: 40vw;
pointer-events: auto;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.mix-waveform-zoom:hover {
opacity: 1;
}
@@ -11,6 +11,8 @@
&larr; @BackLabel
</MudLink>
@TopContent
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h3">@Title</MudText>
@@ -24,6 +24,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
[Parameter] public string BackHref { get; set; } = "/archive";
[Parameter] public string BackLabel { get; set; } = "Archive";
/// <summary>
/// Optional medium-specific content rendered between the back link and the masthead — the "below
/// the back button, above the details" band. The Mix detail page uses it for the visualizer
/// controls row; other media leave it null.
/// </summary>
[Parameter] public RenderFragment? TopContent { get; set; }
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
[Parameter] public RenderFragment? Hero { get; set; }