feat(queue): two-level deque model — PLAY prepends, add appends, last-track-end empties

Fixes five queue bugs: Playlist relabel, last-track-empties, dormant-seed-from-player on first add, immediate panel reactivity, and front/back deque semantics. Adds JumpTo for row jumps.
This commit is contained in:
daniel-c-harvey
2026-06-20 15:26:37 -04:00
parent 5058c72375
commit 214f708e65
12 changed files with 510 additions and 100 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. - `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`). - `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). - `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). - `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). - `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. - `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 @using DeepDrftPublic.Client.Services
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the @* 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 cascaded IQueueService's Enqueue/EnqueueRange (which append to the END without disturbing current
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select. playback; a first add into a dormant queue seeds the head from the externally-playing track when one
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole exists, then appends) — never PlayRelease/PlayTrack/Start/Select. Track mode (Track set) appends a
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@ 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"> <MudTooltip Text="@Tooltip">
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd" <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 @* 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). 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 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 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 Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
shrunken height to the host iframe. *@ shrunken height to the host iframe. *@
@@ -85,7 +85,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6). // Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
private bool ShowFixedPanel => Fixed && HasQueue; private bool ShowFixedPanel => Fixed && HasQueue;
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? []; // Snapshot the live queue into a NEW list every render. 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 (bug #4). Materializing a fresh list per
// access forces the parameter to change identity on every mutation, so the panel re-flows immediately.
private IReadOnlyList<TrackDto> QueueItems =>
QueueService is null ? [] : QueueService.Items.ToList();
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1; private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the // Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
@@ -189,12 +195,14 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private void ClearUpcoming() => QueueService?.ClearUpcoming(); private void ClearUpcoming() => QueueService?.ClearUpcoming();
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that // Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY,
// touches playback — it streams the chosen track via the player. // 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) private async Task OnQueueJump(int index)
{ {
if (QueueService == null) return; if (QueueService == null) return;
await QueueService.PlayRelease(QueueService.Items, index); await QueueService.JumpTo(index);
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -71,6 +71,13 @@
private MudDropContainer<QueueRow>? _dropContainer; 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 // 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. // value surfaced to the parent's callbacks — the component never mutates the underlying list.
private List<QueueRow> Rows => private List<QueueRow> Rows =>
@@ -20,7 +20,7 @@
Class="deepdrft-queue-overlay"> Class="deepdrft-queue-overlay">
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true"> <div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
<div class="deepdrft-queue-modal-header"> <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" <MudButton Variant="Variant.Text"
Size="Size.Small" Size="Size.Small"
Color="Color.Primary" Color="Color.Primary"
@@ -27,6 +27,7 @@
[Parameter] public string LoadingLabel { get; set; } = "Finding a track…"; [Parameter] public string LoadingLabel { get; set; } = "Finding a track…";
[Parameter] public EventCallback OnStreamStarted { get; set; } [Parameter] public EventCallback OnStreamStarted { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; } [Inject] public required ITrackDataService TrackData { get; set; }
private bool _streamLoading; private bool _streamLoading;
@@ -79,7 +80,12 @@
_findingTrack = false; _findingTrack = false;
StateHasChanged(); 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); await PlayerService.SelectTrackStreaming(track);
} }
catch (Exception) catch (Exception)
+8 -1
View File
@@ -100,10 +100,13 @@ else
protected override string PersistKey => "mix-detail"; protected override string PersistKey => "mix-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [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 // 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 // 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() private async Task PlayTrack()
{ {
var track = ViewModel.Track; var track = ViewModel.Track;
@@ -114,6 +117,10 @@ else
{ {
await PlayerService.TogglePlayPause(); await PlayerService.TogglePlayPause();
} }
else if (Queue is not null)
{
await Queue.PlayTrack(track);
}
else else
{ {
await PlayerService.SelectTrackStreaming(track); await PlayerService.SelectTrackStreaming(track);
@@ -94,10 +94,13 @@ else
protected override string PersistKey => "session-detail"; protected override string PersistKey => "session-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [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 // 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 // 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() private async Task PlayTrack()
{ {
var track = ViewModel.Track; var track = ViewModel.Track;
@@ -108,6 +111,10 @@ else
{ {
await PlayerService.TogglePlayPause(); await PlayerService.TogglePlayPause();
} }
else if (Queue is not null)
{
await Queue.PlayTrack(track);
}
else else
{ {
await PlayerService.SelectTrackStreaming(track); await PlayerService.SelectTrackStreaming(track);
+75 -28
View File
@@ -10,18 +10,35 @@ namespace DeepDrftPublic.Client.Services;
/// — it adds no new playback semantics. /// — it adds no new playback semantics.
/// ///
/// <para> /// <para>
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are /// <b>Two-level deque model (the load-bearing invariant).</b> The queue is a deque whose
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/ /// <see cref="Current"/> track (the item at <see cref="CurrentIndex"/>) is the live "front of play".
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/> /// Two families of mutation enter the deque from opposite ends:
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> + /// <list type="bullet">
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only /// <item><b>PLAY (manual)</b> — <see cref="PlayTrack"/> / <see cref="PlayRelease"/> prepend to the
/// adding new ones — so consumers written against today's surface keep working. /// <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>
/// ///
/// <para> /// <para>
/// With an empty queue (<see cref="CurrentIndex"/> == -1) the queue is dormant: it drives nothing and /// 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 /// 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> /// </para>
/// </summary> /// </summary>
public interface IQueueService public interface IQueueService
@@ -41,9 +58,10 @@ public interface IQueueService
/// <summary> /// <summary>
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet — /// 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 /// 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"/>) /// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="PlayTrack"/>/<see cref="Next"/>/
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through /// <see cref="Previous"/>) or on <see cref="Clear"/>. The player bar reads this to route the first
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone. /// play gesture through <see cref="Start"/> (which begins the armed release) rather than streaming
/// the staged track alone.
/// </summary> /// </summary>
bool IsArmed { get; } bool IsArmed { get; }
@@ -55,26 +73,40 @@ public interface IQueueService
/// <summary> /// <summary>
/// Raised whenever the queue's contents or current position change. The player bar subscribes /// 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> /// </summary>
event Action? QueueChanged; event Action? QueueChanged;
/// <summary> /// <summary>
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming /// Manual PLAY of a single track: prepends <paramref name="track"/> to the <em>front</em> of the
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts /// deque, removes the previously-current track, makes <paramref name="track"/> the new head/current,
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses /// and starts streaming it. The rest of the queue (everything that sat after the old current) stays
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to /// intact behind the new head. Into a dormant queue this simply becomes the sole head and plays.
/// the end from there. No-op when <paramref name="tracks"/> is empty. /// 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> /// </summary>
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0); Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
/// <summary> /// <summary>
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT /// 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 /// 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 /// performs no JS interop, so it runs identically during prerender and after WASM boot. It replaces
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which /// the queue (an armed embed is a fresh staged release, not a prepend). The first play gesture (see
/// keeps the loaded release queued so it advances through its tracks. No-op when /// <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which keeps the loaded
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed). /// release queued so it advances through its tracks. No-op when <paramref name="tracks"/> is empty
/// (the queue stays empty and disarmed).
/// </summary> /// </summary>
void Arm(IEnumerable<TrackDto> tracks); void Arm(IEnumerable<TrackDto> tracks);
@@ -88,18 +120,24 @@ public interface IQueueService
Task Start(); Task Start();
/// <summary> /// <summary>
/// Appends a track to the end of the queue without changing what is currently playing. /// 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) the append leaves a coherent /// Into a dormant queue (<see cref="CurrentIndex"/> == -1) while a track is already playing
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct — /// externally (through the attached player but not via the queue), the append first seeds the head
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender. /// 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> /// </summary>
void Enqueue(TrackDto track); void Enqueue(TrackDto track);
/// <summary> /// <summary>
/// Appends tracks to the end of the queue without changing what is currently playing. /// Appends tracks to the <em>end</em> of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent /// Into a dormant queue while a track is already playing externally, the append first seeds the head
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct — /// with that now-playing track (see <see cref="Enqueue"/>), then appends the range. Into a fully
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender. /// 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> /// </summary>
void EnqueueRange(IEnumerable<TrackDto> tracks); void EnqueueRange(IEnumerable<TrackDto> tracks);
@@ -136,6 +174,15 @@ public interface IQueueService
/// </summary> /// </summary>
Task Previous(); 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> /// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
void Clear(); void Clear();
+95 -26
View File
@@ -3,10 +3,12 @@ using DeepDrftModels.DTOs;
namespace DeepDrftPublic.Client.Services; namespace DeepDrftPublic.Client.Services;
/// <summary> /// <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, /// <see cref="IStreamingPlayerService"/>. Holds the ordered list and current index as pure state,
/// drives playback through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>, /// 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> /// <para>
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it. /// 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 /// 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 /// 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 /// 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> /// </para>
/// </summary> /// </summary>
public sealed class QueueService : IQueueService, IDisposable public sealed class QueueService : IQueueService, IDisposable
@@ -54,23 +57,42 @@ public sealed class QueueService : IQueueService, IDisposable
_player.TrackEnded += OnTrackEnded; _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) public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
{ {
var list = tracks.ToList(); var list = tracks.ToList();
if (list.Count == 0) return; if (list.Count == 0) return;
var start = Math.Clamp(startIndex, 0, list.Count - 1); var start = Math.Clamp(startIndex, 0, list.Count - 1);
PrependForPlay(list, start);
_items.Clear();
_items.AddRange(list);
CurrentIndex = start;
// Playback is now starting for real, so the queue is no longer merely armed.
IsArmed = false;
QueueChanged?.Invoke(); QueueChanged?.Invoke();
await PlayCurrent(); 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) public void Arm(IEnumerable<TrackDto> tracks)
{ {
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList(); var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
@@ -94,27 +116,47 @@ public sealed class QueueService : IQueueService, IDisposable
public void Enqueue(TrackDto track) public void Enqueue(TrackDto track)
{ {
SeedHeadFromPlayerIfDormant();
_items.Add(track); _items.Add(track);
// OQ8: appending into a dormant (empty) queue leaves a coherent CurrentIndex so the next EnsureCoherentDormantIndex();
// 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(); QueueChanged?.Invoke();
} }
public void EnqueueRange(IEnumerable<TrackDto> tracks) public void EnqueueRange(IEnumerable<TrackDto> tracks)
{ {
var before = _items.Count; var toAdd = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
_items.AddRange(tracks); if (toAdd.Count == 0) return;
if (_items.Count == before) return;
// OQ8: see Enqueue — first append into a dormant queue stages a coherent CurrentIndex SeedHeadFromPlayerIfDormant();
// without playing. The first newly-appended track becomes current. _items.AddRange(toAdd);
if (CurrentIndex == -1) EnsureCoherentDormantIndex();
CurrentIndex = 0;
QueueChanged?.Invoke(); 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) public void Move(int fromIndex, int toIndex)
{ {
if (fromIndex == toIndex) return; if (fromIndex == toIndex) return;
@@ -192,6 +234,16 @@ public sealed class QueueService : IQueueService, IDisposable
await PlayCurrent(); 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() public void Clear()
{ {
if (_items.Count == 0 && CurrentIndex == -1) return; if (_items.Count == 0 && CurrentIndex == -1) return;
@@ -217,24 +269,41 @@ public sealed class QueueService : IQueueService, IDisposable
// Advance on organic end-of-stream only. TrackEnded is not raised by stop/unload/track-switch, // 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 // 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 // Guard: only act when the track that just ended is the queue's own current item. Call sites that
// that stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar) // stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
// overwrite the player's CurrentTrack without touching the queue. If their track reaches natural // 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 // 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. // 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() private void OnTrackEnded()
{ {
if (!HasNext) return;
if (_player?.CurrentTrack?.Id != Current?.Id) return; if (_player?.CurrentTrack?.Id != Current?.Id) return;
if (Current is null) return;
if (HasNext)
{
// Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's // 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 // 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 // stream, whose own failures surface through the player's ErrorMessage/state — the queue does
// not own playback error handling. // not own playback error handling.
_ = Next(); _ = 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() private async Task PlayCurrent()
{ {
+285 -27
View File
@@ -5,11 +5,12 @@ using Microsoft.AspNetCore.Components;
namespace DeepDrftTests; namespace DeepDrftTests;
/// <summary> /// <summary>
/// Unit tests for the play-queue orchestrator (<see cref="QueueService"/>). The queue is pure /// Unit tests for the two-level deque play-queue orchestrator (<see cref="QueueService"/>). The queue
/// domain logic over the single-slot player, so it is exercised here against a recording fake /// 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: /// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage: PLAY-
/// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and /// prepend (single + release), add-to-queue append, dormant-seed-from-player, ordered advance,
/// auto-advance on the player's <see cref="IPlayerService.TrackEnded"/> signal. /// next/previous bounds, jump, clear, current-index integrity, and auto-advance / last-track-empty on
/// the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// </summary> /// </summary>
[TestFixture] [TestFixture]
public class QueueServiceTests public class QueueServiceTests
@@ -125,8 +126,12 @@ public class QueueServiceTests
} }
[Test] [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)); await _queue.PlayRelease(Tracks(3));
var second = new List<TrackDto> var second = new List<TrackDto>
{ {
@@ -138,39 +143,133 @@ public class QueueServiceTests
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(_queue.Items, Has.Count.EqualTo(2)); Assert.That(_queue.Items.Select(t => t.EntryKey),
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" })); Is.EqualTo(new[] { "x-1", "x-2", "track-2", "track-3" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); 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] [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). // Current advanced to track-2 (index 1) with track-3, track-4 after it. PLAY of a new release
// Items returns the backing list directly; without a defensive copy, the cast // drops only track-2 (the current) and keeps track-3, track-4 behind the prepend. The old
// "tracks as IReadOnlyList<TrackDto>" aliases _items, so _items.Clear() also clears list, // back-history (track-1, before the current) is discarded — a fresh PLAY defines a new front.
// and _items.AddRange(list) adds nothing — wiping the queue and playing nothing. await _queue.PlayRelease(Tracks(4));
await _queue.PlayRelease(Tracks(4)); // populate the live queue 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); await _queue.PlayRelease(_queue.Items, 2);
Assert.Multiple(() => Assert.Multiple(() =>
{ {
// The queue must survive — all four tracks still present, in order. // The defensive copy is intact: all four original tracks were re-prepended in order, and the
Assert.That(_queue.Items, Has.Count.EqualTo(4)); // old current's tail follows. CurrentIndex is the chosen start.
Assert.That(_queue.Items.Select(t => t.EntryKey), Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" })); Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4", "track-2", "track-3", "track-4" }));
// CurrentIndex must be the jumped-to slot.
Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
// Current must be the right track.
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3")); 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")); 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) --- // --- Arm: prerender-safe load without streaming (release embed) ---
[Test] [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] [Test]
public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex() 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 --- // --- Clear ---
[Test] [Test]
@@ -820,16 +1055,38 @@ public class QueueServiceTests
} }
[Test] [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); await _queue.PlayRelease(Tracks(2), startIndex: 1);
var raised = false;
_queue.QueueChanged += () => raised = true;
_player.RaiseTrackEnded(); _player.RaiseTrackEnded();
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); Assert.That(_queue.Items, Is.Empty);
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1)); 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] [Test]
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd() public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd_ThenEmptiesOnLastEnd()
{ {
await _queue.PlayRelease(Tracks(3)); await _queue.PlayRelease(Tracks(3));
_player.RaiseTrackEnded(); // → track-2 _player.RaiseTrackEnded(); // → track-2
_player.RaiseTrackEnded(); // → track-3 _player.RaiseTrackEnded(); // → track-3
_player.RaiseTrackEnded(); // last track: no advance _player.RaiseTrackEnded(); // last track ends → queue empties (bug #2)
Assert.Multiple(() => 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), Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
}); });