fa01b9c8e0
Toggle left of the lava popover hides release content so the visualizer fills the surface; player bar grows to carry the playing release's cover, title, and share. State on WaveformVisualizerControlState; pages and bar observe it.
456 lines
19 KiB
C#
456 lines
19 KiB
C#
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<bool> 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) 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;
|
||
|
||
// 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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private bool IsStaged => PlayerService is { IsLoaded: false, IsLoading: false, CurrentTrack: not null };
|
||
|
||
/// <summary>Play is available once a track is loaded, or staged and waiting for the first gesture.</summary>
|
||
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<TrackDto>? _queueItemsCache;
|
||
private IReadOnlyList<TrackDto> 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;
|
||
|
||
/// <summary>
|
||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||
/// </summary>
|
||
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 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.
|
||
|
||
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<IJSObjectReference?> GetSpacerModuleAsync()
|
||
{
|
||
try
|
||
{
|
||
return _spacerModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
|
||
"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<IJSObjectReference?> GetEmbedModuleAsync()
|
||
{
|
||
try
|
||
{
|
||
return _embedModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
|
||
"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);
|
||
|
||
/// <summary>
|
||
/// The single assignment site for <see cref="_isMinimized"/>. Guards no-op transitions,
|
||
/// fires <see cref="OnMinimized"/> so MainLayout's spacer class stays in sync, and renders
|
||
/// so OnAfterRenderAsync re-evaluates the ResizeObserver on every transition path.
|
||
/// The <c>Fixed</c> branch in OnParametersSet intentionally bypasses this — it is a
|
||
/// prerender/parameter pass, not a user-driven transition, and the embed host has no spacer.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
} |