337 lines
16 KiB
C#
337 lines
16 KiB
C#
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 Mix waveform background. Standalone and reusable: give it a
|
|
/// <see cref="ReleaseId"/> 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
|
|
/// MixVisualizer.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 MixWaveformVisualizer : ComponentBase, IAsyncDisposable
|
|
{
|
|
[Inject] public required IReleaseDataService ReleaseData { get; set; }
|
|
[Inject] public required IJSRuntime JS { 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
|
|
// 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 Mix release whose waveform datum to fetch and render.</summary>
|
|
[Parameter] public required long ReleaseId { 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 MixVisualizer.ts: when true the
|
|
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
|
|
// `[MixVisualizer]`, 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 false once confirmed healthy.
|
|
private static readonly bool Debug = false;
|
|
private const string Tag = "[MixVisualizer]";
|
|
|
|
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 long? _loadedReleaseId;
|
|
|
|
// 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. ReleaseId={ReleaseId}, TrackId={TrackId?.ToString() ?? "null"}.");
|
|
}
|
|
else if (PlayerService is null)
|
|
{
|
|
DebugLog($"NO player cascade — playback will never couple. ReleaseId={ReleaseId}, TrackId={TrackId?.ToString() ?? "null"}.");
|
|
}
|
|
|
|
// ReleaseId is the only fetch input; fetch once per id. 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 id, not on whether a profile came back.
|
|
if (_loadedReleaseId == ReleaseId) return;
|
|
_loadedReleaseId = ReleaseId;
|
|
|
|
DebugLog($"fetching mix waveform datum for ReleaseId={ReleaseId}…");
|
|
var result = await ReleaseData.GetMixWaveform(ReleaseId);
|
|
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 ReleaseId={ReleaseId}) — 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/MixVisualizer.js");
|
|
_handle = await _module.InvokeAsync<IJSObjectReference>("create", _canvas);
|
|
}
|
|
catch (JSException ex)
|
|
{
|
|
Logger.LogWarning(ex, "MixWaveformVisualizer: failed to load the visualizer module; rendering a plain backdrop.");
|
|
return;
|
|
}
|
|
|
|
// 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();
|
|
return;
|
|
}
|
|
|
|
// On every subsequent render (e.g. dark-mode toggle), re-theme if it changed.
|
|
await PushThemeIfChangedAsync();
|
|
}
|
|
|
|
// 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 () =>
|
|
{
|
|
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
|
|
/// 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;
|
|
}
|
|
}
|
|
}
|