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; }
@@ -46,6 +46,12 @@ else
BackHref="/mixes"
BackLabel="All mixes"
ShowMeta="@(hasGenre || hasDate)">
<TopContent>
@* The four visualizer controls — resolution, bubblyness, detach, color-shift speed —
in a row below the back button and above the masthead (spec §3). They mutate the
shared MixVisualizerControlState; the backdrop bridge above pushes the uniforms. *@
<MixVisualizerControls />
</TopContent>
<Hero>
<div class="mix-detail-cover">
@if (!string.IsNullOrEmpty(release.ImagePath))
@@ -0,0 +1,68 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Holds the Mix visualizer's four 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
/// second mix and the sliders 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 (see mix-visualizer-webgl-renderer §3c).
///
/// One state object, four properties — not four sibling holders (Daniel's decided shape, spec §3c).
/// Each C#-side default mirrors a TS-side tuning anchor in MixVisualizer.ts; keep the two in sync, as
/// the existing <c>DefaultVisibleSeconds</c> / <c>DEFAULT_VISIBLE_SECONDS</c> pair does.
///
/// <para>
/// <see cref="Changed"/> is the decoupling seam between the controls row and the visualizer bridge.
/// The controls component (a sibling of the backdrop in the page tree) only mutates this shared state
/// and raises <see cref="Changed"/>; the bridge component (<c>MixWaveformVisualizer</c>) subscribes
/// and pushes the affected uniform to the JS module. This keeps the JS module handle single-owned by
/// the bridge — no handle sharing, no service-locator, no cross-component interop.
/// </para>
/// </summary>
public sealed class MixVisualizerControlState
{
/// <summary>
/// Default opening window. Mirrors <c>DEFAULT_VISIBLE_SECONDS</c> in MixVisualizer.ts; keep the
/// two in sync (the TS owns the rendering anchors, this owns the C#-side session default).
/// </summary>
public const double DefaultVisibleSeconds = 10.0;
/// <summary>
/// Default bulge amount. Mirrors <c>DEFAULT_BUBBLYNESS</c> in MixVisualizer.ts. Normalized [0,1];
/// 0 = straight rectangular bars, 1 = fully rounded liquid silhouettes (still attached).
/// </summary>
public const double DefaultBubblyness = 0.35;
/// <summary>
/// Default detach amount. Mirrors <c>DEFAULT_DETACH</c> in MixVisualizer.ts. Normalized [0,1];
/// 0 = fully attached, 1 = blobs separate and float upward. Off by default.
/// </summary>
public const double DefaultDetach = 0.0;
/// <summary>
/// Default color-shift speed. Mirrors <c>DEFAULT_COLOR_SHIFT_SPEED</c> in MixVisualizer.ts.
/// Normalized [0,1], mapped to a gradient-morph cycle period in the shader (slow → quick).
/// </summary>
public const double DefaultColorShiftSpeed = 0.3;
/// <summary>Visible time-span in seconds (the resolution/zoom control). Reused as-is from 8.K.</summary>
public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
/// <summary>Bulge amount, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double Bubblyness { get; set; } = DefaultBubblyness;
/// <summary>Lava-lamp detachment, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double Detach { get; set; } = DefaultDetach;
/// <summary>Gradient-morph rate, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double ColorShiftSpeed { get; set; } = DefaultColorShiftSpeed;
/// <summary>
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
/// affected uniform(s). Mutators set the property then raise this; subscribers re-read the values.
/// </summary>
public event Action? Changed;
/// <summary>Raise <see cref="Changed"/>. Called by the controls component after mutating a value.</summary>
public void NotifyChanged() => Changed?.Invoke();
}
@@ -1,20 +0,0 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Holds the Mix visualizer's zoom (visible time-span in seconds) 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 slider keeps where you left it — but a fresh page load (F5) constructs a new
/// instance, resetting to the default. That matches the spec's "persist within session, reset on
/// fresh load" without any cookie/localStorage round-trip (see phase-9-mix-visualizer-redesign §B).
/// </summary>
public sealed class MixVisualizerZoomState
{
/// <summary>
/// Default opening window. Mirrors <c>DEFAULT_VISIBLE_SECONDS</c> in MixVisualizer.ts; keep the
/// two in sync (the TS owns the rendering anchors, this owns the C#-side session default).
/// </summary>
public const double DefaultVisibleSeconds = 10.0;
/// <summary>Visible time-span in seconds. Survives navigation; resets on fresh page load.</summary>
public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
}
+3 -3
View File
@@ -27,9 +27,9 @@ public static class Startup
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
services.AddScoped<ReleaseDetailViewModel>();
// Mix visualizer zoom — scoped so it persists across navigation within a session and
// resets on a fresh page load (see MixVisualizerZoomState).
services.AddScoped<MixVisualizerZoomState>();
// Mix visualizer controls — scoped so the four slider positions persist across navigation
// within a session and reset on a fresh page load (see MixVisualizerControlState).
services.AddScoped<MixVisualizerControlState>();
}
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)