fix(public): break OnStateChanged callback chain and lazy-init audio player to stop circuit memory blowup

This commit is contained in:
Daniel Harvey
2026-05-21 07:36:55 -04:00
parent ae2bdcd065
commit b4fff43cb3
3 changed files with 48 additions and 74 deletions
@@ -7,7 +7,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable 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; } [Inject] public required IBrowserViewportService BrowserViewportService { get; set; }
private bool _isMinimized = true; private bool _isMinimized = true;
@@ -16,21 +16,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private bool _isDesktop = true; private bool _isDesktop = true;
private Guid _viewportSubscriptionId; private Guid _viewportSubscriptionId;
private bool IsLoaded => PlayerService.IsLoaded; private bool IsLoaded => PlayerService?.IsLoaded ?? false;
private bool IsLoading => PlayerService.IsLoading; private bool IsLoading => PlayerService?.IsLoading ?? false;
private bool IsStreaming => PlayerService.CanStartStreaming; private bool IsStreaming => PlayerService?.CanStartStreaming ?? false;
private bool IsStreamingMode => PlayerService.IsStreamingMode; private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false;
private bool IsPlaying => PlayerService.IsPlaying; private bool IsPlaying => PlayerService?.IsPlaying ?? false;
private bool IsPaused => PlayerService.IsPaused; private bool IsPaused => PlayerService?.IsPaused ?? false;
private double? Duration => PlayerService.Duration; private double? Duration => PlayerService?.Duration;
private double Volume => PlayerService.Volume; private double Volume => PlayerService?.Volume ?? 0;
private double LoadProgress => PlayerService.LoadProgress; private double LoadProgress => PlayerService?.LoadProgress ?? 0;
private string? ErrorMessage => PlayerService.ErrorMessage; private string? ErrorMessage => PlayerService?.ErrorMessage;
/// <summary> /// <summary>
/// Display time - shows seek position while dragging, otherwise current playback time. /// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary> /// </summary>
private double DisplayTime => _isSeeking ? _seekPosition : PlayerService.CurrentTime; private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0);
/// <summary> /// <summary>
/// Seek is enabled once track is loaded AND duration is known (from WAV header). /// Seek is enabled once track is loaded AND duration is known (from WAV header).
@@ -38,26 +38,16 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
/// </summary> /// </summary>
private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0; private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0;
protected override async Task OnInitializedAsync() protected override void OnParametersSet()
{ {
await base.OnInitializedAsync(); // PlayerService is cascaded by AudioPlayerProvider; once it arrives,
// Set up EventCallback for track selection // wire our track-selection handler. The provider owns OnStateChanged —
PlayerService.OnTrackSelected = new EventCallback(this, Expand); // we intentionally do NOT wrap or replace it. Re-renders propagate
// from the provider via the standard Blazor child render path.
// Store the original OnStateChanged callback set by the provider if (PlayerService != null)
var originalOnStateChanged = PlayerService.OnStateChanged;
// Set up a wrapper that calls both the original callback and our StateHasChanged
PlayerService.OnStateChanged = new EventCallback(this, async () =>
{ {
// Invoke the original callback (AudioPlayerProvider's StateHasChanged) PlayerService.OnTrackSelected = new EventCallback(this, Expand);
if (originalOnStateChanged.HasValue) }
{
await originalOnStateChanged.Value.InvokeAsync();
}
// Also trigger our own re-render
await InvokeAsync(StateHasChanged);
});
} }
private async Task Expand() private async Task Expand()
@@ -76,16 +66,19 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private async Task TogglePlayPause() private async Task TogglePlayPause()
{ {
if (PlayerService == null) return;
await PlayerService.TogglePlayPause(); await PlayerService.TogglePlayPause();
} }
private async Task Stop() private async Task Stop()
{ {
if (PlayerService == null) return;
await PlayerService.Stop(); await PlayerService.Stop();
} }
private void OnSeekStart() private void OnSeekStart()
{ {
if (PlayerService == null) return;
_isSeeking = true; _isSeeking = true;
_seekPosition = PlayerService.CurrentTime; _seekPosition = PlayerService.CurrentTime;
} }
@@ -98,20 +91,22 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private async Task OnSeekEnd(double position) private async Task OnSeekEnd(double position)
{ {
if (PlayerService == null) return;
_isSeeking = false; _isSeeking = false;
await PlayerService.Seek(position); await PlayerService.Seek(position);
} }
private async Task OnVolumeChange(double volume) private async Task OnVolumeChange(double volume)
{ {
if (PlayerService == null) return;
await PlayerService.SetVolume(volume); await PlayerService.SetVolume(volume);
} }
private void ClearError() private void ClearError()
{ {
PlayerService.ClearError(); PlayerService?.ClearError();
} }
private void ToggleMinimized() private void ToggleMinimized()
{ {
_isMinimized = !_isMinimized; _isMinimized = !_isMinimized;
@@ -120,7 +115,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private async Task Close() private async Task Close()
{ {
if (PlayerService.IsLoaded) if (PlayerService != null && PlayerService.IsLoaded)
{ {
await PlayerService.Unload(); await PlayerService.Unload();
} }
@@ -7,7 +7,7 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
{ {
[Inject] public required AudioInteropService AudioInterop { get; set; } [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; [Parameter] public int BucketCount { get; set; } = 32;
@@ -15,46 +15,30 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
private double[] _spectrumData = Array.Empty<double>(); private double[] _spectrumData = Array.Empty<double>();
private bool _isAnimating = false; private bool _isAnimating = false;
private string? _playerId; 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() protected override void OnInitialized()
{ {
_spectrumData = new double[BucketCount]; _spectrumData = new double[BucketCount];
}
// Get the player ID from the service protected override async Task OnParametersSetAsync()
if (PlayerService is AudioPlayerService baseService) {
// 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; _playerId = baseService.PlayerId;
} }
// Chain into the existing OnStateChanged callback to detect play/pause await UpdateAnimationState();
_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();
}
} }
private async Task UpdateAnimationState() private async Task UpdateAnimationState()
{ {
if (string.IsNullOrEmpty(_playerId)) return; if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return;
var shouldAnimate = PlayerService.IsPlaying; var shouldAnimate = PlayerService.IsPlaying;
@@ -17,25 +17,20 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
protected override void OnInitialized() 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); _audioPlayerService = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
// Set up EventCallback to properly marshal UI updates back to UI thread // Provider is the SOLE owner of OnStateChanged. When the service fires,
// Use InvokeAsync to ensure proper Blazor render cycle triggering // 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)); _audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
// OnTrackSelected will be set by individual child components that need it // 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();
}
}
/// <summary> /// <summary>
/// Dispose the player on unmount so the JS setInterval driving progress /// Dispose the player on unmount so the JS setInterval driving progress
/// callbacks no longer holds a DotNetObjectReference into a destroyed /// callbacks no longer holds a DotNetObjectReference into a destroyed