Files
deepdrft/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs
T

457 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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). 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;
/// <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 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, ~5660px)
// 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<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;
}
}
}