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