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
+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()