feat(player): docked queue overlay with reorder, remove, jump, and clear-upcoming

Add a Queue toggle to the docked player bar opening a centered editable queue
overlay. New additive QueueService.ClearUpcoming keeps the playing track while
dropping the rest. Current track is non-removable.
This commit is contained in:
daniel-c-harvey
2026-06-19 15:18:25 -04:00
parent 4317a2f9e7
commit fe3819f378
10 changed files with 413 additions and 5 deletions
@@ -19,6 +19,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private bool _isMinimized = true;
private bool _isSeeking = false;
private double _seekPosition = 0;
private bool _queueOpen = false;
private IStreamingPlayerService? _subscribedService;
private IQueueService? _subscribedQueue;
@@ -63,6 +64,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private bool HasNext => QueueService?.HasNext ?? false;
private bool HasPrevious => QueueService?.HasPrevious ?? false;
// Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue,
// mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue
// self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only.
private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0;
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
/// <summary>
/// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary>
@@ -106,7 +114,16 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private void OnQueueChanged() => InvokeAsync(StateHasChanged);
private void OnQueueChanged()
{
// 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()
{
@@ -120,6 +137,27 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
await QueueService.Previous();
}
private void ToggleQueue() => _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 reuses the existing "play from index" semantics (OQ2). This is the one queue action that
// touches playback — it streams the chosen track via the player.
private async Task OnQueueJump(int index)
{
if (QueueService == null) return;
await QueueService.PlayRelease(QueueService.Items, index);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// The Fixed embed is already in normal flow — no spacer/clip needed.
@@ -260,6 +298,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
_subscribedService = null;
}
if (_subscribedQueue != null)
{
_subscribedQueue.QueueChanged -= OnQueueChanged;
_subscribedQueue = null;
}
if (_spacerModule is not null)
{
try