From b4fff43cb3d30d0b548660618225c4d52fff9b1c Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Thu, 21 May 2026 07:36:55 -0400 Subject: [PATCH 1/2] fix(public): break OnStateChanged callback chain and lazy-init audio player to stop circuit memory blowup --- .../AudioPlayerBar/AudioPlayerBar.razor.cs | 63 +++++++++---------- .../SpectrumVisualizer.razor.cs | 38 ++++------- .../Controls/AudioPlayerProvider.razor.cs | 21 +++---- 3 files changed, 48 insertions(+), 74 deletions(-) diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs index dd1c347..5cf4c85 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs @@ -7,7 +7,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar; public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable { - [CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; } + [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [Inject] public required IBrowserViewportService BrowserViewportService { get; set; } private bool _isMinimized = true; @@ -16,21 +16,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable private bool _isDesktop = true; private Guid _viewportSubscriptionId; - private bool IsLoaded => PlayerService.IsLoaded; - private bool IsLoading => PlayerService.IsLoading; - private bool IsStreaming => PlayerService.CanStartStreaming; - private bool IsStreamingMode => PlayerService.IsStreamingMode; - private bool IsPlaying => PlayerService.IsPlaying; - private bool IsPaused => PlayerService.IsPaused; - private double? Duration => PlayerService.Duration; - private double Volume => PlayerService.Volume; - private double LoadProgress => PlayerService.LoadProgress; - private string? ErrorMessage => PlayerService.ErrorMessage; + private bool IsLoaded => PlayerService?.IsLoaded ?? false; + private bool IsLoading => PlayerService?.IsLoading ?? false; + private bool IsStreaming => PlayerService?.CanStartStreaming ?? false; + private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false; + private bool IsPlaying => PlayerService?.IsPlaying ?? false; + private bool IsPaused => PlayerService?.IsPaused ?? false; + private double? Duration => PlayerService?.Duration; + private double Volume => PlayerService?.Volume ?? 0; + private double LoadProgress => PlayerService?.LoadProgress ?? 0; + private string? ErrorMessage => PlayerService?.ErrorMessage; /// /// Display time - shows seek position while dragging, otherwise current playback time. /// - private double DisplayTime => _isSeeking ? _seekPosition : PlayerService.CurrentTime; + private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0); /// /// Seek is enabled once track is loaded AND duration is known (from WAV header). @@ -38,26 +38,16 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable /// private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0; - protected override async Task OnInitializedAsync() + protected override void OnParametersSet() { - await base.OnInitializedAsync(); - // Set up EventCallback for track selection - PlayerService.OnTrackSelected = new EventCallback(this, Expand); - - // Store the original OnStateChanged callback set by the provider - var originalOnStateChanged = PlayerService.OnStateChanged; - - // Set up a wrapper that calls both the original callback and our StateHasChanged - PlayerService.OnStateChanged = new EventCallback(this, async () => + // PlayerService is cascaded by AudioPlayerProvider; once it arrives, + // wire our track-selection handler. The provider owns OnStateChanged — + // we intentionally do NOT wrap or replace it. Re-renders propagate + // from the provider via the standard Blazor child render path. + if (PlayerService != null) { - // Invoke the original callback (AudioPlayerProvider's StateHasChanged) - if (originalOnStateChanged.HasValue) - { - await originalOnStateChanged.Value.InvokeAsync(); - } - // Also trigger our own re-render - await InvokeAsync(StateHasChanged); - }); + PlayerService.OnTrackSelected = new EventCallback(this, Expand); + } } private async Task Expand() @@ -76,16 +66,19 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable private async Task TogglePlayPause() { + if (PlayerService == null) return; await PlayerService.TogglePlayPause(); } private async Task Stop() { + if (PlayerService == null) return; await PlayerService.Stop(); } private void OnSeekStart() { + if (PlayerService == null) return; _isSeeking = true; _seekPosition = PlayerService.CurrentTime; } @@ -98,20 +91,22 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable private async Task OnSeekEnd(double position) { + if (PlayerService == null) return; _isSeeking = false; await PlayerService.Seek(position); } private async Task OnVolumeChange(double volume) { + if (PlayerService == null) return; await PlayerService.SetVolume(volume); } - + private void ClearError() { - PlayerService.ClearError(); + PlayerService?.ClearError(); } - + private void ToggleMinimized() { _isMinimized = !_isMinimized; @@ -120,7 +115,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable private async Task Close() { - if (PlayerService.IsLoaded) + if (PlayerService != null && PlayerService.IsLoaded) { await PlayerService.Unload(); } diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/SpectrumVisualizer.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/SpectrumVisualizer.razor.cs index 6535f22..6689089 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/SpectrumVisualizer.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/SpectrumVisualizer.razor.cs @@ -7,7 +7,7 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable { [Inject] public required AudioInteropService AudioInterop { get; set; } - [CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; } + [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [Parameter] public int BucketCount { get; set; } = 32; @@ -15,46 +15,30 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable private double[] _spectrumData = Array.Empty(); private bool _isAnimating = false; private string? _playerId; - private EventCallback? _originalOnStateChanged; - private bool IsVisible => PlayerService.IsPlaying || PlayerService.IsPaused || _isAnimating; + private bool IsVisible => (PlayerService?.IsPlaying ?? false) || (PlayerService?.IsPaused ?? false) || _isAnimating; protected override void OnInitialized() { _spectrumData = new double[BucketCount]; + } - // Get the player ID from the service - if (PlayerService is AudioPlayerService baseService) + protected override async Task OnParametersSetAsync() + { + // Provider re-renders cascade down to children and re-run OnParametersSet. + // Pick up the player id once the cascade arrives, then drive animation + // state from the parent's current IsPlaying — no callback wrapping needed. + if (_playerId == null && PlayerService is AudioPlayerService baseService) { _playerId = baseService.PlayerId; } - // Chain into the existing OnStateChanged callback to detect play/pause - _originalOnStateChanged = PlayerService.OnStateChanged; - PlayerService.OnStateChanged = new EventCallback(this, async () => - { - // Call original callback first - if (_originalOnStateChanged.HasValue) - { - await _originalOnStateChanged.Value.InvokeAsync(); - } - // Then update our animation state - await UpdateAnimationState(); - }); - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - // Initial check in case already playing - await UpdateAnimationState(); - } + await UpdateAnimationState(); } private async Task UpdateAnimationState() { - if (string.IsNullOrEmpty(_playerId)) return; + if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return; var shouldAnimate = PlayerService.IsPlaying; diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs index 2272bea..707fe41 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs @@ -17,25 +17,20 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable protected override void OnInitialized() { - // Create the service immediately (but don't initialize yet) + // Create the service immediately (but don't initialize yet). + // The base class lazily initializes on first track selection via + // EnsureInitializedAsync — that path is correct because audio contexts + // require a user gesture anyway. Initializing eagerly here causes 4+ + // SignalR round-trips before any content is stable. _audioPlayerService = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger); - // Set up EventCallback to properly marshal UI updates back to UI thread - // Use InvokeAsync to ensure proper Blazor render cycle triggering + // Provider is the SOLE owner of OnStateChanged. When the service fires, + // the provider re-renders, which cascades to its children automatically. + // Children must not wrap or replace this callback. _audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged)); // OnTrackSelected will be set by individual child components that need it } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender && _audioPlayerService != null) - { - // Initialize the service after render when JavaScript is available - await _audioPlayerService.InitializeAsync(); - StateHasChanged(); - } - } - /// /// Dispose the player on unmount so the JS setInterval driving progress /// callbacks no longer holds a DotNetObjectReference into a destroyed From 5d823868c82920bd5e7f1faa91813deddf02fdff Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Thu, 21 May 2026 07:52:13 -0400 Subject: [PATCH 2/2] docs(AudioPlayerProvider): explain IsFixed=true intent and runtime-swap caveat --- DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor index c7c9cd8..48ae6bf 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor @@ -1,3 +1,7 @@ - +@* IsFixed="true": the StreamingAudioPlayerService instance is created once in OnInitialized + and is never replaced — the reference is stable for the lifetime of the component. + If instance swapping at runtime is ever needed, change IsFixed to false (adds subscription + overhead on every parent re-render, but allows children to see the new reference). *@ + @ChildContent \ No newline at end of file