Merge p12-w3-bridge-live-track into dev (bridge follows the live playing track, not the fixed host TrackId)

This commit is contained in:
daniel-c-harvey
2026-06-17 11:39:32 -04:00
@@ -10,10 +10,11 @@ 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 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
/// 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.
///
@@ -47,19 +48,21 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
[Parameter] public required string ReleaseEntryKey { get; set; }
/// <summary>
/// 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).
/// 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 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.
/// 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; }
@@ -146,12 +149,17 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
}
/// <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.
/// 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 =>
IsActivePlayer ? PlayerService!.CurrentTrack!.EntryKey : TrackEntryKey;
LivePlayerTrack?.EntryKey ?? TrackEntryKey;
/// <summary>
/// Fetch the current track's high-res datum, but only when the track identity changed since the last
@@ -217,10 +225,11 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
{
// 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.
// 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 — IsActivePlayer={IsActivePlayer} (player.CurrentTrack.Id={currentTrackId}, TrackId={TrackId?.ToString() ?? "null"}), player.IsPlaying={PlayerService?.IsPlaying}, player.Duration={PlayerService?.Duration?.ToString("F2") ?? "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.
@@ -321,7 +330,7 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
// 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).");
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)
@@ -369,27 +378,50 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
await _handle.InvokeVoidAsync("refreshTheme");
}
// ── Live signal sources. The matching player wins; PlaybackPosition is the no-player fallback. ─
// ── Live signal sources. The live player track 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;
/// <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 =>
IsActivePlayer && PlayerService!.Duration is > 0 ? PlayerService.Duration : null;
LivePlayerTrack is not null && PlayerService!.Duration is > 0 ? PlayerService.Duration : null;
private bool IsPlaying => IsActivePlayer && (PlayerService?.IsPlaying ?? false);
private bool IsPlaying => LivePlayerTrack is not null && (PlayerService?.IsPlaying ?? false);
private double CurrentPositionSeconds
{
get
{
// Prefer the matching player's absolute time. Otherwise fall back to the one-way
// 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 (IsActivePlayer)
if (LivePlayerTrack is not null)
return PlayerService!.CurrentTime;
if (PlayerDurationSeconds is { } dur)
return Math.Clamp(PlaybackPosition, 0, 1) * dur;