From fe3819f378fcb087d35aacb7844277f862cecb83 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 19 Jun 2026 15:18:25 -0400 Subject: [PATCH] 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. --- .../AudioPlayerBar/AudioPlayerBar.razor | 24 ++- .../AudioPlayerBar/AudioPlayerBar.razor.cs | 46 +++++- .../AudioPlayerBar/PlayerTransportZone.razor | 15 ++ .../PlayerTransportZone.razor.cs | 10 ++ .../Controls/QueueList.razor | 5 +- .../Controls/QueueOverlay.razor | 69 ++++++++ .../Services/IQueueService.cs | 11 ++ .../Services/QueueService.cs | 14 ++ .../wwwroot/styles/deepdrft-styles.css | 155 ++++++++++++++++++ DeepDrftTests/QueueServiceTests.cs | 69 ++++++++ 10 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 DeepDrftPublic.Client/Controls/QueueOverlay.razor diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor index 425ed2e..54cc9c5 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor @@ -25,6 +25,9 @@ else HasPrevious="HasPrevious" SkipNext="@SkipNext" SkipPrevious="@SkipPrevious" + ShowQueueButton="ShowQueueButton" + QueueOpen="_queueOpen" + QueueToggle="@ToggleQueue" Class="transport-zone"/> @@ -49,12 +52,27 @@ else @if (!string.IsNullOrEmpty(ErrorMessage)) { - @ErrorMessage } + + @* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in + the dock subtree does not affect its screen-centered rendering. Only mounted in docked mode — + the Fixed embed gets its own inline panel in a later wave. *@ + @if (ShowQueueButton) + { + + } } diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs index f69963a..cb84f52 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs @@ -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 QueueItems => QueueService?.Items ?? []; + private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1; + /// /// Display time - shows seek position while dragging, otherwise current playback time. /// @@ -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 diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor index 3510f38..9e585cf 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor @@ -20,5 +20,20 @@ Indeterminate="@(LoadProgress == 0)"/> } + @* Queue toggle: a second row between the transport controls and the timestamp (§3.1 placement — + "below the control buttons, to the left of the timestamps"). Shown only when a queue is loaded, + mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@ + @if (ShowQueueButton) + { + + + + } diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor.cs index aa9bf02..71471e2 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor.cs @@ -18,5 +18,15 @@ public partial class PlayerTransportZone : ComponentBase [Parameter] public bool HasPrevious { get; set; } [Parameter] public EventCallback SkipNext { get; set; } [Parameter] public EventCallback SkipPrevious { get; set; } + + /// Whether to render the Queue toggle button. Gated on a non-empty queue by the bar. + [Parameter] public bool ShowQueueButton { get; set; } + + /// Whether the queue overlay is open. Drives the button's active state. + [Parameter] public bool QueueOpen { get; set; } + + /// Raised when the Queue button is clicked. The bar toggles the overlay. + [Parameter] public EventCallback QueueToggle { get; set; } + [Parameter] public string? Class { get; set; } } diff --git a/DeepDrftPublic.Client/Controls/QueueList.razor b/DeepDrftPublic.Client/Controls/QueueList.razor index 3a46fdc..6e473a2 100644 --- a/DeepDrftPublic.Client/Controls/QueueList.razor +++ b/DeepDrftPublic.Client/Controls/QueueList.razor @@ -113,7 +113,10 @@ } - @if (Editable) + @* The current track cannot be removed (OQ3/OQ11): the queue empties only organically as the + current ends with nothing after it. Suppress the × on the current row only — reorder of the + current track is still allowed. *@ + @if (Editable && !isCurrent) { +
+
+ Up Next + Clear +
+
+ +
+
+ + +@code { + /// Whether the overlay is shown. Owned by the parent (the Queue button toggles it). + [Parameter] public bool Visible { get; set; } + + /// The queue to render. Passed straight through to . + [Parameter] public IReadOnlyList? Items { get; set; } + + /// Index of the current track within , or -1 when none. + [Parameter] public int CurrentIndex { get; set; } = -1; + + /// Raised when the scrim is clicked to dismiss the overlay. + [Parameter] public EventCallback OnClose { get; set; } + + /// Raised when Clear is pressed — empties the up-next, keeping the current track playing. + [Parameter] public EventCallback OnClear { get; set; } + + /// Reorder callback forwarded from the hosted . + [Parameter] public EventCallback<(int FromIndex, int ToIndex)> OnReorder { get; set; } + + /// Remove callback forwarded from the hosted . + [Parameter] public EventCallback OnRemove { get; set; } + + /// Jump-to-track callback forwarded from the hosted . + [Parameter] public EventCallback OnJump { get; set; } + + // Clear is meaningful only when there is something beyond the current track to discard. + private bool CanClear => Items is { Count: > 1 }; +} diff --git a/DeepDrftPublic.Client/Services/IQueueService.cs b/DeepDrftPublic.Client/Services/IQueueService.cs index 7ad355e..53d3c90 100644 --- a/DeepDrftPublic.Client/Services/IQueueService.cs +++ b/DeepDrftPublic.Client/Services/IQueueService.cs @@ -138,4 +138,15 @@ public interface IQueueService /// Empties the queue and resets the position. Does not stop the player. void Clear(); + + /// + /// Empties the up-next while keeping the currently-playing track: removes every item except + /// , leaving it as the sole remaining item at == 0, + /// and re-emits . Unlike (which empties everything and + /// goes dormant), this preserves what is playing — the player is never stopped and the current track + /// stays queued, so playback continues uninterrupted while the rest of the queue is discarded. + /// Interop-free; safe during prerender. No-op (no throw, no ) when the queue + /// is empty/dormant or already holds only the current track. + /// + void ClearUpcoming(); } diff --git a/DeepDrftPublic.Client/Services/QueueService.cs b/DeepDrftPublic.Client/Services/QueueService.cs index 7eb16c8..8696ec1 100644 --- a/DeepDrftPublic.Client/Services/QueueService.cs +++ b/DeepDrftPublic.Client/Services/QueueService.cs @@ -201,6 +201,20 @@ public sealed class QueueService : IQueueService, IDisposable QueueChanged?.Invoke(); } + public void ClearUpcoming() + { + // Keep the currently-playing track, drop everything else. No current track (dormant/empty) or a + // queue that already holds only the current → nothing to clear. + var current = Current; + if (current is null || _items.Count <= 1) return; + + _items.Clear(); + _items.Add(current); + CurrentIndex = 0; + // Playback is untouched (C2): the current track keeps streaming; we only discarded the up-next. + QueueChanged?.Invoke(); + } + // Advance on organic end-of-stream only. TrackEnded is not raised by stop/unload/track-switch, // so a manual stop or a fresh single-track selection elsewhere never spuriously advances the // queue. When the queue is past its last track, end-of-stream simply stops — nothing to advance. diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css index 773afd3..3bb29ba 100644 --- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css +++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css @@ -750,3 +750,158 @@ body:has(.waveform-visualizer-control-overlay) { color: var(--mud-palette-text-primary); opacity: 0.85; } + +/* ============================================================================= + QUEUE OVERLAY + LIST (Phase 17 wave 17.2 — docked queue panel) + + The overlay is a direct lift of the visualizer-control modal (Phase 15 §4): a centered MudOverlay + whose scrim tint + z-index + body-scroll lock match that idiom exactly. The panel chrome (square + corners, lighter-navy ground, thin light border) is the NowPlayingCard treatment (§5). MudOverlay + portals out of the component subtree to the body, so these are plain GLOBAL rules — CSS isolation + cannot reach portaled content. + ============================================================================= */ + +/* Raise the overlay above the sticky header (100), the fixed player dock (1200), and the minimized + FAB (1300) — same stacking decision as the visualizer overlay so the scrim tints the whole viewport. */ +.deepdrft-queue-overlay { + z-index: 1400 !important; +} + +/* Mild modal tint from the shared scrim token. The doubled selector (0,2,0) outranks MudBlazor's own + .mud-overlay-dark (0,1,0) regardless of stylesheet load order. */ +.deepdrft-queue-overlay .mud-overlay-scrim.mud-overlay-dark { + background-color: rgba(var(--deepdrft-scrim-rgb), var(--deepdrft-modal-scrim-alpha)); +} + +.deepdrft-queue-overlay .mud-overlay-content { + max-height: 90vh; + overflow: visible; +} + +/* Lock body scroll while the queue overlay is open (matches the visualizer overlay). */ +body:has(.deepdrft-queue-overlay) { + overflow: hidden; +} + +/* The mostly-square panel (§3.2: min(90vw, 520px)). NowPlayingCard chrome: square corners, lighter-navy + ground, thin light border. Internal column: fixed header over a scrollable list body. */ +.deepdrft-queue-modal { + display: flex; + flex-direction: column; + width: min(90vw, 520px); + height: min(90vw, 520px); + max-height: 90vh; + background: var(--deepdrft-panel-ground); + border: 1px solid var(--deepdrft-border-light); + border-radius: 0; + backdrop-filter: blur(8px); + overflow: hidden; +} + +.deepdrft-queue-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--deepdrft-border-light); +} + +/* Mono uppercase eyebrow — the NowPlayingCard .np-label typography, recoloured light (static). */ +.deepdrft-queue-modal-title { + font-family: var(--deepdrft-font-mono); + font-size: 0.72rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--deepdrft-white); + opacity: 0.85; +} + +.deepdrft-queue-modal-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 0.5rem 0.5rem 0.75rem; +} + +/* ── The list itself (consumed by QueueList in both modes; styled here once). ── */ +.deepdrft-queue-list { + display: flex; + flex-direction: column; +} + +.deepdrft-queue-zone { + display: flex; + flex-direction: column; +} + +.deepdrft-queue-row { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.45rem 0.5rem; + border-radius: 4px; + color: var(--deepdrft-white); + transition: background 0.15s ease; +} + +.deepdrft-queue-row:hover { + background: color-mix(in srgb, var(--deepdrft-white) 6%, transparent); +} + +/* Current track: a subtle green wash + left accent, matching the green = active principle. */ +.deepdrft-queue-row-current { + background: color-mix(in srgb, var(--deepdrft-green-accent) 14%, transparent); + box-shadow: inset 2px 0 0 0 var(--deepdrft-green-accent); +} + +.deepdrft-queue-drag-handle { + cursor: grab; + opacity: 0.45; + flex: 0 0 auto; +} + +.deepdrft-queue-position { + font-family: var(--deepdrft-font-mono); + font-size: 0.72rem; + opacity: 0.6; + min-width: 1.4rem; + text-align: right; + flex: 0 0 auto; +} + +/* Row body grows + truncates; clicking it jumps playback (OQ2). */ +.deepdrft-queue-body { + display: flex; + flex-direction: column; + gap: 0.1rem; + flex: 1 1 auto; + min-width: 0; + cursor: pointer; +} + +.deepdrft-queue-title { + font-size: 0.92rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.deepdrft-queue-artist { + font-size: 0.74rem; + opacity: 0.6; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.deepdrft-queue-nowplaying, +.deepdrft-queue-remove { + flex: 0 0 auto; +} + +/* Active (open) state for the bar's Queue toggle — a soft green chip behind the glyph, matching the + visualizer toggle's on-state idiom. */ +.deepdrft-queue-toggle-active { + background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent); + border-radius: 6px; +} diff --git a/DeepDrftTests/QueueServiceTests.cs b/DeepDrftTests/QueueServiceTests.cs index 552bef0..5087c4b 100644 --- a/DeepDrftTests/QueueServiceTests.cs +++ b/DeepDrftTests/QueueServiceTests.cs @@ -678,6 +678,75 @@ public class QueueServiceTests }); } + // --- ClearUpcoming (OQ5: keep the current track, drop the up-next) — Phase 17 wave 17.2 --- + + [Test] + public async Task ClearUpcoming_KeepsCurrentTrack_DropsTheRest_WithoutStopping() + { + // Current = track-2; ClearUpcoming leaves only track-2 at index 0 and does not stop the player. + await _queue.PlayRelease(Tracks(4), startIndex: 1); + var streamedBefore = _player.SelectedTracks.Count; + + _queue.ClearUpcoming(); + + Assert.Multiple(() => + { + Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-2" })); + Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); + Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); + Assert.That(_queue.HasNext, Is.False); + Assert.That(_queue.HasPrevious, Is.False); + Assert.That(_player.StopCount, Is.EqualTo(0), "ClearUpcoming must not stop playback (C2)"); + Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore), + "ClearUpcoming must not re-stream"); + }); + } + + [Test] + public async Task ClearUpcoming_RaisesQueueChangedOnce() + { + await _queue.PlayRelease(Tracks(3)); + var changed = 0; + _queue.QueueChanged += () => changed++; + + _queue.ClearUpcoming(); + + Assert.That(changed, Is.EqualTo(1)); + } + + [Test] + public async Task ClearUpcoming_WhenOnlyCurrentRemains_IsNoOpAndDoesNotRaiseQueueChanged() + { + await _queue.PlayRelease(Tracks(1)); + var raised = false; + _queue.QueueChanged += () => raised = true; + + _queue.ClearUpcoming(); + + Assert.Multiple(() => + { + Assert.That(_queue.Items, Has.Count.EqualTo(1)); + Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); + Assert.That(raised, Is.False); + }); + } + + [Test] + public void ClearUpcoming_OnEmptyQueue_IsNoOpAndDoesNotRaiseQueueChanged() + { + var raised = false; + _queue.QueueChanged += () => raised = true; + + _queue.ClearUpcoming(); + + Assert.Multiple(() => + { + Assert.That(_queue.Items, Is.Empty); + Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); + Assert.That(raised, Is.False); + }); + } + // --- QueueChanged notifications --- [Test]