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; /// /// 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 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. /// /// Strictly read-only (spec §D): no seek, no two-way write-back. 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 parameter /// is the composability fallback for hosts that have no player cascade (e.g. an embed) and want to drive /// position themselves. /// 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 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; } /// /// 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. /// [Parameter] public required string ReleaseEntryKey { get; set; } /// /// 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 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; } /// /// 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. /// [Parameter] public double PlaybackPosition { get; set; } /// /// Container-sizing mode (phase-12 §6c). Default false 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 true 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 window), so the JS module is /// identical in both modes; Fill only changes which box that canvas occupies. /// [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(); } /// /// 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 => LivePlayerTrack?.EntryKey ?? TrackEntryKey; /// /// 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. /// 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( "import", "./js/visualizer/WaveformVisualizer.js"); _handle = await _module.InvokeAsync("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. ─────────────────────────── /// /// 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: /// /// scroll speed [0,1] is mapped onto the useful zoom band via /// and pushed through setScrollSpeed /// (higher speed → tighter window → faster scroll); /// gradient rotation speed → setGradientRotationSpeed (live OKLab anchor rotation); /// gravity / heat / collision strength → their dedicated lava-physics dials; /// fluid amount → setFluidAmount (blob count + volume); fluid viscosity → /// setFluidViscosity (cohesion / sphere-restoration) — the Phase 10 split of the /// former single density knob; /// waveform width → the ribbon-extent uniform; /// lava / waveform enabled → setLavaEnabled / setWaveformEnabled, the genuine /// per-subsystem draw-skip (no physics / no blob upload, ribbon SDF skipped — §10.1). /// /// 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); } /// /// 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. /// 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. ─ /// /// 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 => 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; } } }