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; /// /// Full-page scrolling Mix waveform background. Standalone and reusable: give it a /// 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 /// MixVisualizer.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 on the Mix detail page comes from the cascaded player /// service (which also supplies the mix 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 MixWaveformVisualizer : ComponentBase, IAsyncDisposable { [Inject] public required IReleaseDataService ReleaseData { get; set; } [Inject] public required IJSRuntime JS { get; set; } [Inject] public required MixVisualizerControlState 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 Mix release whose waveform datum to fetch and render. [Parameter] public required long ReleaseId { get; set; } /// /// 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). /// [Parameter] public long? TrackId { 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; } // Bridge-level diagnostics. Mirrors the JS-side DEBUG flag in MixVisualizer.ts: when true the // datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed // `[MixVisualizer]`, 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 false once confirmed healthy. // ON for the Phase 10 reframe Wave R2 lava test (matches the JS-side DEBUG in // MixVisualizer.ts). Daniel evaluates the physics in-browser; flip back to false at // reframe close along with the JS flag. private static readonly bool Debug = true; private const string Tag = "[MixVisualizer]"; 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; private long? _loadedReleaseId; // 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. ReleaseId={ReleaseId}, TrackId={TrackId?.ToString() ?? "null"}."); } else if (PlayerService is null) { DebugLog($"NO player cascade — playback will never couple. ReleaseId={ReleaseId}, TrackId={TrackId?.ToString() ?? "null"}."); } // ReleaseId is the only fetch input; fetch once per id. 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 id, not on whether a profile came back. if (_loadedReleaseId == ReleaseId) return; _loadedReleaseId = ReleaseId; DebugLog($"fetching mix waveform datum for ReleaseId={ReleaseId}…"); var result = await ReleaseData.GetMixWaveform(ReleaseId); 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 (not generated yet, or not a Mix) — empty backdrop; the detail page still // renders its content over a plain background. _profile = null; DebugLog(result.Success ? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseId={ReleaseId}) — backdrop stays blank." : $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop 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 // 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"}."); await PushPlaybackAsync(); StateHasChanged(); }); protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { try { _module = await JS.InvokeAsync( "import", "./js/visualizer/MixVisualizer.js"); _handle = await _module.InvokeAsync("create", _canvas); } catch (JSException ex) { Logger.LogWarning(ex, "MixWaveformVisualizer: failed to load the visualizer module; rendering a plain backdrop."); return; } // Seed the module with the current state now that it exists. All four control values // come from the shared (session-persisted) state, so a mix opened mid-session seeds the // module with the slider 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 row mutated a slider on the shared state and raised Changed. Push all four control // values (cheap scalar interop). In the Phase 10 reframe Wave R2, three of them are re-routed to // the lava physics inside the JS handle (setBubblyness→gravity, setDetach→heat, // setColorShiftSpeed→collision) — see MixVisualizer.ts; the bridge contract is unchanged. 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. Used to seed on first render and /// to re-push when the controls row signals a change. In the Phase 10 reframe Wave R2 the four /// live controls are routed to the lava physics by the JS handle (see MixVisualizer.ts): /// Bubblyness→gravity, Detach→heat, ColorShiftSpeed→collision, and the repurposed resolution knob /// (WaveformWidth)→waveform width. VisibleSeconds is still seeded once via setZoom so the window /// holds at its default; the controls row no longer mutates it this wave. Bridge contract unchanged. /// private async Task PushControlsAsync() { if (_handle is null) return; await _handle.InvokeVoidAsync("setZoom", ControlState.VisibleSeconds); await _handle.InvokeVoidAsync("setBubblyness", ControlState.Bubblyness); await _handle.InvokeVoidAsync("setDetach", ControlState.Detach); await _handle.InvokeVoidAsync("setColorShiftSpeed", ControlState.ColorShiftSpeed); await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth); } /// /// 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 IsActivePlayer + 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 matching player 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; private double? PlayerDurationSeconds => IsActivePlayer && PlayerService!.Duration is > 0 ? PlayerService.Duration : null; private bool IsPlaying => IsActivePlayer && (PlayerService?.IsPlaying ?? false); private double CurrentPositionSeconds { get { // Prefer the matching 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) 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; } } }