feat(public): scrolling Canvas 2D Mix visualizer — windowed, playback-coupled, zoomable, read-only (8.K W2)
This commit is contained in:
@@ -1,132 +1,270 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
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>
|
||||
/// Renders a Mix release's stored loudness profile as a full-page background silhouette. Standalone
|
||||
/// and reusable: give it a <see cref="ReleaseId"/> and it fetches its own datum. Visually distinct
|
||||
/// from the player-bar spectrum/level idiom by design — this is a single continuous mirrored wave,
|
||||
/// not discrete peak bars.
|
||||
/// 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
|
||||
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 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>
|
||||
/// Normalized playback head in [0, 1]. Two-way bindable so a future click-to-seek can write back
|
||||
/// through it; today it is read-only input that drives the played-portion wash. The seam exists
|
||||
/// now so wiring click-to-seek later is a pure addition, not a signature change.
|
||||
/// 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; }
|
||||
|
||||
[Parameter] public EventCallback<double> PlaybackPositionChanged { get; set; }
|
||||
private ElementReference _canvas;
|
||||
private IJSObjectReference? _module;
|
||||
private IJSObjectReference? _handle;
|
||||
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private WaveformProfileDto? _profile;
|
||||
private long? _loadedReleaseId;
|
||||
private bool _hasDatum;
|
||||
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user seeks by interacting with the waveform. Unused until click-to-seek ships;
|
||||
/// present now to lock the seek seam into the public contract.
|
||||
/// 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>
|
||||
[Parameter] public EventCallback<double> OnSeek { get; set; }
|
||||
|
||||
// Fixed SVG coordinate width. The path is computed in this space, then stretched to the
|
||||
// viewport via preserveAspectRatio="none".
|
||||
private const int ViewBoxWidth = 1000;
|
||||
|
||||
private readonly string _clipId = $"mix-wf-clip-{Guid.NewGuid():N}";
|
||||
|
||||
private WaveformProfileDto? _profile;
|
||||
private string _silhouettePath = string.Empty;
|
||||
private long? _loadedReleaseId;
|
||||
|
||||
private double ClampedPosition => Math.Clamp(PlaybackPosition, 0d, 1d);
|
||||
private double ZoomFraction => MixZoomMapping.SecondsToFraction(ZoomState.VisibleSeconds);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// ReleaseId is the only fetch input; fetch once per id. A PlaybackPosition update re-renders
|
||||
// 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;
|
||||
// Subscribe to the player's multicast side-channel once, to re-render on position/play ticks.
|
||||
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedService))
|
||||
{
|
||||
if (_subscribedService is not null)
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||
_subscribedService = PlayerService;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
var result = await ReleaseData.GetMixWaveform(ReleaseId);
|
||||
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0)
|
||||
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
|
||||
{
|
||||
_profile = profile;
|
||||
try
|
||||
{
|
||||
_silhouettePath = BuildSilhouettePath(profile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "MixWaveformVisualizer: failed to decode waveform profile for release {ReleaseId}; rendering empty backdrop.", ReleaseId);
|
||||
_profile = null;
|
||||
_silhouettePath = string.Empty;
|
||||
}
|
||||
_hasDatum = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No datum (not generated yet, or not a Mix) — leave the background empty; the detail
|
||||
// page still renders its content over a plain backdrop.
|
||||
// No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still
|
||||
// renders its content over a plain background.
|
||||
_profile = null;
|
||||
_silhouettePath = string.Empty;
|
||||
_hasDatum = false;
|
||||
}
|
||||
|
||||
// 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).
|
||||
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.
|
||||
await PushZoomAsync();
|
||||
await PushDatumAsync();
|
||||
await PushPlaybackAsync();
|
||||
await PushThemeIfChangedAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// On every subsequent render (e.g. dark-mode toggle), re-theme if it changed.
|
||||
await PushThemeIfChangedAsync();
|
||||
}
|
||||
|
||||
private async Task OnZoomFractionChanged(double fraction)
|
||||
{
|
||||
ZoomState.VisibleSeconds = MixZoomMapping.FractionToSeconds(fraction);
|
||||
await PushZoomAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
|
||||
|
||||
/// <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 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.
|
||||
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) 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();
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a closed, vertically mirrored silhouette path across the buckets. Loudness bytes are
|
||||
// [0, 255]; mapped to a half-height amplitude around the vertical midline (y=50). The top edge
|
||||
// runs left-to-right, the bottom edge mirrors right-to-left, and the path closes — yielding a
|
||||
// filled continuous wave shape rather than separate bars.
|
||||
private static string BuildSilhouettePath(WaveformProfileDto profile)
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
var data = Convert.FromBase64String(profile.Data);
|
||||
int n = data.Length;
|
||||
if (n == 0) return string.Empty;
|
||||
|
||||
const double midline = 50d;
|
||||
const double maxAmplitude = 48d; // leave a 2-unit margin top and bottom
|
||||
double step = n > 1 ? (double)ViewBoxWidth / (n - 1) : ViewBoxWidth;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Top edge, left to right.
|
||||
for (int i = 0; i < n; i++)
|
||||
if (_subscribedService is not null)
|
||||
{
|
||||
double x = i * step;
|
||||
double amp = data[i] / 255d * maxAmplitude;
|
||||
double y = midline - amp;
|
||||
sb.Append(i == 0 ? 'M' : 'L');
|
||||
AppendPoint(sb, x, y);
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
_subscribedService = null;
|
||||
}
|
||||
|
||||
// Bottom edge, right to left (mirror).
|
||||
for (int i = n - 1; i >= 0; i--)
|
||||
if (_handle is not null)
|
||||
{
|
||||
double x = i * step;
|
||||
double amp = data[i] / 255d * maxAmplitude;
|
||||
double y = midline + amp;
|
||||
sb.Append('L');
|
||||
AppendPoint(sb, x, y);
|
||||
try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { }
|
||||
try { await _handle.DisposeAsync(); } catch (JSDisconnectedException) { }
|
||||
_handle = null;
|
||||
}
|
||||
|
||||
sb.Append('Z');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendPoint(StringBuilder sb, double x, double y)
|
||||
{
|
||||
sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture));
|
||||
sb.Append(' ');
|
||||
sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture));
|
||||
sb.Append(' ');
|
||||
if (_module is not null)
|
||||
{
|
||||
try { await _module.DisposeAsync(); } catch (JSDisconnectedException) { }
|
||||
_module = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user