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)
{
+
+
+
+@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]