using DeepDrftPublic.Client.Services; using DeepDrftPublic.Client.Clients; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; namespace DeepDrftPublic.Client.Controls; public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable { [Inject] public required AudioInteropService AudioInterop { get; set; } [Inject] public required TrackMediaClient TrackMediaClient { get; set; } [Inject] public required ILogger Logger { get; set; } [Inject] public required BeaconInterop Beacon { get; set; } [Inject] public required IPlayEventSink PlayEventSink { get; set; } [Inject] public required IAnonIdProvider AnonId { get; set; } private IStreamingPlayerService? _audioPlayerService; private QueueService? _queueService; [Parameter] public RenderFragment? ChildContent { get; set; } protected override void OnInitialized() { // 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. var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger); // Phase 16: bind the play-session tracker to the player after construction, the same way the // queue binds — the player is built with `new`, not DI, so threading telemetry through its // constructor would force the provider to over-resolve. The tracker owns the floor/bucket logic // and emits via the injected sink (the beacon in production); the beacon also drives the // page-unload close so a mid-play tab-close still records the listen. Attached on the concrete // type before it is exposed through the IStreamingPlayerService field. player.AttachTracker(new PlayTracker(PlayEventSink), Beacon); _audioPlayerService = player; // 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 // The queue orchestrates above the single-slot player. The player is not DI-registered // (constructed here), so the queue binds to it via Attach rather than constructor injection — // no construction cycle, no IServiceProvider. Cascaded alongside the player so the bar and a // future up-next panel both read it. _queueService = new QueueService(); _queueService.Attach(_audioPlayerService); } /// /// Warm the anon-id cache once the provider is interactive (Phase 16 wave 16.3). Done here, after the /// first render, because the localStorage read is JS interop — not available during prerender. By the /// time any play session closes and the sink reads AnonId.Current, the cache is populated; a /// play that somehow closes before this completes simply sends no anonId (acceptable over-count). The /// provider is the natural warm point: it is mounted in MainLayout, so it goes interactive on every /// page the player can play from. /// protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) await AnonId.EnsureLoadedAsync(); } /// /// Dispose the player on unmount so the JS setInterval driving progress /// callbacks no longer holds a DotNetObjectReference into a destroyed /// component (otherwise it throws every 100ms after navigation away). /// public async ValueTask DisposeAsync() { // Dispose the queue first so it unsubscribes from the player's TrackEnded before the // player tears down. _queueService?.Dispose(); _queueService = null; if (_audioPlayerService is IAsyncDisposable disposable) { await disposable.DisposeAsync(); } _audioPlayerService = null; } }