Merge queue-deque-redesign into dev

Two-level deque queue model + five bug fixes, plus review cleanup.
This commit is contained in:
daniel-c-harvey
2026-06-20 19:01:07 -04:00
13 changed files with 526 additions and 102 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1). Serializes the play classification and fires it via `BeaconInterop` to `api/event/play`. Synchronous (`EmitPlay` cannot await — it is called from the player close path and the page-unload handler). **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/telemetry/anonid.ts` (mints GUID on first visit, returns null without throwing when storage is unavailable).
- `IQueueService` / `QueueService`: Ordered playback orchestrator above the single-slot player. `PlayRelease(tracks, startIndex)` replaces the queue and starts streaming; `Next`/`Previous` advance or step back; `Enqueue`/`EnqueueRange` append without interrupting the current track; `Clear` empties the queue. **Armed-idle state** added to support prerender-safe release embeds: `Arm(tracks)` loads the track list at index 0 with no JS interop (safe during prerender); `IsArmed` signals the armed-but-not-streaming state; `Start()` begins streaming the current track and clears `IsArmed`, leaving the list and position intact so auto-advance carries on. `AudioPlayerBar` reads `IsArmed` to route the first play gesture through `Start()` instead of streaming the staged track alone. `QueueChanged` event fires on all list/position changes; cascaded via `AudioPlayerProvider`. **Wave 17.1 additions:** `Move(int fromIndex, int toIndex)` reorders `Items` in-place, adjusting `CurrentIndex` so the same track stays current across the move — never re-streams or interrupts playback; `RemoveAt(int index)` removes an item and adjusts `CurrentIndex` (removing the current track does not stop playback; removing the last remaining item leaves the queue empty and dormant). Both are interop-free state mutations that re-emit `QueueChanged`. **Dormant-`Enqueue` coherence (OQ8):** `Enqueue`/`EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) set `CurrentIndex` to 0 so a subsequent play/skip is correct — but do not auto-play. **Wave 17.2 additions:** `ClearUpcoming()` removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0` and re-emitting `QueueChanged` — touches no playback (OQ5: Clear does not stop or remove the current track). `PlayRelease` now always materializes a defensive copy of its input (`tracks.ToList()`) so it can never alias the service's own `Items` list — fixes a row-jump bug where `PlayRelease(Items, index)` could mutate the live list mid-operation.
- `IQueueService` / `QueueService`: **Two-level deque** orchestrator above the single-slot player. The deque has two entry ends. **PLAY (manual)** enters the FRONT: `PlayTrack(track)` and `PlayRelease(tracks, startIndex)` prepend the played track/release in order, **remove the previously-current track**, make the new front current, start streaming it, and leave whatever sat after the old current intact behind the prepend (a whole release prepends in order in one op). The detail pages (Cut header/row, Session/Mix hero) and `StreamNowButton` route their PLAY through these. **Add-to-queue** enters the BACK: `Enqueue`/`EnqueueRange` append to the end without interrupting the current track (`AddToQueueButton`). `Next`/`Previous` advance or step back, walking `CurrentIndex` and leaving played tracks behind so `Previous` can reach them; `JumpTo(index)` moves the pointer to a queued row and streams it once (the playlist panel's row-jump — it does NOT prepend or stream the intervening rows). **End-of-track:** auto-advance (`TrackEnded`) advances when there is a next track; when the **last** track ends naturally the queue **empties** and goes dormant (bug #2) rather than stranding the finished track. `Clear` empties the queue. **Bug #3 (dormant-seed):** the first `Enqueue`/`EnqueueRange` into a dormant queue while a track is already playing externally (via the attached player, not through the queue) seeds the head with that now-playing track and then appends — yielding `[now-playing, added]` (even when adding the same track). The queue learns the externally-playing track through the existing `Attach(player)` seam (`_player.CurrentTrack`) — no new dependency, no `IServiceProvider`. **Armed-idle state** (prerender-safe release embeds): `Arm(tracks)` replaces the queue at index 0 with no JS interop; `IsArmed` signals armed-but-not-streaming; `Start()` streams the current track and clears `IsArmed`. `AudioPlayerBar` reads `IsArmed` to route the embed's first play gesture through `Start()`. `QueueChanged` fires on all list/position changes; cascaded via `AudioPlayerProvider`. `Move`/`RemoveAt` are interop-free reorder/remove mutations that adjust `CurrentIndex` and never re-stream. `ClearUpcoming()` keeps the current track and drops the up-next. **Bug #4 (reactivity):** `AudioPlayerBar.QueueItems` snapshots `QueueService.Items` into a fresh list per render (the service exposes its backing list by reference), so Blazor parameter diffing detects in-place Clear/remove/reorder; `QueueList.OnParametersSet` refreshes its `MudDropContainer` snapshot so the open panel re-flows immediately. **Bug #1 (label):** the docked `QueueOverlay` panel header reads **"Playlist"** (the current track stays listed). `PlayRelease` materializes `tracks.ToList()` before mutating so it can never alias the service's own `Items` list.
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)``Stream` with optional Range header support for seek-beyond-buffer.
@@ -3,10 +3,11 @@
@using DeepDrftPublic.Client.Services
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the
cascaded IQueueService's Enqueue/EnqueueRange (which append without disturbing current playback and
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select.
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@
cascaded IQueueService's Enqueue/EnqueueRange (which append to the END without disturbing current
playback; a first add into a dormant queue seeds the head from the externally-playing track when one
exists, then appends) — never PlayRelease/PlayTrack/Start/Select. Track mode (Track set) appends a
single track; release mode (ReleaseTracks set) appends the whole ordered list. Reads queue state from
the layout-level cascade (C1); owns no data fetch. *@
<MudTooltip Text="@Tooltip">
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
@@ -45,7 +45,7 @@ else
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3).
Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked
overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started).
overlay, which calls JumpTo (moves the pointer and streams the row, clearing IsArmed).
Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
shrunken height to the host iframe. *@
@@ -85,7 +85,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
private bool ShowFixedPanel => Fixed && HasQueue;
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
// Cached snapshot of the queue list (bug #4 fix). QueueService.Items returns the service's
// backing list by reference, so passing it straight through means Blazor parameter diffing sees
// an unchanged reference after an in-place Clear/remove/reorder and the child (QueueList /
// MudDropContainer) keeps its stale snapshot until reopened. We snapshot on first access and
// rebuild in OnQueueChanged, so every real mutation hands the child a NEW reference while
// progress-tick re-renders (the frequent path) reuse the cached one without allocating.
private IReadOnlyList<TrackDto>? _queueItemsCache;
private IReadOnlyList<TrackDto> QueueItems =>
_queueItemsCache ??= QueueService is null ? [] : QueueService.Items.ToList();
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
@@ -141,6 +149,11 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private void OnQueueChanged()
{
// Invalidate the snapshot so QueueItems rebuilds a fresh list on the next render.
// This gives Blazor a new reference on every real mutation (bug #4 reactivity preserved)
// while progress-tick re-renders that don't go through here keep the cached reference.
_queueItemsCache = null;
// 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.
@@ -189,12 +202,14 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
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.
// Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY,
// not an in-place seek), so a jump cannot route through it without duplicating the queue. JumpTo
// moves the pointer to the chosen row and streams it once — preserving deque order. This is the one
// queue action besides PLAY/skip that touches playback.
private async Task OnQueueJump(int index)
{
if (QueueService == null) return;
await QueueService.PlayRelease(QueueService.Items, index);
await QueueService.JumpTo(index);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -71,6 +71,13 @@
private MudDropContainer<QueueRow>? _dropContainer;
// MudDropContainer snapshots its Items into internal drop zones and does not re-read them on a
// plain re-render — so a Clear/remove/reorder that changes the parent's Items list must be pushed
// into the container explicitly, or the panel shows the stale order until reopened (bug #4). The
// parent passes a fresh Items reference per mutation; refreshing here on every parameter set re-flows
// the container's snapshot to match. Cheap: Refresh only re-reads the bound list.
protected override void OnParametersSet() => _dropContainer?.Refresh();
// 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<QueueRow> Rows =>
@@ -20,7 +20,7 @@
Class="deepdrft-queue-overlay">
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
<div class="deepdrft-queue-modal-header">
<span class="deepdrft-queue-modal-title">Up Next</span>
<span class="deepdrft-queue-modal-title">Playlist</span>
<MudButton Variant="Variant.Text"
Size="Size.Small"
Color="Color.Primary"
@@ -13,6 +13,7 @@ namespace DeepDrftPublic.Client.Controls;
public partial class ReleaseDetailScaffold : ComponentBase
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
[Parameter] public required string Title { get; set; }
[Parameter] public string? Artist { get; set; }
@@ -96,13 +97,19 @@ public partial class ReleaseDetailScaffold : ComponentBase
{
if (Track is null || PlayerService is null) return;
// Toggle if this track is already active (playing or paused); otherwise start a fresh
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
// Toggle if this track is already active (playing or paused); otherwise PLAY it —
// prepend to the queue's front (deque PLAY semantics) so it becomes current and
// the existing queue stays intact behind it. Falls back to a direct stream when
// the queue cascade is absent (prerender / non-interactive).
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(Track);
}
else
{
await PlayerService.SelectTrackStreaming(Track);
@@ -27,6 +27,7 @@
[Parameter] public string LoadingLabel { get; set; } = "Finding a track…";
[Parameter] public EventCallback OnStreamStarted { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
private bool _streamLoading;
@@ -79,7 +80,12 @@
_findingTrack = false;
StateHasChanged();
if (PlayerService is not null)
// PLAY semantics: prepend to the queue's front so a "stream now" track becomes current and
// any existing queue stays intact behind it. Falls back to a direct stream when the queue
// cascade is absent.
if (Queue is not null)
await Queue.PlayTrack(track);
else if (PlayerService is not null)
await PlayerService.SelectTrackStreaming(track);
}
catch (Exception)
+8 -1
View File
@@ -100,10 +100,13 @@ else
protected override string PersistKey => "mix-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
// The hero now carries the play affordance (the scaffold's header is suppressed), so the
// play-toggle is wired here directly — mirroring SessionDetail. Toggle if this track is already
// active, otherwise start a fresh stream.
// active, otherwise PLAY it: prepend to the queue's front (deque PLAY semantics) so it becomes
// current and the existing queue stays intact behind it. Falls back to a direct stream when the
// queue cascade is absent (prerender / non-interactive).
private async Task PlayTrack()
{
var track = ViewModel.Track;
@@ -114,6 +117,10 @@ else
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(track);
}
else
{
await PlayerService.SelectTrackStreaming(track);
@@ -94,10 +94,13 @@ else
protected override string PersistKey => "session-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
// Mirrors the play-toggle wiring the shared scaffold owns. Session detail composes the player
// affordance directly (it diverges from ReleaseDetailScaffold for the overlay layout), so the
// toggle logic lives here: toggle if this track is already active, otherwise start a fresh stream.
// toggle logic lives here: toggle if this track is already active, otherwise PLAY it — prepend to
// the queue's front (deque PLAY semantics) so it becomes current and the existing queue stays
// intact behind it. Falls back to a direct stream when the queue cascade is absent.
private async Task PlayTrack()
{
var track = ViewModel.Track;
@@ -108,6 +111,10 @@ else
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(track);
}
else
{
await PlayerService.SelectTrackStreaming(track);
+75 -28
View File
@@ -10,18 +10,35 @@ namespace DeepDrftPublic.Client.Services;
/// — it adds no new playback semantics.
///
/// <para>
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/>
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> +
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only
/// adding new ones — so consumers written against today's surface keep working.
/// <b>Two-level deque model (the load-bearing invariant).</b> The queue is a deque whose
/// <see cref="Current"/> track (the item at <see cref="CurrentIndex"/>) is the live "front of play".
/// Two families of mutation enter the deque from opposite ends:
/// <list type="bullet">
/// <item><b>PLAY (manual)</b> — <see cref="PlayTrack"/> / <see cref="PlayRelease"/> prepend to the
/// <em>front</em>. The previously-current track is removed, the prepended track(s) become the head
/// in order, the new head becomes current and starts streaming, and whatever sat after the old
/// current stays intact behind the prepend. A whole release prepends in order in one operation.</item>
/// <item><b>Add-to-queue</b> — <see cref="Enqueue"/> / <see cref="EnqueueRange"/> append to the
/// <em>end</em>. They never interrupt the current track and never start playback.</item>
/// </list>
/// </para>
///
/// <para>
/// <b>Advance and end-of-track.</b> <see cref="Next"/> and auto-advance (the player's
/// <see cref="IPlayerService.TrackEnded"/>) walk <see cref="CurrentIndex"/> forward, leaving the just-
/// played track in the list behind the pointer so <see cref="Previous"/> can step back to it. The one
/// exception is the <em>last</em> track: when the current track ends naturally and there is nothing
/// after it, the queue <b>empties</b> and goes dormant (<see cref="CurrentIndex"/> == -1) rather than
/// stranding the finished track as current.
/// </para>
///
/// <para>
/// With an empty queue (<see cref="CurrentIndex"/> == -1) the queue is dormant: it drives nothing and
/// auto-advances nothing, so direct single-track play through the player behaves exactly as it did
/// before the queue existed.
/// before the queue existed. The <b>first</b> <see cref="Enqueue"/>/<see cref="EnqueueRange"/> into a
/// dormant queue while a track is already playing externally seeds the head from the player's current
/// track (learned through the attached player, no extra dependency) and then appends the added item, so
/// the resulting deque is <c>[now-playing, added…]</c> rather than a phantom single entry.
/// </para>
/// </summary>
public interface IQueueService
@@ -41,9 +58,10 @@ public interface IQueueService
/// <summary>
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet —
/// the embed's pre-gesture state. Set by <see cref="Arm"/>; cleared the moment playback actually
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="Next"/>/<see cref="Previous"/>)
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone.
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="PlayTrack"/>/<see cref="Next"/>/
/// <see cref="Previous"/>) or on <see cref="Clear"/>. The player bar reads this to route the first
/// play gesture through <see cref="Start"/> (which begins the armed release) rather than streaming
/// the staged track alone.
/// </summary>
bool IsArmed { get; }
@@ -55,26 +73,40 @@ public interface IQueueService
/// <summary>
/// Raised whenever the queue's contents or current position change. The player bar subscribes
/// to re-render its skip-forward/back affordances. Fires on enqueue, advance, step-back, and clear.
/// to re-render its skip-forward/back affordances. Fires on enqueue, prepend, advance, step-back,
/// and clear.
/// </summary>
event Action? QueueChanged;
/// <summary>
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to
/// the end from there. No-op when <paramref name="tracks"/> is empty.
/// Manual PLAY of a single track: prepends <paramref name="track"/> to the <em>front</em> of the
/// deque, removes the previously-current track, makes <paramref name="track"/> the new head/current,
/// and starts streaming it. The rest of the queue (everything that sat after the old current) stays
/// intact behind the new head. Into a dormant queue this simply becomes the sole head and plays.
/// This is the deque-front counterpart to the append-only <see cref="Enqueue"/>.
/// </summary>
Task PlayTrack(TrackDto track);
/// <summary>
/// Manual PLAY of a release: prepends <paramref name="tracks"/> (in the order given) to the
/// <em>front</em> of the deque, removes the previously-current track, and starts streaming the
/// prepended track at <paramref name="startIndex"/> — which becomes current. Tracks prepended
/// before <paramref name="startIndex"/> sit behind the pointer (reachable via <see cref="Previous"/>);
/// tracks after it are up-next; whatever sat after the old current stays intact behind the whole
/// prepend. This is the "play album" entry point the detail pages consume: a header Play uses
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index. No-op when
/// <paramref name="tracks"/> is empty.
/// </summary>
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
/// <summary>
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT
/// streaming anything — the queue is "armed". This is the embed's prerender-safe entry point: it
/// performs no JS interop, so it runs identically during prerender and after WASM boot. The first
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which
/// keeps the loaded release queued so it advances through its tracks. No-op when
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed).
/// performs no JS interop, so it runs identically during prerender and after WASM boot. It replaces
/// the queue (an armed embed is a fresh staged release, not a prepend). The first play gesture (see
/// <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which keeps the loaded
/// release queued so it advances through its tracks. No-op when <paramref name="tracks"/> is empty
/// (the queue stays empty and disarmed).
/// </summary>
void Arm(IEnumerable<TrackDto> tracks);
@@ -88,18 +120,24 @@ public interface IQueueService
Task Start();
/// <summary>
/// Appends a track to the end of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
/// <see cref="CurrentIndex"/> (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.
/// Appends a track to the <em>end</em> of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) while a track is already playing
/// externally (through the attached player but not via the queue), the append first seeds the head
/// with that now-playing track, then appends <paramref name="track"/> — yielding
/// <c>[now-playing, track]</c> so the queue reflects what the listener actually hears. Into a fully
/// dormant queue with nothing playing, the single appended track becomes the head at
/// <see cref="CurrentIndex"/> == 0. Either way it does NOT begin playback (add is not play).
/// Interop-free; safe during prerender.
/// </summary>
void Enqueue(TrackDto track);
/// <summary>
/// Appends tracks to the end of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
/// <see cref="CurrentIndex"/> (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.
/// Appends tracks to the <em>end</em> of the queue without changing what is currently playing.
/// Into a dormant queue while a track is already playing externally, the append first seeds the head
/// with that now-playing track (see <see cref="Enqueue"/>), then appends the range. Into a fully
/// dormant queue with nothing playing, the first appended track becomes the head at
/// <see cref="CurrentIndex"/> == 0. It does NOT begin playback (add is not play). Interop-free; safe
/// during prerender.
/// </summary>
void EnqueueRange(IEnumerable<TrackDto> tracks);
@@ -136,6 +174,15 @@ public interface IQueueService
/// </summary>
Task Previous();
/// <summary>
/// Moves the current pointer to <paramref name="index"/> and streams that track once. This is the
/// row-jump primitive the open playlist panel uses: unlike <see cref="PlayRelease"/> it does not
/// prepend (the track is already in the deque), and unlike repeated <see cref="Next"/> it does not
/// stream the intervening rows. No-op when <paramref name="index"/> is out of range or already
/// current.
/// </summary>
Task JumpTo(int index);
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
void Clear();
+100 -31
View File
@@ -3,10 +3,12 @@ using DeepDrftModels.DTOs;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Default <see cref="IQueueService"/>: a single-slot orchestrator over an
/// Default <see cref="IQueueService"/>: a two-level deque orchestrator over an
/// <see cref="IStreamingPlayerService"/>. Holds the ordered list and current index as pure state,
/// drives playback through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>,
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal. PLAY mutations enter
/// the front (prepend); add-to-queue mutations enter the back (append) — see <see cref="IQueueService"/>
/// for the full invariant.
///
/// <para>
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
@@ -14,7 +16,8 @@ namespace DeepDrftPublic.Client.Services;
/// creates the player) rather than constructor injection. This keeps the player single-slot, avoids a
/// construction cycle between provider/player/queue, and needs no <c>IServiceProvider</c>. The queue's
/// own constructor stays parameterless, so the queue logic is unit-testable against a fake player with
/// no container.
/// no container. The attached player is also the seam by which the queue learns the externally-playing
/// track when a dormant <see cref="Enqueue"/> needs to seed the head.
/// </para>
/// </summary>
public sealed class QueueService : IQueueService, IDisposable
@@ -54,23 +57,42 @@ public sealed class QueueService : IQueueService, IDisposable
_player.TrackEnded += OnTrackEnded;
}
public async Task PlayTrack(TrackDto track)
{
PrependForPlay(new[] { track }, prependIndex: 0);
QueueChanged?.Invoke();
await PlayCurrent();
}
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
{
var list = tracks.ToList();
if (list.Count == 0) return;
var start = Math.Clamp(startIndex, 0, list.Count - 1);
_items.Clear();
_items.AddRange(list);
CurrentIndex = start;
// Playback is now starting for real, so the queue is no longer merely armed.
IsArmed = false;
PrependForPlay(list, start);
QueueChanged?.Invoke();
await PlayCurrent();
}
// The shared PLAY-prepend mutation (bug #5). Removes the previously-current track, inserts the
// played track(s) at the front in order, and points CurrentIndex at the prepended item the caller
// chose to start on. Whatever sat AFTER the old current stays intact behind the prepend; the old
// back-history (items before the old current) is discarded because a fresh PLAY defines a new
// front. Pure state — callers invoke QueueChanged + PlayCurrent. IsArmed clears: playback is real now.
private void PrependForPlay(IReadOnlyList<TrackDto> played, int prependIndex)
{
// Drop the previously-current track only (its tail — the up-next after it — is preserved).
// Anything before the old current is back-history that a new PLAY supersedes.
if (CurrentIndex >= 0 && CurrentIndex < _items.Count)
_items.RemoveRange(0, CurrentIndex + 1);
_items.InsertRange(0, played);
CurrentIndex = prependIndex;
IsArmed = false;
}
public void Arm(IEnumerable<TrackDto> tracks)
{
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
@@ -94,27 +116,47 @@ public sealed class QueueService : IQueueService, IDisposable
public void Enqueue(TrackDto track)
{
SeedHeadFromPlayerIfDormant();
_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;
EnsureCoherentDormantIndex();
QueueChanged?.Invoke();
}
public void EnqueueRange(IEnumerable<TrackDto> tracks)
{
var before = _items.Count;
_items.AddRange(tracks);
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;
var toAdd = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
if (toAdd.Count == 0) return;
SeedHeadFromPlayerIfDormant();
_items.AddRange(toAdd);
EnsureCoherentDormantIndex();
QueueChanged?.Invoke();
}
// Bug #3: the first add into a dormant queue while a track is already playing externally (through
// the attached player but not via the queue) must seed the head with that now-playing track, so the
// append yields [now-playing, added] instead of a phantom single entry. We read the player's
// CurrentTrack — the same seam OnTrackEnded uses — so no extra dependency is introduced. Only seeds
// when truly dormant (empty list) AND a player track exists; a non-dormant queue is untouched.
private void SeedHeadFromPlayerIfDormant()
{
if (_items.Count != 0) return;
var playing = _player?.CurrentTrack;
if (playing is null) return;
_items.Add(playing);
CurrentIndex = 0;
}
// After an append, a dormant queue (CurrentIndex == -1, e.g. nothing was playing to seed from)
// needs a coherent head so a subsequent play/skip is correct — but add is not play, so we never
// stream here. A queue that already has a current index is left untouched.
private void EnsureCoherentDormantIndex()
{
if (CurrentIndex == -1 && _items.Count > 0)
CurrentIndex = 0;
}
public void Move(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return;
@@ -192,6 +234,16 @@ public sealed class QueueService : IQueueService, IDisposable
await PlayCurrent();
}
public async Task JumpTo(int index)
{
if (index < 0 || index >= _items.Count) return;
if (index == CurrentIndex) return;
CurrentIndex = index;
IsArmed = false;
QueueChanged?.Invoke();
await PlayCurrent();
}
public void Clear()
{
if (_items.Count == 0 && CurrentIndex == -1) return;
@@ -217,23 +269,40 @@ public sealed class QueueService : IQueueService, IDisposable
// 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.
// queue.
//
// Guard: only advance when the track that just ended is the queue's own current item. Call sites
// that stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
// Guard: only act when the track that just ended is the queue's own current item. Call sites that
// stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
// overwrite the player's CurrentTrack without touching the queue. If their track reaches natural
// end, the player fires TrackEnded — but the queue's Current no longer matches the player's
// CurrentTrack, so we must not advance. Id-based equality is used rather than ReferenceEquals
// CurrentTrack, so we must not touch the queue. Id-based equality is used rather than ReferenceEquals
// because DTO copies through serialisation are not reference-equal.
//
// When the ended track IS the queue's current: advance if there is a next track, otherwise the queue
// has reached its end — empty it (bug #2), so the finished last track is not stranded as current and
// the queue goes dormant (panel/button gone per HasQueue gating).
private void OnTrackEnded()
{
if (!HasNext) return;
if (_player?.CurrentTrack?.Id != Current?.Id) return;
// Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's
// end-of-playback callback continuation; we must not block it. Advancing kicks off the next
// stream, whose own failures surface through the player's ErrorMessage/state — the queue does
// not own playback error handling.
_ = Next();
if (Current is null) return;
if (HasNext)
{
// Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's
// end-of-playback callback continuation; we must not block it. Advancing kicks off the next
// stream, whose own failures surface through the player's ErrorMessage/state — the queue does
// not own playback error handling.
_ = Next();
}
else
{
// Last track ended naturally → empty the deque. The player is left alone (its stream has
// already ended on its own); we only reset queue state.
_items.Clear();
CurrentIndex = -1;
IsArmed = false;
QueueChanged?.Invoke();
}
}
private async Task PlayCurrent()
+285 -27
View File
@@ -5,11 +5,12 @@ using Microsoft.AspNetCore.Components;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the play-queue orchestrator (<see cref="QueueService"/>). The queue is pure
/// domain logic over the single-slot player, so it is exercised here against a recording fake
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage:
/// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and
/// auto-advance on the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// Unit tests for the two-level deque play-queue orchestrator (<see cref="QueueService"/>). The queue
/// is pure domain logic over the single-slot player, so it is exercised here against a recording fake
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage: PLAY-
/// prepend (single + release), add-to-queue append, dormant-seed-from-player, ordered advance,
/// next/previous bounds, jump, clear, current-index integrity, and auto-advance / last-track-empty on
/// the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// </summary>
[TestFixture]
public class QueueServiceTests
@@ -125,8 +126,12 @@ public class QueueServiceTests
}
[Test]
public async Task PlayRelease_ReplacesAnExistingQueue()
public async Task PlayRelease_PrependsToFront_RemovesPreviousCurrent_KeepsRemainderIntact()
{
// Deque PLAY (bug #5): PlayRelease into a non-empty queue prepends the release at the front,
// removes the previously-current track, and leaves the up-next that sat after it intact behind
// the prepend. Current was track-1 (index 0) → after prepend, the old current is dropped and
// its tail (track-2, track-3) stays behind [x-1, x-2].
await _queue.PlayRelease(Tracks(3));
var second = new List<TrackDto>
{
@@ -138,39 +143,133 @@ public class QueueServiceTests
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Has.Count.EqualTo(2));
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" }));
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "x-1", "x-2", "track-2", "track-3" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("x-1"));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("x-1"));
});
}
[Test]
public async Task PlayRelease_ViaLiveQueueItems_PreservesTracksAndJumpsToIndex()
public async Task PlayRelease_FromMidQueueCurrent_DropsOnlyTheCurrentTrack_NotItsTail()
{
// Regression guard for the aliasing bug: OnQueueJump calls PlayRelease(QueueService.Items, index).
// Items returns the backing list directly; without a defensive copy, the cast
// "tracks as IReadOnlyList<TrackDto>" aliases _items, so _items.Clear() also clears list,
// and _items.AddRange(list) adds nothing — wiping the queue and playing nothing.
await _queue.PlayRelease(Tracks(4)); // populate the live queue
// Current advanced to track-2 (index 1) with track-3, track-4 after it. PLAY of a new release
// drops only track-2 (the current) and keeps track-3, track-4 behind the prepend. The old
// back-history (track-1, before the current) is discarded — a fresh PLAY defines a new front.
await _queue.PlayRelease(Tracks(4));
await _queue.Next(); // current = track-2 at index 1
// Jump to index 2 via the live Items reference, exactly as OnQueueJump does.
await _queue.PlayRelease(new List<TrackDto> { new() { EntryKey = "p-1", TrackName = "P1" } });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "p-1", "track-3", "track-4" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("p-1"));
});
}
[Test]
public async Task PlayRelease_WithStartIndex_PrependsWholeReleaseInOrder_CurrentAtStartIndex()
{
// A mid-album row play prepends the whole release in order; the chosen startIndex becomes
// current. Tracks before it sit behind the pointer (Previous reaches them); tracks after are
// up-next. The previous current is dropped.
await _queue.PlayRelease(Tracks(2)); // existing queue: [track-1*, track-2]
var release = new List<TrackDto>
{
new() { EntryKey = "r-1", TrackName = "R1" },
new() { EntryKey = "r-2", TrackName = "R2" },
new() { EntryKey = "r-3", TrackName = "R3" },
};
await _queue.PlayRelease(release, startIndex: 1);
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "r-1", "r-2", "r-3", "track-2" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("r-2"));
Assert.That(_queue.HasPrevious, Is.True); // r-1 is behind the pointer
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("r-2"));
});
}
[Test]
public async Task PlayRelease_ViaLiveQueueItems_DoesNotCorruptListUnderPrepend()
{
// Aliasing guard retained under the deque model: a caller that passes the live Items reference
// into PlayRelease must not corrupt the list. PlayRelease materializes tracks.ToList() before
// the RemoveRange/InsertRange prepend, so the defensive copy survives the mutation.
await _queue.PlayRelease(Tracks(4));
// Pass the live Items reference (current = track-1). Prepend drops the current and re-inserts
// the copy at the front, with the tail (track-2..4) preserved behind it.
await _queue.PlayRelease(_queue.Items, 2);
Assert.Multiple(() =>
{
// The queue must survive — all four tracks still present, in order.
Assert.That(_queue.Items, Has.Count.EqualTo(4));
// The defensive copy is intact: all four original tracks were re-prepended in order, and the
// old current's tail follows. CurrentIndex is the chosen start.
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
// CurrentIndex must be the jumped-to slot.
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4", "track-2", "track-3", "track-4" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
// Current must be the right track.
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
// The player must have streamed the jumped-to track.
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
});
}
// --- PlayTrack: deque PLAY of a single track (prepend to front) — bug #5 ---
[Test]
public async Task PlayTrack_IntoDormantQueue_BecomesSoleHeadAndStreams()
{
await _queue.PlayTrack(new TrackDto { EntryKey = "solo", TrackName = "Solo" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "solo" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("solo"));
});
}
[Test]
public async Task PlayTrack_FromNonEmptyQueue_PrependsDropsPreviousCurrent_KeepsRemainder()
{
// bug #5: PLAY of a single track from a non-empty queue prepends it as the new head, drops the
// previously-current track, and leaves the remainder intact behind the new head.
await _queue.PlayRelease(Tracks(3)); // [track-1*, track-2, track-3]
await _queue.PlayTrack(new TrackDto { EntryKey = "jump-in", TrackName = "Jump In" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "jump-in", "track-2", "track-3" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("jump-in"));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("jump-in"));
});
}
[Test]
public async Task PlayTrack_DisarmsAnArmedQueue()
{
_queue.Arm(Tracks(3));
await _queue.PlayTrack(new TrackDto { EntryKey = "override", TrackName = "Override" });
Assert.Multiple(() =>
{
Assert.That(_queue.IsArmed, Is.False);
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("override"));
});
}
// --- Arm: prerender-safe load without streaming (release embed) ---
[Test]
@@ -671,6 +770,81 @@ public class QueueServiceTests
});
}
[Test]
public void Enqueue_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
{
// Bug #3: a single track is playing NOT through the queue (the player's CurrentTrack is set, the
// queue is dormant). The first Add-to-queue must seed the head with that now-playing track and
// then append the added one → [now-playing, added], even if they are the same track.
var nowPlaying = new TrackDto { Id = 7, EntryKey = "now-playing", TrackName = "Now Playing" };
_player.SimulateDirectPlay(nowPlaying);
_queue.Enqueue(new TrackDto { EntryKey = "added", TrackName = "Added" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "now-playing", "added" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "the now-playing track is the head/current");
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("now-playing"));
Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed");
});
// A second add appends a third item — no ghost/duplicate seeding.
_queue.Enqueue(new TrackDto { EntryKey = "added-2", TrackName = "Added 2" });
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "now-playing", "added", "added-2" }));
}
[Test]
public void Enqueue_OfTheSameExternallyPlayingTrack_SeedsHeadThenAppendsTheDuplicate()
{
// Bug #3 exact repro: add the very track that is playing externally. Result must be a 2-item
// queue [now-playing(current), same-track-appended] — not a single ghost entry.
var nowPlaying = new TrackDto { Id = 7, EntryKey = "the-track", TrackName = "The Track" };
_player.SimulateDirectPlay(nowPlaying);
_queue.Enqueue(nowPlaying);
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Has.Count.EqualTo(2));
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "the-track", "the-track" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
});
}
[Test]
public void EnqueueRange_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
{
var nowPlaying = new TrackDto { Id = 9, EntryKey = "live", TrackName = "Live" };
_player.SimulateDirectPlay(nowPlaying);
_queue.EnqueueRange(Tracks(2));
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "live", "track-1", "track-2" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_player.SelectedTracks, Is.Empty);
});
}
[Test]
public void Enqueue_IntoDormantQueue_WithNothingPlaying_DoesNotSeedAPhantomHead()
{
// No external track playing → nothing to seed. The single added track is the head (OQ8 coherent
// index), and there is no phantom duplicate.
_queue.Enqueue(new TrackDto { EntryKey = "only", TrackName = "Only" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "only" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
});
}
[Test]
public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex()
{
@@ -686,6 +860,67 @@ public class QueueServiceTests
});
}
// --- JumpTo: row-jump within the deque (move pointer + stream once) ---
[Test]
public async Task JumpTo_MovesPointerForwardAndStreamsTheTargetOnce()
{
await _queue.PlayRelease(Tracks(4)); // current = track-1
var streamedBefore = _player.SelectedTracks.Count;
await _queue.JumpTo(2);
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
// Exactly one new stream — the intervening track-2 must NOT have been streamed.
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore + 1));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
});
}
[Test]
public async Task JumpTo_MovesPointerBackwardAndStreamsTheTarget()
{
await _queue.PlayRelease(Tracks(4), startIndex: 3); // current = track-4
await _queue.JumpTo(1);
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2"));
});
}
[Test]
public async Task JumpTo_DoesNotDuplicateTheQueue()
{
// Regression guard: JumpTo must NOT prepend (it is not a PLAY) — the deque length is unchanged.
await _queue.PlayRelease(Tracks(4));
await _queue.JumpTo(2);
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
}
[Test]
public async Task JumpTo_SameIndexOrOutOfRange_IsNoOp()
{
await _queue.PlayRelease(Tracks(3)); // current = track-1
var streamedBefore = _player.SelectedTracks.Count;
await _queue.JumpTo(0); // already current
await _queue.JumpTo(-1);
await _queue.JumpTo(3);
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
"no-op jumps must not re-stream");
}
// --- Clear ---
[Test]
@@ -820,16 +1055,38 @@ public class QueueServiceTests
}
[Test]
public async Task TrackEnded_OnLastTrack_DoesNotAdvanceOrReplay()
public async Task TrackEnded_OnLastTrack_EmptiesTheQueueAndGoesDormant()
{
// Bug #2: when the current track ends naturally and there is nothing after it, the queue empties
// (CurrentIndex == -1, dormant) rather than stranding the finished track as current. No replay.
await _queue.PlayRelease(Tracks(2), startIndex: 1);
var raised = false;
_queue.QueueChanged += () => raised = true;
_player.RaiseTrackEnded();
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
Assert.That(_queue.Current, Is.Null);
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "no replay on end");
Assert.That(raised, Is.True, "emptying the queue raises QueueChanged");
});
}
[Test]
public async Task TrackEnded_OnSingleTrackQueue_EmptiesTheQueue()
{
// Bug #2, single-track variant: a one-item queue playing to its end empties (dormant).
await _queue.PlayRelease(Tracks(1));
_player.RaiseTrackEnded();
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
});
}
@@ -877,17 +1134,18 @@ public class QueueServiceTests
}
[Test]
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd()
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd_ThenEmptiesOnLastEnd()
{
await _queue.PlayRelease(Tracks(3));
_player.RaiseTrackEnded(); // → track-2
_player.RaiseTrackEnded(); // → track-3
_player.RaiseTrackEnded(); // last track: no advance
_player.RaiseTrackEnded(); // last track ends → queue empties (bug #2)
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
});