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
@@ -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) { }