feat(p12-w2): track-cardinal high-res waveform fetch + bridge rewire
Add GET api/track/{trackEntryKey}/waveform/high-res (+ proxy), ITrackDataService.GetTrackWaveform; rewire visualizer to resolve the current track's EntryKey and re-fetch on track change. Retire the client mix-waveform read path.
This commit is contained in:
@@ -8,21 +8,24 @@ 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.
|
||||
/// 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 playing track when this is the active
|
||||
/// player, 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"/>) is only 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 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.
|
||||
/// 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 IReleaseDataService ReleaseData { get; set; }
|
||||
[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; }
|
||||
@@ -36,17 +39,30 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
|
||||
// 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>
|
||||
/// <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 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).
|
||||
/// The id of the host's selected/default 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 visualizer 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>
|
||||
/// The EntryKey of the host's selected/default track — the datum to render when no matching player
|
||||
/// is active (e.g. a Mix detail page at rest, before playback starts). When the cascaded player is on
|
||||
/// this visualizer's track (<see cref="IsActivePlayer"/>), the playing track's EntryKey takes
|
||||
/// precedence so the datum follows live playback (a multi-track Cut, the NowPlaying card). Null with
|
||||
/// no active 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);
|
||||
@@ -72,7 +88,13 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
|
||||
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private WaveformProfileDto? _profile;
|
||||
private string? _loadedReleaseKey;
|
||||
|
||||
// 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.
|
||||
@@ -118,14 +140,60 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
|
||||
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;
|
||||
// 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 when this visualizer is
|
||||
/// the active player, 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.
|
||||
/// </summary>
|
||||
private string? CurrentTrackKey =>
|
||||
IsActivePlayer ? PlayerService!.CurrentTrack!.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;
|
||||
|
||||
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;
|
||||
@@ -133,12 +201,12 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
// No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still
|
||||
// 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 datum for ReleaseEntryKey={ReleaseEntryKey}) — backdrop stays blank."
|
||||
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank.");
|
||||
? $"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.
|
||||
@@ -153,6 +221,10 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
|
||||
// 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"}.");
|
||||
// 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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user