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:
daniel-c-harvey
2026-06-17 11:12:26 -04:00
parent ec3989c354
commit a19a734757
13 changed files with 216 additions and 74 deletions
@@ -83,27 +83,4 @@ public class ReleaseClient
? ApiResult<ReleaseDto>.CreatePassResult(release)
: ApiResult<ReleaseDto>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches the high-res waveform datum for a Mix release, addressed by its public EntryKey. A 404
/// means no datum is stored (not yet generated, or not a Mix) — a valid state, so it returns a pass
/// result with a null value. Any other non-success status is a genuine failure.
/// </summary>
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
{
var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, JsonOptions);
return profile is not null
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
}
}
@@ -129,6 +129,33 @@ public class TrackClient
: ApiResult<List<GenreSummaryDto>>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches the per-track high-res waveform datum, addressed by the track's EntryKey (phase-12 §5b).
/// A 404 means no high-res datum is stored (a track not yet backfilled) — a valid state, so it
/// returns a pass result with a null value and the visualizer blanks gracefully. Any other
/// non-success status is a genuine failure.
/// </summary>
public async Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey)
{
var response = await _http.GetAsync($"api/track/{Uri.EscapeDataString(trackEntryKey)}/waveform/high-res");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return profile is not null
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
{
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
@@ -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();
});
+5 -2
View File
@@ -35,8 +35,11 @@ else
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
playback only when the player is on this mix's track. *@
<WaveformVisualizer ReleaseEntryKey="@release.EntryKey" TrackId="@ViewModel.Track?.Id" />
playback only when the player is on this mix's track; TrackEntryKey is the datum to render at rest
(before playback) — the mix's single track, so the lava shows immediately on page load (§4). *@
<WaveformVisualizer ReleaseEntryKey="@release.EntryKey"
TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" />
<div class="mix-detail-foreground">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
@@ -24,11 +24,4 @@ public interface IReleaseDataService
/// <summary>Single release resolved by its opaque public EntryKey, with both metadata satellites (nulls for non-matching media).</summary>
Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey);
/// <summary>
/// The Mix waveform datum for a release addressed by its public EntryKey. Success with a value
/// when present; success with a null value when no datum is stored (a valid state, not a failure);
/// failure on any other transport error.
/// </summary>
Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey);
}
@@ -30,6 +30,14 @@ public interface ITrackDataService
Task<ApiResult<TrackDto>> GetTrack(string trackId);
/// <summary>
/// The per-track high-res waveform datum, addressed by the track's EntryKey (phase-12 §5b — the
/// datum is the track's; the release is only addressing context). Success with a value when a
/// high-res datum is stored; success with a null value when none is (a not-yet-backfilled track —
/// a valid state, not a failure, the visualizer blanks); failure on any other transport error.
/// </summary>
Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey);
/// <summary>
/// Fetches a random track from the public library for instant play. Success with a value on a
/// hit; success with a null value when the library is empty (a valid state, not a failure);
@@ -31,7 +31,4 @@ public class ReleaseClientDataService : IReleaseDataService
public Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
=> _releaseClient.GetByEntryKey(entryKey);
public Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
=> _releaseClient.GetMixWaveform(entryKey);
}
@@ -39,6 +39,9 @@ public class TrackClientDataService : ITrackDataService
public Task<ApiResult<TrackDto>> GetTrack(string trackId)
=> _trackClient.GetTrack(trackId);
public Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey)
=> _trackClient.GetTrackWaveform(trackEntryKey);
public Task<ApiResult<TrackDto?>> GetRandomTrack()
=> _trackClient.GetRandom();
}