2c2342fbaf
Tokenize scrim navy RGB triple (--deepdrft-scrim-rgb); LAVA row now flex-start so knobs group left; WAVE row keeps space-between for right-pinned width knob; remove inert margin-left:auto/wvc-row-right; correct stale seven->ten count in OnControlStateChanged comment.
481 lines
26 KiB
C#
481 lines
26 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>
|
||
/// Scrolling waveform visualizer, track-cardinal (phase-12 §4/§5). It renders the high-res loudness
|
||
/// datum of whatever track is currently playing/selected: the datum is the track's, not the release's,
|
||
/// so the fetch resolves the current track's <c>EntryKey</c> (the live playing track when the cascaded
|
||
/// player is this visualizer's source — see <see cref="LivePlayerTrack"/> — else the host-supplied
|
||
/// <see cref="TrackEntryKey"/>) and re-fetches when that track identity changes — not when the release
|
||
/// changes. The release (<see cref="ReleaseEntryKey"/>) anchors which playing tracks this visualizer
|
||
/// follows (its own and any sibling in the same release), and is otherwise addressing context. 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 comes from the cascaded player service (which also supplies
|
||
/// the track 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 ITrackDataService TrackData { 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 host release. Addressing context only (phase-12 §4) — the datum
|
||
/// is fetched per-track, not per-release. Carried for diagnostics and host identity; it no longer
|
||
/// drives the datum fetch.
|
||
/// </summary>
|
||
[Parameter] public required string ReleaseEntryKey { get; set; }
|
||
|
||
/// <summary>
|
||
/// The id of the host's selected/default playable track. Anchors the host's at-rest identity: at
|
||
/// rest (no live player) the visualizer renders this track's datum via <see cref="TrackEntryKey"/>.
|
||
/// It also keeps an unrelated track playing elsewhere from coupling to this visualizer — the live
|
||
/// player only becomes this visualizer's source when its track is part of this host (it IS this
|
||
/// track, or it shares this host's <see cref="ReleaseEntryKey"/>; see <see cref="LivePlayerTrack"/>).
|
||
/// Null leaves the visualizer in the at-rest state unless the player's track matches the release.
|
||
/// </summary>
|
||
[Parameter] public long? TrackId { get; set; }
|
||
|
||
/// <summary>
|
||
/// The EntryKey of the host's selected/default track — the datum to render when no live player is
|
||
/// this visualizer's source (e.g. a Mix detail page at rest, before playback starts). When the
|
||
/// cascaded player is this visualizer's live source (<see cref="LivePlayerTrack"/>), the playing
|
||
/// track's own EntryKey takes precedence so the datum follows live playback (a multi-track Cut, the
|
||
/// NowPlaying card). Null with no live player leaves the visualizer blank.
|
||
/// </summary>
|
||
[Parameter] public string? TrackEntryKey { 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; }
|
||
|
||
/// <summary>
|
||
/// Container-sizing mode (phase-12 §6c). Default <c>false</c> keeps the full-viewport mount the
|
||
/// engine has always used (fixed, inset 0, clipped above the player bar) — Mix's mode-A full-bleed
|
||
/// and the ambient mode-B mounts are unchanged. Set <c>true</c> for a contained mount (mode C, the
|
||
/// NowPlaying card): the canvas fills its nearest positioned ancestor instead of the viewport, with
|
||
/// no footer clip. This is a CSS/layout toggle only — the renderer already sizes the backing store to
|
||
/// the canvas's own box (a ResizeObserver on the canvas, never <c>window</c>), so the JS module is
|
||
/// identical in both modes; <c>Fill</c> only changes which box that canvas occupies.
|
||
/// </summary>
|
||
[Parameter] public bool Fill { 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;
|
||
|
||
// The track EntryKey the loaded datum belongs to. The fetch-once guard keys on the current track's
|
||
// identity (phase-12 §4), not the release, so the datum re-fetches when the playing/selected track
|
||
// changes while the release stays fixed (a multi-track Cut, the NowPlaying card). Null until the
|
||
// first fetch; an in-flight fetch is tracked separately so concurrent ticks don't double-fetch.
|
||
private string? _loadedTrackKey;
|
||
private string? _fetchingTrackKey;
|
||
|
||
// 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"}.");
|
||
}
|
||
|
||
// Fetch the current track's datum if its identity changed since the last fetch (parameter set
|
||
// can change TrackEntryKey; the player side comes through OnPlayerStateChanged).
|
||
await EnsureDatumForCurrentTrackAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// The EntryKey of the track whose datum to render: the live playing track's own EntryKey when the
|
||
/// cascaded player is this visualizer's source (<see cref="LivePlayerTrack"/>), otherwise the host's
|
||
/// selected/default <see cref="TrackEntryKey"/>. This is the single source of "which track's datum" —
|
||
/// both the fetch key and what re-arms the fetch-once guard. Following the *live* track (not the fixed
|
||
/// host <see cref="TrackId"/>) is what lets a multi-track Cut, or the NowPlaying card, re-fetch and
|
||
/// render the now-playing track as playback advances (phase-12 §4/§6c). At rest it degrades to the
|
||
/// host track — single-track hosts (Mix/Session), where the live track IS the host track, are
|
||
/// unchanged.
|
||
/// </summary>
|
||
private string? CurrentTrackKey =>
|
||
LivePlayerTrack?.EntryKey ?? TrackEntryKey;
|
||
|
||
/// <summary>
|
||
/// Fetch the current track's high-res datum, but only when the track identity changed since the last
|
||
/// fetch (phase-12 §4 — the guard re-arms on track change, not release change). Idempotent and
|
||
/// re-entrancy-guarded: callable from both OnParametersSetAsync (TrackEntryKey changed) and
|
||
/// OnPlayerStateChanged (the playing track changed) without double-fetching. A track with no stored
|
||
/// datum leaves the visualizer blank; the guard keys on the fetched key, not on whether a datum came
|
||
/// back, so a not-yet-backfilled track does not refetch on every tick.
|
||
/// </summary>
|
||
private async Task EnsureDatumForCurrentTrackAsync()
|
||
{
|
||
var trackKey = CurrentTrackKey;
|
||
|
||
// Nothing to fetch (no active player and no selected track): clear any stale datum and disarm.
|
||
if (string.IsNullOrEmpty(trackKey))
|
||
{
|
||
if (_loadedTrackKey is not null || _profile is not null)
|
||
{
|
||
_loadedTrackKey = null;
|
||
_profile = null;
|
||
await PushDatumAsync();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Already loaded (or loading) this exact track — don't refetch.
|
||
if (trackKey == _loadedTrackKey || trackKey == _fetchingTrackKey) return;
|
||
|
||
_fetchingTrackKey = trackKey;
|
||
DebugLog($"fetching high-res waveform datum for trackEntryKey={trackKey}…");
|
||
var result = await TrackData.GetTrackWaveform(trackKey);
|
||
|
||
// The current track may have advanced again while this fetch was in flight; if so, discard this
|
||
// result and let the newer track's fetch (already armed via _fetchingTrackKey) win.
|
||
if (_fetchingTrackKey != trackKey)
|
||
{
|
||
DebugLog($"discarding stale datum fetch for trackEntryKey={trackKey} — current track moved on.");
|
||
return;
|
||
}
|
||
_fetchingTrackKey = null;
|
||
_loadedTrackKey = trackKey;
|
||
|
||
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 (track not yet backfilled, or transport error) — blank visualizer; the host still
|
||
// renders its content over a plain background.
|
||
_profile = null;
|
||
DebugLog(result.Success
|
||
? $"datum fetch returned EMPTY/absent (no stored high-res datum for trackEntryKey={trackKey}) — visualizer stays blank."
|
||
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — visualizer 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 live
|
||
// player is this visualizer's source (LivePlayerTrack), and what duration/position/play-state it
|
||
// reports.
|
||
var currentTrackId = PlayerService?.CurrentTrack is { } ct ? ct.Id.ToString() : "null";
|
||
DebugLog($"player StateChanged — liveTrackKey={LivePlayerTrack?.EntryKey ?? "null"} (player.CurrentTrack.Id={currentTrackId}, TrackId={TrackId?.ToString() ?? "null"}, ReleaseEntryKey={ReleaseEntryKey}), player.IsPlaying={PlayerService?.IsPlaying}, player.Duration={PlayerService?.Duration?.ToString("F2") ?? "null"}.");
|
||
// The playing track may have changed (a multi-track Cut advancing, the NowPlaying card following
|
||
// playback) — re-fetch its datum if so. EnsureDatumForCurrentTrackAsync is guarded, so a tick that
|
||
// didn't change the track is a cheap no-op.
|
||
await EnsureDatumForCurrentTrackAsync();
|
||
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 control values (the eight
|
||
// dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
|
||
// so a mix opened mid-session seeds the module with the knob/toggle 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 ten control
|
||
// values (cheap scalar interop): the eight continuous dials plus the two subsystem enables. Each
|
||
// dial drives its own dedicated uniform 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 control values to the module from the shared state — the eight continuous dials plus the
|
||
/// two Phase 15 subsystem enables. Used to seed on first render and to re-push when the controls
|
||
/// panel signals a change. Each value is its own dedicated dial / enable:
|
||
/// <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>
|
||
/// <item>lava / waveform enabled → <c>setLavaEnabled</c> / <c>setWaveformEnabled</c>, the genuine
|
||
/// per-subsystem draw-skip (no physics / no blob upload, ribbon SDF skipped — §10.1).</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);
|
||
// Phase 15 — the two subsystem enables. "Off" is a genuine draw-skip in the module (no physics,
|
||
// no blob upload / ribbon SDF skipped), not a dim. Pushed through the same Changed seam as the
|
||
// dials, so a toggle re-reads here exactly as a knob does.
|
||
await _handle.InvokeVoidAsync("setLavaEnabled", ControlState.LavaEnabled);
|
||
await _handle.InvokeVoidAsync("setWaveformEnabled", ControlState.WaveformEnabled);
|
||
}
|
||
|
||
/// <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 a live player source + 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 live player track wins; PlaybackPosition is the no-player fallback. ─
|
||
|
||
/// <summary>
|
||
/// The cascaded player's current track when that player is *this visualizer's* live source, else null.
|
||
/// A player track is this visualizer's source when it is part of what this host represents:
|
||
/// <list type="bullet">
|
||
/// <item>it IS the host's pinned track (<c>CurrentTrack.Id == TrackId</c>) — the single-track
|
||
/// Mix/Session case, preserved at exact parity; or</item>
|
||
/// <item>it shares the host's release (<c>CurrentTrack.Release.EntryKey == ReleaseEntryKey</c>) —
|
||
/// a multi-track Cut where the release is fixed but the playing track scrolls.</item>
|
||
/// </list>
|
||
/// The release-match disjunct is what lets the visualizer FOLLOW the live track as playback advances
|
||
/// past the host's fixed <see cref="TrackId"/> (phase-12 §4) instead of reverting to the host default;
|
||
/// the id-match disjunct guarantees single-track hosts behave identically even if the player track's
|
||
/// release graph is sparse. A track playing that is neither this track nor in this release is NOT our
|
||
/// source — the visualizer stays at the host's at-rest slice rather than scrolling to unrelated audio.
|
||
/// This is the single gate every live-signal accessor (datum key, duration, position, play-state)
|
||
/// shares, so they cannot disagree about which track is live.
|
||
/// </summary>
|
||
private TrackDto? LivePlayerTrack
|
||
{
|
||
get
|
||
{
|
||
if (PlayerService?.CurrentTrack is not { } track) return null;
|
||
if (TrackId is { } id && track.Id == id) return track;
|
||
if (!string.IsNullOrEmpty(track.Release?.EntryKey)
|
||
&& track.Release.EntryKey == ReleaseEntryKey) return track;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private double? PlayerDurationSeconds =>
|
||
LivePlayerTrack is not null && PlayerService!.Duration is > 0 ? PlayerService.Duration : null;
|
||
|
||
private bool IsPlaying => LivePlayerTrack is not null && (PlayerService?.IsPlaying ?? false);
|
||
|
||
private double CurrentPositionSeconds
|
||
{
|
||
get
|
||
{
|
||
// Prefer the live 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 (LivePlayerTrack is not null)
|
||
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;
|
||
}
|
||
}
|
||
}
|