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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user