using DeepDrftModels.DTOs; using DeepDrftPublic.Client.Services; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using MudBlazor; namespace DeepDrftPublic.Client.Controls.AudioPlayerBar; public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable { [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [CascadingParameter] public IQueueService? QueueService { get; set; } [Parameter] public bool Fixed { get; set; } = false; [Parameter] public EventCallback OnMinimized { get; set; } [Inject] private IJSRuntime JsRuntime { get; set; } = default!; private bool _isMinimized = true; private bool _isSeeking = false; private double _seekPosition = 0; private IStreamingPlayerService? _subscribedService; private IQueueService? _subscribedQueue; // Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's // spacer reserves its space. We mirror this element's live height into a CSS // var via a ResizeObserver (see Interop/layout/spacer.ts) rather than a static // value, because the player reflows across breakpoints and grows with the // error banner. // // _miniDock is the minimized FAB container. We observe it in minimized state so // --player-height stays non-zero (the FAB's actual height) and the WaveformVisualizer // clips to the top of the FAB rather than extending to the viewport bottom (fix §1). // The player-spacer's .minimized class uses a hardcoded 60px and ignores the var, // so publishing the FAB height here does not regress the spacer. private ElementReference _playerRoot; private ElementReference _miniDock; private ElementReference _lastObservedElement; private IJSObjectReference? _spacerModule; private bool _spacerObserved; private bool IsLoaded => PlayerService?.IsLoaded ?? false; private bool IsLoading => PlayerService?.IsLoading ?? false; /// /// A track is staged when it has been selected as the current track but not yet loaded into /// the audio context (the embed's pre-gesture state). The first play click loads + plays it. /// private bool IsStaged => PlayerService is { IsLoaded: false, IsLoading: false, CurrentTrack: not null }; /// Play is available once a track is loaded, or staged and waiting for the first gesture. private bool CanPlay => IsLoaded || IsStaged; private bool IsStreaming => PlayerService?.CanStartStreaming ?? false; private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false; private double? Duration => PlayerService?.Duration; private TrackDto? CurrentTrack => PlayerService?.CurrentTrack; private double Volume => PlayerService?.Volume ?? 0; private double LoadProgress => PlayerService?.LoadProgress ?? 0; private string? ErrorMessage => PlayerService?.ErrorMessage; // Skip affordances reflect live queue state. With no queue (null) or an empty queue both are // false, so the buttons sit disabled and the bar behaves exactly as it did before the queue. private bool HasNext => QueueService?.HasNext ?? false; private bool HasPrevious => QueueService?.HasPrevious ?? false; /// /// Display time - shows seek position while dragging, otherwise current playback time. /// private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0); private string PlayerModeClass => Fixed ? "player-fixed" : "player-dock"; protected override void OnParametersSet() { if (Fixed) { _isMinimized = false; } // 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. Because the cascade is // IsFixed, the provider's re-render does NOT reliably re-render this bar // (it has no incoming parameters that change), so we subscribe to the // multicast StateChanged side-channel to re-render ourselves. if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService)) { if (_subscribedService != null) _subscribedService.StateChanged -= OnPlayerStateChanged; PlayerService.OnTrackSelected = new EventCallback(this, Expand); PlayerService.StateChanged += OnPlayerStateChanged; _subscribedService = PlayerService; } // The queue cascade is also IsFixed, so re-render the skip affordances off its own // change signal — same posture as the player StateChanged subscription above. if (QueueService != null && !ReferenceEquals(QueueService, _subscribedQueue)) { if (_subscribedQueue != null) _subscribedQueue.QueueChanged -= OnQueueChanged; QueueService.QueueChanged += OnQueueChanged; _subscribedQueue = QueueService; } } private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged); private void OnQueueChanged() => InvokeAsync(StateHasChanged); private async Task SkipNext() { if (QueueService == null) return; await QueueService.Next(); } private async Task SkipPrevious() { if (QueueService == null) return; await QueueService.Previous(); } protected override async Task OnAfterRenderAsync(bool firstRender) { // The Fixed embed is already in normal flow — no spacer/clip needed. // For the docked player: we observe in BOTH expanded and minimized states // so --player-height always reflects the live height of whichever element // is visible. This keeps the WaveformVisualizer clipped to the top of // the footer in both states (fix §1). // expanded → observe _playerRoot (full player bar, reflows across breakpoints) // minimized → observe _miniDock (floating FAB container, ~56–60px) // The player-spacer's .minimized class uses a hardcoded height and ignores // the var, so publishing the FAB height here does not regress the spacer. if (Fixed) return; var elementToObserve = _isMinimized ? _miniDock : _playerRoot; var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id; if (alreadyOnThisElement) return; var module = await GetSpacerModuleAsync(); if (module is null) return; await module.InvokeVoidAsync("observe", elementToObserve); _spacerObserved = true; _lastObservedElement = elementToObserve; } private async Task GetSpacerModuleAsync() { try { return _spacerModule ??= await JsRuntime.InvokeAsync( "import", "./js/layout/spacer.js"); } catch (JSException) { // Module failed to load — the spacer falls back to 0px (no overlap // guard, but the player still works). Nothing actionable here. return null; } } private async Task Expand() => await SetMinimized(false); /// /// The single assignment site for . Guards no-op transitions, /// fires so MainLayout's spacer class stays in sync, and renders /// so OnAfterRenderAsync re-evaluates the ResizeObserver on every transition path. /// The Fixed branch in OnParametersSet intentionally bypasses this — it is a /// prerender/parameter pass, not a user-driven transition, and the embed host has no spacer. /// private async Task SetMinimized(bool value) { if (_isMinimized == value) return; _isMinimized = value; if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(value); StateHasChanged(); } private async Task TogglePlayPause() { if (PlayerService == null) return; // Gesture-gated start: a staged-but-unloaded track (the embed autoplay path) is loaded on // the first play click — the user gesture the browser requires before audio can start. if (IsStaged) { await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!); 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; } private void OnSeekChange(double position) { _seekPosition = position; StateHasChanged(); } 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(); } private async Task ToggleMinimized() => await SetMinimized(!_isMinimized); private async Task Close() { if (PlayerService != null && PlayerService.IsLoaded) { await PlayerService.Unload(); } await SetMinimized(true); } public async ValueTask DisposeAsync() { if (_subscribedService != null) { _subscribedService.StateChanged -= OnPlayerStateChanged; _subscribedService = null; } if (_spacerModule is not null) { try { // Clear the var so a torn-down player can't strand a spacer height. await _spacerModule.InvokeVoidAsync("unobserve"); await _spacerModule.DisposeAsync(); } catch (JSException) { // Runtime already gone (navigation/teardown) — nothing to clean up. } _spacerModule = null; } } }