2b42e01cd0
Queue owns ordered tracks, current index, skip-fwd/back, and auto-advance via the player's TrackEnded hook; binds through Attach (no ctor growth, no service-locator). Player-bar skip controls; empty-queue play unchanged. Adds QueueService unit tests.
255 lines
9.1 KiB
C#
255 lines
9.1 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!;
|
|
|
|
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.
|
|
private ElementReference _playerRoot;
|
|
private IJSObjectReference? _spacerModule;
|
|
private bool _spacerObserved;
|
|
|
|
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;
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
// 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<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;
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
} |