diff --git a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs index 3592e28..8135448 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizer.razor.cs @@ -10,10 +10,11 @@ namespace DeepDrftPublic.Client.Controls; /// /// 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 EntryKey (the playing track when this is the active -/// player, else the host-supplied ) and re-fetches when that track identity -/// changes — not when the release changes. The release () 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 EntryKey (the live playing track when the cascaded +/// player is this visualizer's source — see — else the host-supplied +/// ) and re-fetches when that track identity changes — not when the release +/// changes. The release () 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; } /// - /// 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 . + /// 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 ). + /// Null leaves the visualizer in the at-rest state unless the player's track matches the release. /// [Parameter] public long? TrackId { get; set; } /// - /// 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 (), 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 (), 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. /// [Parameter] public string? TrackEntryKey { get; set; } @@ -146,12 +149,17 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable } /// - /// 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 . 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 (), otherwise the host's + /// selected/default . 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 ) 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. /// private string? CurrentTrackKey => - IsActivePlayer ? PlayerService!.CurrentTrack!.EntryKey : TrackEntryKey; + LivePlayerTrack?.EntryKey ?? TrackEntryKey; /// /// 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. ─ - /// True only when the cascaded player is loaded with THIS mix's track. - private bool IsActivePlayer => - PlayerService is { CurrentTrack: not null } - && TrackId is { } id - && PlayerService.CurrentTrack.Id == id; + /// + /// 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: + /// + /// it IS the host's pinned track (CurrentTrack.Id == TrackId) — the single-track + /// Mix/Session case, preserved at exact parity; or + /// it shares the host's release (CurrentTrack.Release.EntryKey == ReleaseEntryKey) — + /// a multi-track Cut where the release is fixed but the playing track scrolls. + /// + /// The release-match disjunct is what lets the visualizer FOLLOW the live track as playback advances + /// past the host's fixed (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. + /// + 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;