diff --git a/DeepDrftPublic.Client/Controls/QueueList.razor b/DeepDrftPublic.Client/Controls/QueueList.razor
new file mode 100644
index 0000000..3a46fdc
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/QueueList.razor
@@ -0,0 +1,124 @@
+@namespace DeepDrftPublic.Client.Controls
+@using DeepDrftModels.DTOs
+
+@* Shared presentational queue list. Renders the ordered queue with the current track marked, and
+ (when Editable) drag-reorder handles + per-row remove controls. This is the single "view" both
+ the docked overlay (17.2) and the embedded panel (17.3) consume — one source, multiple views.
+
+ Purely presentational: owns no data fetch, no player wiring, and no IQueueService mutation of its
+ own. Order changes, removals, and row jumps are surfaced to the parent as EventCallbacks; the
+ parent calls the queue engine. It runs during prerender without JS interop (MudDropContainer's
+ drag work is client-only and inert when no drag occurs). *@
+
+@if (Items is { Count: > 0 })
+{
+ @if (Editable)
+ {
+
+
+
+
+
+ @RenderRow(context)
+
+
+ }
+ else
+ {
+
+ @foreach (var row in Rows)
+ {
+ @RenderRow(row)
+ }
+
+ }
+}
+
+@code {
+ /// The ordered tracks to render. Empty/null renders nothing.
+ [Parameter] public IReadOnlyList? Items { get; set; }
+
+ ///
+ /// Index of the current track within , or -1 when none. The matching row is
+ /// rendered with a now-playing marker.
+ ///
+ [Parameter] public int CurrentIndex { get; set; } = -1;
+
+ ///
+ /// When true, rows show drag handles and a remove control and reorder is enabled. When false the
+ /// list is a read-only display (the embed's fixed-order shared queue).
+ ///
+ [Parameter] public bool Editable { get; set; }
+
+ ///
+ /// Raised when the user reorders a row: (fromIndex, toIndex). The parent calls
+ /// IQueueService.Move. Only fires when .
+ ///
+ [Parameter] public EventCallback<(int FromIndex, int ToIndex)> OnReorder { get; set; }
+
+ ///
+ /// Raised when the user removes a row, carrying the row's index. The parent calls
+ /// IQueueService.RemoveAt. Only fires when .
+ ///
+ [Parameter] public EventCallback OnRemove { get; set; }
+
+ ///
+ /// Raised when the user clicks a row body to jump playback to it, carrying the row's index. The
+ /// parent decides whether/how to honour it (e.g. play from that index).
+ ///
+ [Parameter] public EventCallback OnJump { get; set; }
+
+ private MudDropContainer? _dropContainer;
+
+ // Index-tagged view rows. The index is the row's position in Items at render time and is the
+ // value surfaced to the parent's callbacks — the component never mutates the underlying list.
+ private List Rows =>
+ Items is null
+ ? []
+ : Items.Select((track, index) => new QueueRow(index, track)).ToList();
+
+ private async Task OnItemDropped(MudItemDropInfo dropInfo)
+ {
+ var from = dropInfo.Item!.Index;
+ var to = dropInfo.IndexInZone;
+ // MudDropContainer recomputes the list from the parent's next render; refresh its snapshot so
+ // the dragged row snaps back until the parent's Move re-flows the cascaded Items.
+ _dropContainer?.Refresh();
+ if (from == to) return;
+ await OnReorder.InvokeAsync((from, to));
+ }
+
+ private sealed record QueueRow(int Index, TrackDto Track);
+
+ private RenderFragment RenderRow(QueueRow row) => __builder =>
+ {
+ var isCurrent = row.Index == CurrentIndex;
+
+ @if (Editable)
+ {
+
+ }
+
@(row.Index + 1)
+
OnJump.InvokeAsync(row.Index)">
+ @row.Track.TrackName
+ @if (row.Track.Release is { Artist: var artist } && !string.IsNullOrWhiteSpace(artist))
+ {
+ @artist
+ }
+
+ @if (isCurrent)
+ {
+
+ }
+ @if (Editable)
+ {
+
+ }
+
+ };
+}
diff --git a/DeepDrftPublic.Client/Services/IQueueService.cs b/DeepDrftPublic.Client/Services/IQueueService.cs
index 2bd0f9e..7ad355e 100644
--- a/DeepDrftPublic.Client/Services/IQueueService.cs
+++ b/DeepDrftPublic.Client/Services/IQueueService.cs
@@ -87,12 +87,45 @@ public interface IQueueService
///
Task Start();
- /// Appends a track to the end of the queue without changing what is currently playing.
+ ///
+ /// Appends a track to the end of the queue without changing what is currently playing.
+ /// Into a dormant queue ( == -1) the append leaves a coherent
+ /// (the first appended track) so a subsequent play/skip is correct —
+ /// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
+ ///
void Enqueue(TrackDto track);
- /// Appends tracks to the end of the queue without changing what is currently playing.
+ ///
+ /// Appends tracks to the end of the queue without changing what is currently playing.
+ /// Into a dormant queue ( == -1) the append leaves a coherent
+ /// (the first appended track) so a subsequent play/skip is correct —
+ /// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
+ ///
void EnqueueRange(IEnumerable tracks);
+ ///
+ /// Reorders the queue, moving the track at to
+ /// , and re-emits . Adjusts
+ /// so the same track stays current across the move — it does
+ /// not restart, re-stream, or interrupt the currently-playing track. Interop-free; safe during
+ /// prerender. No-op (no throw, no ) when either index is out of range
+ /// or the indices are equal.
+ ///
+ void Move(int fromIndex, int toIndex);
+
+ ///
+ /// Removes the track at and re-emits . Does
+ /// not touch playback (the player stays a single-track device): removing the current track does
+ /// not stop it — the playing track runs to its natural end while
+ /// resolves to the new occupant of that slot (the next track) so the next auto-advance/skip is
+ /// coherent. Removing a track before the current decrements (the same
+ /// track stays current); removing after the current leaves it unchanged. Removing the last
+ /// remaining track empties the queue ( == -1, dormant). Interop-free;
+ /// safe during prerender. No-op (no throw, no ) when
+ /// is out of range.
+ ///
+ void RemoveAt(int index);
+
///
/// Advances to the next track and streams it. No-op when is false.
///
diff --git a/DeepDrftPublic.Client/Services/QueueService.cs b/DeepDrftPublic.Client/Services/QueueService.cs
index bdebd4c..7eb16c8 100644
--- a/DeepDrftPublic.Client/Services/QueueService.cs
+++ b/DeepDrftPublic.Client/Services/QueueService.cs
@@ -95,6 +95,11 @@ public sealed class QueueService : IQueueService, IDisposable
public void Enqueue(TrackDto track)
{
_items.Add(track);
+ // OQ8: appending into a dormant (empty) queue leaves a coherent CurrentIndex so the next
+ // play/skip is correct — but does NOT auto-play (add is not play). PlayCurrent is never
+ // called here, so this stays interop-free and prerender-safe.
+ if (CurrentIndex == -1)
+ CurrentIndex = 0;
QueueChanged?.Invoke();
}
@@ -102,8 +107,71 @@ public sealed class QueueService : IQueueService, IDisposable
{
var before = _items.Count;
_items.AddRange(tracks);
- if (_items.Count != before)
- QueueChanged?.Invoke();
+ if (_items.Count == before) return;
+ // OQ8: see Enqueue — first append into a dormant queue stages a coherent CurrentIndex
+ // without playing. The first newly-appended track becomes current.
+ if (CurrentIndex == -1)
+ CurrentIndex = 0;
+ QueueChanged?.Invoke();
+ }
+
+ public void Move(int fromIndex, int toIndex)
+ {
+ if (fromIndex == toIndex) return;
+ if (fromIndex < 0 || fromIndex >= _items.Count) return;
+ if (toIndex < 0 || toIndex >= _items.Count) return;
+
+ var moved = _items[fromIndex];
+ _items.RemoveAt(fromIndex);
+ _items.Insert(toIndex, moved);
+
+ // Keep the same track current across the reorder. No playback is touched (C2): we only
+ // recompute which index the current track now sits at.
+ if (CurrentIndex == fromIndex)
+ {
+ CurrentIndex = toIndex;
+ }
+ else if (fromIndex < CurrentIndex && toIndex >= CurrentIndex)
+ {
+ // The current track shifted one slot toward the front to fill the vacated lower slot.
+ CurrentIndex--;
+ }
+ else if (fromIndex > CurrentIndex && toIndex <= CurrentIndex)
+ {
+ // An item inserted at/above the current slot pushed the current track one slot back.
+ CurrentIndex++;
+ }
+
+ QueueChanged?.Invoke();
+ }
+
+ public void RemoveAt(int index)
+ {
+ if (index < 0 || index >= _items.Count) return;
+
+ _items.RemoveAt(index);
+
+ if (_items.Count == 0)
+ {
+ // Last remaining track removed → empty + dormant. Does not stop the player (C2).
+ CurrentIndex = -1;
+ }
+ else if (index < CurrentIndex)
+ {
+ // A track before the current was removed: the same track stays current at a lower index.
+ CurrentIndex--;
+ }
+ else if (index == CurrentIndex && CurrentIndex >= _items.Count)
+ {
+ // The current track was removed and it was the last slot: there is no "next" occupant to
+ // resolve to, so the queue goes dormant (CurrentIndex == -1). Playback is NOT stopped
+ // (C2) — the just-removed track keeps playing to its natural end; auto-advance simply has
+ // nothing further. Removing current when it is NOT the last leaves CurrentIndex pointing
+ // at the new occupant of that slot (the next track), so no adjustment is needed there.
+ CurrentIndex = -1;
+ }
+
+ QueueChanged?.Invoke();
}
public async Task Next()
diff --git a/DeepDrftTests/QueueServiceTests.cs b/DeepDrftTests/QueueServiceTests.cs
index dcd5e1b..552bef0 100644
--- a/DeepDrftTests/QueueServiceTests.cs
+++ b/DeepDrftTests/QueueServiceTests.cs
@@ -331,14 +331,17 @@ public class QueueServiceTests
// --- Enqueue / EnqueueRange ---
[Test]
- public void Enqueue_AppendsWithoutChangingCurrentOrStartingPlayback()
+ public void Enqueue_IntoDormantQueue_StagesCoherentIndexWithoutStartingPlayback()
{
+ // OQ8 (Phase 17 wave 17.1): the first add into a dormant queue stages a coherent
+ // CurrentIndex (0) so the next play/skip is correct, but does NOT begin playback. (This
+ // supersedes the pre-OQ8 expectation that a dormant Enqueue left CurrentIndex at -1.)
_queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Has.Count.EqualTo(1));
- Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_player.SelectedTracks, Is.Empty);
});
}
@@ -366,6 +369,296 @@ public class QueueServiceTests
Assert.That(_queue.Items, Has.Count.EqualTo(3));
}
+ // --- Move (reorder) — Phase 17 wave 17.1 ---
+
+ [Test]
+ public async Task Move_ReordersItems_AndRaisesQueueChangedOnce()
+ {
+ // T1: Move(2, 0) on a 4-item queue reorders correctly; QueueChanged fired once.
+ await _queue.PlayRelease(Tracks(4));
+ var changed = 0;
+ _queue.QueueChanged += () => changed++;
+
+ _queue.Move(2, 0);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.Items.Select(t => t.EntryKey),
+ Is.EqualTo(new[] { "track-3", "track-1", "track-2", "track-4" }));
+ Assert.That(changed, Is.EqualTo(1));
+ });
+ }
+
+ [Test]
+ public async Task Move_MovingOtherItemsAroundCurrent_KeepsSameTrackCurrent()
+ {
+ // T2 (moving others): with CurrentIndex on track-2, moving an item across it leaves track-2 current.
+ await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1
+
+ _queue.Move(3, 0); // move track-4 to the front → [4,1,2,3]; track-2 now at index 2
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
+ });
+ }
+
+ [Test]
+ public async Task Move_MovingCurrentItself_UpdatesCurrentIndexToNewSlot()
+ {
+ // T2 (moving current): moving the current track updates CurrentIndex to its new slot.
+ await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1
+
+ _queue.Move(1, 3); // move track-2 to the end → [1,3,4,2]
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(3));
+ });
+ }
+
+ [Test]
+ public async Task Move_DoesNotStreamAnything()
+ {
+ // T3: Move drives no playback — the fake player records no further SelectTrackStreaming call.
+ await _queue.PlayRelease(Tracks(4));
+ var streamedBefore = _player.SelectedTracks.Count;
+
+ _queue.Move(3, 0);
+ _queue.Move(0, 2);
+
+ Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
+ "Move must not stream — it is a pure list/index mutation");
+ }
+
+ [Test]
+ public async Task Move_ReorderingCurrentTrack_AutoAdvancePointsAtCorrectNext()
+ {
+ // T10: reorder the currently-playing item; the next auto-advance follows the new order.
+ await _queue.PlayRelease(Tracks(4)); // current = track-1 at index 0
+
+ _queue.Move(0, 2); // move current track-1 to index 2 → [2,3,1,4]; track-1 still current at index 2
+
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
+
+ _player.RaiseTrackEnded(); // organic end of track-1 → advance to index 3 (track-4)
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(3));
+ Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-4"));
+ Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-4"));
+ });
+ }
+
+ [Test]
+ public async Task Move_OutOfRangeOrEqualIndices_AreNoOpsAndDoNotRaiseQueueChanged()
+ {
+ // T8 (Move half): out-of-range and equal-index moves do not throw and do not fire QueueChanged.
+ await _queue.PlayRelease(Tracks(3));
+ var raised = false;
+ _queue.QueueChanged += () => raised = true;
+
+ Assert.DoesNotThrow(() =>
+ {
+ _queue.Move(-1, 0);
+ _queue.Move(0, 5);
+ _queue.Move(5, 0);
+ _queue.Move(1, 1);
+ });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(raised, Is.False);
+ Assert.That(_queue.Items.Select(t => t.EntryKey),
+ Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
+ });
+ }
+
+ // --- RemoveAt — Phase 17 wave 17.1 ---
+
+ [Test]
+ public async Task RemoveAt_AfterCurrent_LeavesCurrentIndexUnchanged()
+ {
+ // T4: removing a track after the current leaves CurrentIndex unchanged, item gone.
+ await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1
+
+ _queue.RemoveAt(3); // remove track-4 (after current)
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
+ Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
+ Assert.That(_queue.Items.Select(t => t.EntryKey),
+ Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
+ });
+ }
+
+ [Test]
+ public async Task RemoveAt_BeforeCurrent_DecrementsCurrentIndex_SameTrackStaysCurrent()
+ {
+ // T5: removing before the current decrements CurrentIndex; the same track stays current.
+ await _queue.PlayRelease(Tracks(4), startIndex: 2); // current = track-3 at index 2
+
+ _queue.RemoveAt(0); // remove track-1 (before current)
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
+ Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
+ });
+ }
+
+ [Test]
+ public async Task RemoveAt_OfCurrent_NotLast_DoesNotStop_AndResolvesToNextOccupant()
+ {
+ // T6 (not last): removing the current track does not stop the player; CurrentIndex resolves
+ // to the new occupant of that slot (the next track).
+ await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1
+ var streamedBefore = _player.SelectedTracks.Count;
+
+ _queue.RemoveAt(1); // remove current track-2 → [1,3,4]; index 1 now holds track-3
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
+ Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
+ Assert.That(_player.StopCount, Is.EqualTo(0), "RemoveAt must not stop playback (C2)");
+ Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
+ "RemoveAt must not re-stream");
+ });
+ }
+
+ [Test]
+ public async Task RemoveAt_OfCurrent_WhenLastSlot_ResolvesToDormantMinusOne()
+ {
+ // T6 (last slot, others remain): removing the current track when it is the last item has no
+ // next occupant → CurrentIndex resolves to -1; playback is not stopped.
+ await _queue.PlayRelease(Tracks(3), startIndex: 2); // current = track-3 at last index 2
+
+ _queue.RemoveAt(2);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
+ Assert.That(_queue.Current, Is.Null);
+ Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2" }));
+ Assert.That(_player.StopCount, Is.EqualTo(0));
+ });
+ }
+
+ [Test]
+ public async Task RemoveAt_OfLastRemainingTrack_EmptiesAndGoesDormant()
+ {
+ // T7: removing the last remaining item → empty + dormant; QueueChanged fired.
+ await _queue.PlayRelease(Tracks(1));
+ var raised = false;
+ _queue.QueueChanged += () => raised = true;
+
+ _queue.RemoveAt(0);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.Items, Is.Empty);
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
+ Assert.That(_queue.Current, Is.Null);
+ Assert.That(raised, Is.True);
+ });
+ }
+
+ [Test]
+ public async Task RemoveAt_OutOfRange_IsNoOpAndDoesNotRaiseQueueChanged()
+ {
+ // T8 (RemoveAt half): out-of-range removal does not throw and does not fire QueueChanged.
+ await _queue.PlayRelease(Tracks(3));
+ var raised = false;
+ _queue.QueueChanged += () => raised = true;
+
+ Assert.DoesNotThrow(() =>
+ {
+ _queue.RemoveAt(-1);
+ _queue.RemoveAt(3);
+ });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(raised, Is.False);
+ Assert.That(_queue.Items, Has.Count.EqualTo(3));
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
+ });
+ }
+
+ // --- Dormant Enqueue (OQ8: pure append, coherent index, no auto-play) — Phase 17 wave 17.1 ---
+
+ [Test]
+ public async Task Enqueue_IntoDormantQueue_LeavesCoherentCurrentIndexWithoutPlaying_ThenPlayStartsCorrectly()
+ {
+ // T9: Enqueue into a dormant queue stages a coherent CurrentIndex (0) but does not auto-play;
+ // a subsequent play (Next from the staged position, or PlayRelease) behaves per OQ8.
+ _queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "first add stages a coherent index");
+ Assert.That(_queue.Current!.EntryKey, Is.EqualTo("a"));
+ Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed");
+ Assert.That(_queue.IsArmed, Is.False, "dormant Enqueue does not arm");
+ });
+
+ _queue.Enqueue(new TrackDto { EntryKey = "b", TrackName = "B" });
+
+ Assert.Multiple(() =>
+ {
+ // Second add appends without disturbing the staged current.
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
+ Assert.That(_queue.HasNext, Is.True);
+ Assert.That(_player.SelectedTracks, Is.Empty);
+ });
+
+ // The coherent index means a skip-forward streams the right track without a prior play.
+ await _queue.Next();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
+ Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("b"));
+ });
+ }
+
+ [Test]
+ public void EnqueueRange_IntoDormantQueue_StagesCoherentIndexWithoutPlaying()
+ {
+ // T9 (range variant): EnqueueRange into a dormant queue stages index 0, streams nothing.
+ _queue.EnqueueRange(Tracks(3));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.Items, Has.Count.EqualTo(3));
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
+ Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-1"));
+ Assert.That(_player.SelectedTracks, Is.Empty);
+ Assert.That(_queue.IsArmed, Is.False);
+ });
+ }
+
+ [Test]
+ public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex()
+ {
+ // Guard the non-dormant path stays unchanged: appending while playing leaves current put.
+ await _queue.PlayRelease(Tracks(2), startIndex: 1); // current = track-2 at index 1
+
+ _queue.Enqueue(new TrackDto { EntryKey = "tail", TrackName = "Tail" });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_queue.CurrentIndex, Is.EqualTo(1), "active-queue Enqueue must not disturb current");
+ Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
+ });
+ }
+
// --- Clear ---
[Test]