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!; // Theater Mode (Phase 20). Property-injected (no constructor growth) so the bar can read // TheaterMode to mount the "now showing" band and re-render when the flag flips. The toggle lives on // the detail pages; the bar only observes — single source, multiple observers (§6). [Inject] private WaveformVisualizerControlState VisualizerControlState { get; set; } = default!; private bool _isMinimized = true; private bool _isSeeking = false; private double _seekPosition = 0; private bool _queueOpen = false; private IStreamingPlayerService? _subscribedService; private IQueueService? _subscribedQueue; private bool _subscribedToVisualizerState; // 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). The player-spacer's // .minimized class uses a hardcoded 60px and ignores the var, so this is belt-and- // braces; the var's sole live consumer is the spacer's .expanded height. private ElementReference _playerRoot; private ElementReference _miniDock; private ElementReference _lastObservedElement; private IJSObjectReference? _spacerModule; private bool _spacerObserved; // Fixed-embed → host resize handshake (OQ1 Option A). When the inline panel collapses/expands we // measure the player's live height and post it to the host so the iframe resizes to match. The // dirty flag defers the post to OnAfterRenderAsync so the DOM reflects the new panel state first. private IJSObjectReference? _embedModule; private bool _embedHeightDirty; private bool _embedHeightPosted; 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; // Queue button gating. The button appears in BOTH modes when a queue is loaded, mirroring the // skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue self, so a // single-track embed (empty queue) shows no button and no panel (UC6). In docked mode it toggles // the overlay; in Fixed mode it collapses/expands the inline panel (OQ1 Option A). private bool HasQueue => (QueueService?.Items.Count ?? 0) > 0; private bool ShowQueueButton => HasQueue; // The docked overlay mounts only in docked mode; the Fixed embed renders its inline panel instead. private bool ShowDockedOverlay => !Fixed && HasQueue; // The Fixed-mode inline panel: always shown (read-only, C3) when a release embed has a queue. // Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6). private bool ShowFixedPanel => Fixed && HasQueue; // Cached snapshot of the queue list (bug #4 fix). QueueService.Items returns the service's // backing list by reference, so passing it straight through means Blazor parameter diffing sees // an unchanged reference after an in-place Clear/remove/reorder and the child (QueueList / // MudDropContainer) keeps its stale snapshot until reopened. We snapshot on first access and // rebuild in OnQueueChanged, so every real mutation hands the child a NEW reference while // progress-tick re-renders (the frequent path) reuse the cached one without allocating. private IReadOnlyList? _queueItemsCache; private IReadOnlyList QueueItems => _queueItemsCache ??= QueueService is null ? [] : QueueService.Items.ToList(); private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1; // Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the // up-next out of the box; the Queue button collapses it to let the viewer reclaim iframe space. private bool _fixedPanelOpen = true; // The Queue button's "open" state differs by mode: docked tracks the overlay, Fixed tracks the // inline panel's expanded state. One button, mode-appropriate meaning. private bool QueueButtonOpen => Fixed ? _fixedPanelOpen : _queueOpen; /// /// 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; } // Theater Mode (Phase 20 §7): re-render the bar when TheaterMode flips so the "now showing" band // appears/disappears. VisualizerControlState is injected (one stable scoped instance per session), // so the subscribe is once-only — same idempotent subscribe-here / unsubscribe-on-dispose shape. if (!_subscribedToVisualizerState) { VisualizerControlState.Changed += OnVisualizerStateChanged; _subscribedToVisualizerState = true; } } private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged); private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged); private void OnQueueChanged() { // Invalidate the snapshot so QueueItems rebuilds a fresh list on the next render. // This gives Blazor a new reference on every real mutation (bug #4 reactivity preserved) // while progress-tick re-renders that don't go through here keep the cached reference. _queueItemsCache = null; // If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close // the overlay so it cannot strand open over an empty queue. The button gate hides the overlay // mount too, so this keeps state and view consistent. if (_queueOpen && (QueueService?.Items.Count ?? 0) == 0) _queueOpen = false; InvokeAsync(StateHasChanged); } private async Task SkipNext() { if (QueueService == null) return; await QueueService.Next(); } private async Task SkipPrevious() { if (QueueService == null) return; await QueueService.Previous(); } // Docked: toggle the overlay. Fixed: collapse/expand the inline panel and flag a height re-post so // the host iframe resizes to match the new panel state (OQ1 Option A). The post happens in // OnAfterRenderAsync (below) once the DOM reflects the new state, then degrades safely — the host // listener may simply not be present (Option B's behaviour). private void ToggleQueue() { if (Fixed) { _fixedPanelOpen = !_fixedPanelOpen; _embedHeightDirty = true; return; } _queueOpen = !_queueOpen; } private void CloseQueue() => _queueOpen = false; // Reorder/remove/clear are interop-free engine mutations (C2/C5): they never re-stream or interrupt // the playing track. QueueChanged re-renders the bar and the overlay's list. private void OnQueueReorder((int FromIndex, int ToIndex) move) => QueueService?.Move(move.FromIndex, move.ToIndex); private void OnQueueRemove(int index) => QueueService?.RemoveAt(index); private void ClearUpcoming() => QueueService?.ClearUpcoming(); // Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY, // not an in-place seek), so a jump cannot route through it without duplicating the queue. JumpTo // moves the pointer to the chosen row and streams it once — preserving deque order. This is the one // queue action besides PLAY/skip that touches playback. private async Task OnQueueJump(int index) { if (QueueService == null) return; await QueueService.JumpTo(index); } protected override async Task OnAfterRenderAsync(bool firstRender) { // Fixed embed: post the live player height to the host so the iframe sizes to the panel. We // post on the first render (so the host snaps to the expanded panel rather than the snippet's // initial guess) and whenever the panel is collapsed/expanded (_embedHeightDirty). No spacer/ // clip here — the embed is in normal flow. if (Fixed) { if (ShowFixedPanel && (!_embedHeightPosted || _embedHeightDirty)) { _embedHeightDirty = false; _embedHeightPosted = true; await PostEmbedHeight(); } return; } // For the docked player: we observe in BOTH expanded and minimized states // so --player-height stays non-zero and always reflects the live height of // whichever element is visible. The var's sole live consumer is the // player-spacer's .expanded height (keeps the spacer sized correctly across // breakpoints and banner reflows). // 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 60px and ignores // the var, so observing in minimized state is belt-and-braces; it does not // regress the spacer. 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; } } // Measure the player root's live height and post it to the host page (OQ1 Option A). Best-effort: // a missing module or a host that ignores the message just means no outer resize (Option B value). private async Task PostEmbedHeight() { var module = await GetEmbedModuleAsync(); if (module is null) return; try { await module.InvokeVoidAsync("postHeight", _playerRoot); } catch (JSException) { // Runtime gone or element detached mid-teardown — nothing actionable. } } private async Task GetEmbedModuleAsync() { try { return _embedModule ??= await JsRuntime.InvokeAsync( "import", "./js/embed/embed-frame.js"); } catch (JSException) { // Module failed to load — the panel still renders and toggles; only the outer resize is lost. 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) { // Release embed: the queue is armed with the whole release. Route the first gesture through // the queue so it takes over (streams track 0 and auto-advances) rather than streaming the // staged track in isolation. Single-track embeds leave the queue disarmed and fall through // to the direct stream below — unchanged. if (QueueService is { IsArmed: true }) { await QueueService.Start(); return; } 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 (_subscribedQueue != null) { _subscribedQueue.QueueChanged -= OnQueueChanged; _subscribedQueue = null; } if (_subscribedToVisualizerState) { VisualizerControlState.Changed -= OnVisualizerStateChanged; _subscribedToVisualizerState = false; } 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; } if (_embedModule is not null) { try { await _embedModule.DisposeAsync(); } catch (JSException) { // Runtime already gone (navigation/teardown) — nothing to clean up. } _embedModule = null; } } }