Files
deepdrft/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs
T

356 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Full-page scrolling waveform background. Standalone and reusable: give it a
/// <see cref="ReleaseEntryKey"/> and it fetches its own loudness datum. The rendering itself — a windowed,
/// bottom-to-top, playback-coupled scroll with a glassy theme-aware gradient — lives in the
/// WaveformVisualizer.ts interop module; this component is the bridge that feeds it datum, playback
/// position, zoom, and theme, and owns the module lifecycle.
///
/// Strictly read-only (spec §D): no seek, no two-way write-back. <see cref="PlaybackPosition"/> is a
/// one-way input. The live playback signal on the Mix detail page comes from the cascaded player
/// service (which also supplies the mix duration needed for the time↔sample mapping); the
/// <see cref="PlaybackPosition"/> parameter is the composability fallback for hosts that have no
/// player cascade (e.g. an embed) and want to drive position themselves.
/// </summary>
public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
{
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
[Inject] public required WaveformVisualizerControlState ControlState { get; set; }
[Inject] public required ILogger<WaveformVisualizer> Logger { get; set; }
// Live playback + the mix duration come from the cascaded streaming player when present. The
// cascade is IsFixed, so we subscribe to its multicast StateChanged side-channel to learn about
// position/play-state ticks (same pattern as WaveformSeeker / SpectrumVisualizer).
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// Live dark-mode state. Toggling re-themes the gradient without a reload: the cascade re-renders
// us, and OnAfterRender pushes fresh palette colours into the module.
[CascadingParameter] public DarkModeSettings? DarkMode { get; set; }
/// <summary>The opaque public EntryKey of the Mix release whose waveform datum to fetch and render.</summary>
[Parameter] public required string ReleaseEntryKey { get; set; }
/// <summary>
/// The id of this mix's playable track. Used to gate the cascaded player as the live source: we
/// only couple to playback when the player is on THIS track, so a different track playing
/// elsewhere leaves this backdrop at its at-rest slice instead of scrolling to the wrong audio.
/// Null leaves the visualizer in the at-rest state (no player coupling).
/// </summary>
[Parameter] public long? TrackId { get; set; }
/// <summary>
/// Normalized playback head in [0, 1]. One-way input only — the component never writes back.
/// Used as the position source for hosts with no cascaded player (composability fallback);
/// when a matching player is cascaded, its live position takes precedence.
/// </summary>
[Parameter] public double PlaybackPosition { get; set; }
// Bridge-level diagnostics. Mirrors the JS-side DEBUG flag in WaveformVisualizer.ts: when true the
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
// `[WaveformVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
// which upstream link is broken when the ribbon stays blank — set true temporarily to diagnose.
private static readonly bool Debug = false;
private const string Tag = "[WaveformVisualizer]";
private static void DebugLog(string message)
{
if (Debug) Console.WriteLine($"{Tag} {message}");
}
private ElementReference _canvas;
private IJSObjectReference? _module;
private IJSObjectReference? _handle;
private IStreamingPlayerService? _subscribedService;
private WaveformProfileDto? _profile;
private string? _loadedReleaseKey;
// 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
// push the datum when its identity or duration-availability actually changes.
private WaveformProfileDto? _pushedProfile;
private bool _pushedWithDuration;
// Theme last pushed to the module, so we only re-push on an actual change.
private bool? _lastIsDark;
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()
{
// Subscribe to the player's multicast side-channel once, to re-render on position/play ticks.
// Log whether the cascade is even present: a null PlayerService here means the visualizer
// never couples to playback (no StateChanged events ever reach OnPlayerStateChanged).
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService is not null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
DebugLog($"subscribed to player StateChanged. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
}
else if (PlayerService is null)
{
DebugLog($"NO player cascade — playback will never couple. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
}
// ReleaseEntryKey is the only fetch input; fetch once per key. Position/zoom/theme changes
// re-render but must not refetch, and a release with no datum must not refetch either — so the
// guard keys on the fetched key, not on whether a profile came back.
if (_loadedReleaseKey == ReleaseEntryKey) return;
_loadedReleaseKey = ReleaseEntryKey;
DebugLog($"fetching mix waveform datum for ReleaseEntryKey={ReleaseEntryKey}…");
var result = await ReleaseData.GetMixWaveform(ReleaseEntryKey);
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
{
_profile = profile;
DebugLog($"datum fetch OK — {profile.BucketCount} buckets, base64 length {profile.Data.Length}.");
}
else
{
// No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still
// renders its content over a plain background.
_profile = null;
DebugLog(result.Success
? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseEntryKey={ReleaseEntryKey}) — backdrop stays blank."
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank.");
}
// Push the (possibly new) datum to the module if it is already created.
await PushDatumAsync();
}
private void OnPlayerStateChanged() => InvokeAsync(async () =>
{
// Position/play-state changed: push it to the module (cheap; no re-fetch, no full re-render
// needed for the canvas itself, but StateHasChanged keeps the slider/visibility in sync).
// Log the gating inputs so a "ribbon never couples" failure shows exactly why: whether the
// player is on THIS track (IsActivePlayer), and what duration/position/play-state it reports.
var currentTrackId = PlayerService?.CurrentTrack is { } ct ? ct.Id.ToString() : "null";
DebugLog($"player StateChanged — IsActivePlayer={IsActivePlayer} (player.CurrentTrack.Id={currentTrackId}, TrackId={TrackId?.ToString() ?? "null"}), player.IsPlaying={PlayerService?.IsPlaying}, player.Duration={PlayerService?.Duration?.ToString("F2") ?? "null"}.");
await PushPlaybackAsync();
StateHasChanged();
});
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
_module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./js/visualizer/WaveformVisualizer.js");
_handle = await _module.InvokeAsync<IJSObjectReference>("create", _canvas);
}
catch (JSException ex)
{
Logger.LogWarning(ex, "WaveformVisualizer: failed to load the visualizer module; rendering a plain backdrop.");
return;
}
// Seed the module with the current state now that it exists. All seven control values
// come from the shared (session-persisted) state, so a mix opened mid-session seeds the
// module with the knob positions the listener left them at.
await PushControlsAsync();
await PushDatumAsync();
await PushPlaybackAsync();
await PushThemeIfChangedAsync();
return;
}
// On every subsequent render (e.g. dark-mode toggle), re-theme if it changed.
await PushThemeIfChangedAsync();
}
// The controls bar mutated a knob on the shared state and raised Changed. Push all seven control
// values (cheap scalar interop). Each control now drives its own dedicated dial in the JS handle
// (lava reframe Wave R4) — scroll speed → visible-time-span, plus the six lava/colour dials; see
// PushControlsAsync. The bridge stays the sole owner of the JS module handle.
private void OnControlStateChanged() => InvokeAsync(async () =>
{
await PushControlsAsync();
});
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary>
/// Push the eight control values to the module from the shared state. Used to seed on first render
/// and to re-push when the controls bar signals a change. Each value is its own dedicated dial:
/// <list type="bullet">
/// <item>scroll speed [0,1] is mapped onto the useful zoom band via
/// <see cref="WaveformZoomMapping.ScrollKnobToSeconds"/> and pushed through <c>setScrollSpeed</c>
/// (higher speed → tighter window → faster scroll);</item>
/// <item>gradient rotation speed → <c>setGradientRotationSpeed</c> (live OKLab anchor rotation);</item>
/// <item>gravity / heat / collision strength → their dedicated lava-physics dials;</item>
/// <item>fluid amount → <c>setFluidAmount</c> (blob count + volume); fluid viscosity →
/// <c>setFluidViscosity</c> (cohesion / sphere-restoration) — the Phase 10 split of the
/// former single density knob;</item>
/// <item>waveform width → the ribbon-extent uniform.</item>
/// </list>
/// </summary>
private async Task PushControlsAsync()
{
if (_handle is null) return;
// Scroll speed is a normalized [0,1] axis; map it onto the useful zoom band (Phase 10 retune —
// the knob's full travel now covers the 60%100% zoom range, dropping the dead slow/wide end).
var visibleSeconds = WaveformZoomMapping.ScrollKnobToSeconds(ControlState.ScrollSpeed);
await _handle.InvokeVoidAsync("setScrollSpeed", visibleSeconds);
await _handle.InvokeVoidAsync("setGradientRotationSpeed", ControlState.GradientRotationSpeed);
await _handle.InvokeVoidAsync("setLavaGravity", ControlState.LavaGravity);
await _handle.InvokeVoidAsync("setLavaHeat", ControlState.LavaHeat);
await _handle.InvokeVoidAsync("setFluidAmount", ControlState.FluidAmount);
await _handle.InvokeVoidAsync("setFluidViscosity", ControlState.FluidViscosity);
await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength);
await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
}
/// <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
/// can call it without re-decoding the (large) base64 datum in JS every frame.
/// </summary>
private async Task PushDatumAsync()
{
if (_handle is null) return;
var haveDuration = _profile is not null && PlayerDurationSeconds is > 0;
// No change since the last push? Nothing to do.
if (ReferenceEquals(_profile, _pushedProfile) && haveDuration == _pushedWithDuration)
return;
if (!haveDuration)
{
// The most common stuck state: a datum is loaded but no positive player duration has
// arrived, so we cannot map samples↔time and push an empty datum. Spell out which half
// is missing so the broken link is unambiguous in the console.
DebugLog($"datum push deferred (empty) — profile={(_profile is null ? "null" : "loaded")}, playerDuration={PlayerDurationSeconds?.ToString("F2") ?? "null"} (needs IsActivePlayer + duration>0).");
}
if (haveDuration)
{
// The mix duration must come from the player (no DTO field carries it); without a
// positive duration we cannot map samples↔time, so we hold off until it arrives.
DebugLog($"datum push (REAL) — base64 length {_profile!.Data.Length}, duration {PlayerDurationSeconds!.Value:F2}s.");
await _handle.InvokeVoidAsync("setDatum", _profile.Data, PlayerDurationSeconds.Value);
}
else
{
await _handle.InvokeVoidAsync("setDatum", string.Empty, 0d);
}
_pushedProfile = _profile;
_pushedWithDuration = haveDuration;
}
private async Task PushPlaybackAsync()
{
if (_handle is null)
{
DebugLog("PushPlayback skipped — module handle not created yet.");
return;
}
// Duration arrives via the player after the initial (duration-less) datum push; the
// idempotent PushDatumAsync re-pushes exactly once when it first becomes available.
await PushDatumAsync();
DebugLog($"setPlayback → position={CurrentPositionSeconds:F2}s, isPlaying={IsPlaying}.");
await _handle.InvokeVoidAsync("setPlayback", CurrentPositionSeconds, IsPlaying);
}
private async Task PushThemeIfChangedAsync()
{
if (_handle is null) return;
var isDark = DarkMode?.IsDarkMode ?? false;
if (_lastIsDark == isDark) return;
_lastIsDark = isDark;
// The module reads the gradient stops directly from the canvas's computed --mud-palette-*
// vars (canvas gradients can't resolve var(), so resolution must happen in JS). The bespoke
// light/dark themes swap those vars on toggle; we just tell the module to re-read.
await _handle.InvokeVoidAsync("refreshTheme");
}
// ── Live signal sources. The matching player wins; PlaybackPosition is the no-player fallback. ─
/// <summary>True only when the cascaded player is loaded with THIS mix's track.</summary>
private bool IsActivePlayer =>
PlayerService is { CurrentTrack: not null }
&& TrackId is { } id
&& PlayerService.CurrentTrack.Id == id;
private double? PlayerDurationSeconds =>
IsActivePlayer && PlayerService!.Duration is > 0 ? PlayerService.Duration : null;
private bool IsPlaying => IsActivePlayer && (PlayerService?.IsPlaying ?? false);
private double CurrentPositionSeconds
{
get
{
// Prefer the matching player's absolute time. Otherwise fall back to the one-way
// PlaybackPosition ([0,1]) scaled by whatever duration we have; with no duration the
// position is unusable, so show the at-rest slice (0).
if (IsActivePlayer)
return PlayerService!.CurrentTime;
if (PlayerDurationSeconds is { } dur)
return Math.Clamp(PlaybackPosition, 0, 1) * dur;
return 0;
}
}
public async ValueTask DisposeAsync()
{
if (_subscribedService is not null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
if (_subscribedToControls)
{
ControlState.Changed -= OnControlStateChanged;
_subscribedToControls = false;
}
if (_handle is not null)
{
try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { }
try { await _handle.DisposeAsync(); } catch (JSDisconnectedException) { }
_handle = null;
}
if (_module is not null)
{
try { await _module.DisposeAsync(); } catch (JSDisconnectedException) { }
_module = null;
}
}
}