From f296bbdf000982e58d480eb5b4ae76f11ad3f57c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 19 Jun 2026 14:32:08 -0400 Subject: [PATCH] Add queue Move/RemoveAt + dormant-Enqueue coherence and shared QueueList (Phase 17.1) --- .../Controls/QueueList.razor | 124 ++++++++ .../Services/IQueueService.cs | 37 ++- .../Services/QueueService.cs | 72 ++++- DeepDrftTests/QueueServiceTests.cs | 297 +++++++++++++++++- 4 files changed, 524 insertions(+), 6 deletions(-) create mode 100644 DeepDrftPublic.Client/Controls/QueueList.razor 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) +
+ @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]