feat(visualizer): controls row + unified MixVisualizerControlState; 3 inert uniforms wired (P10 W2)
This commit is contained in:
@@ -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 @@
|
||||
← @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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user