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; } [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; // 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. private ElementReference _playerRoot; 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; /// /// 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; } } private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged); protected override async Task OnAfterRenderAsync(bool firstRender) { // Only the docked, expanded shape needs a spacer: the Fixed embed is // already in normal flow, and the minimized FAB floats clear of content. // Toggle the observer on the minimized/expanded transition only — the // ResizeObserver itself handles every size change in between. var shouldObserve = !_isMinimized && !Fixed; if (shouldObserve == _spacerObserved) return; var module = await GetSpacerModuleAsync(); if (module is null) return; if (shouldObserve) await module.InvokeVoidAsync("observe", _playerRoot); else await module.InvokeVoidAsync("unobserve"); _spacerObserved = shouldObserve; } 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; } } }