feat(player): add IQueueService orchestrating album playback above the single-slot player (P11 11.F)

Queue owns ordered tracks, current index, skip-fwd/back, and auto-advance via the player's TrackEnded hook; binds through Attach (no ctor growth, no service-locator). Player-bar skip controls; empty-queue play unchanged. Adds QueueService unit tests.
This commit is contained in:
daniel-c-harvey
2026-06-16 00:04:44 -04:00
parent 56e205082d
commit 2b42e01cd0
14 changed files with 771 additions and 2 deletions
@@ -43,6 +43,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
/// <inheritdoc />
public event Action? StateChanged;
/// <inheritdoc />
public event Action? TrackEnded;
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
{
_audioInterop = audioInterop;
@@ -268,6 +271,12 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
CurrentTime = 0;
Duration = null;
await NotifyStateChanged();
// Fire AFTER the state notification so any queue orchestrator that advances on this
// signal selects the next track against a fully-settled idle state. Raised only on
// organic end-of-stream — stop/unload/track-switch go through ResetToIdle, which does
// not raise this — so a subscriber can treat it unambiguously as "advance the queue."
TrackEnded?.Invoke();
}
@@ -40,6 +40,15 @@ public interface IPlayerService
/// <see cref="OnStateChanged"/> (throttled to ~10/s during streaming).
/// </summary>
event Action? StateChanged;
/// <summary>
/// Raised once when the current track reaches its natural end of playback (the JS
/// end-of-stream callback), distinct from a stop/unload/track-switch. This is the single
/// hook the play-queue subscribes to in order to auto-advance to the next track. It does
/// NOT fire when playback is stopped, the track is switched, or the player is unloaded —
/// only on organic completion — so an orchestrator can treat it as "advance the queue."
/// </summary>
event Action? TrackEnded;
// Control methods
Task InitializeAsync();
@@ -0,0 +1,80 @@
using DeepDrftModels.DTOs;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Orchestrates ordered playback ("what plays next") <em>above</em> the single-slot
/// <see cref="IStreamingPlayerService"/>. The player stays a single-track device; the queue owns the
/// track list, the current position, skip-forward/back, and auto-advance on natural track end. It
/// drives playback solely through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>
/// — 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.
/// </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.
/// </para>
/// </summary>
public interface IQueueService
{
/// <summary>The ordered tracks currently queued. Empty when nothing is enqueued.</summary>
IReadOnlyList<TrackDto> Items { get; }
/// <summary>
/// Index into <see cref="Items"/> of the track the queue considers current, or -1 when the
/// queue is empty. Always a valid index into <see cref="Items"/> when non-negative.
/// </summary>
int CurrentIndex { get; }
/// <summary>The current track, or null when the queue is empty.</summary>
TrackDto? Current { get; }
/// <summary>True when there is a track after <see cref="CurrentIndex"/> to advance to.</summary>
bool HasNext { get; }
/// <summary>True when there is a track before <see cref="CurrentIndex"/> to step back to.</summary>
bool HasPrevious { get; }
/// <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.
/// </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.
/// </summary>
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
/// <summary>Appends a track to the end of the queue without changing what is currently playing.</summary>
void Enqueue(TrackDto track);
/// <summary>Appends tracks to the end of the queue without changing what is currently playing.</summary>
void EnqueueRange(IEnumerable<TrackDto> tracks);
/// <summary>
/// Advances to the next track and streams it. No-op when <see cref="HasNext"/> is false.
/// </summary>
Task Next();
/// <summary>
/// Steps back to the previous track and streams it. No-op when <see cref="HasPrevious"/> is false.
/// </summary>
Task Previous();
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
void Clear();
}
@@ -0,0 +1,136 @@
using DeepDrftModels.DTOs;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Default <see cref="IQueueService"/>: a single-slot 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.
///
/// <para>
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
/// So the queue is bound to the player via <see cref="Attach"/> (called once by the provider after it
/// 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.
/// </para>
/// </summary>
public sealed class QueueService : IQueueService, IDisposable
{
private readonly List<TrackDto> _items = new();
private IStreamingPlayerService? _player;
public IReadOnlyList<TrackDto> Items => _items;
public int CurrentIndex { get; private set; } = -1;
public TrackDto? Current =>
CurrentIndex >= 0 && CurrentIndex < _items.Count ? _items[CurrentIndex] : null;
public bool HasNext => CurrentIndex >= 0 && CurrentIndex < _items.Count - 1;
public bool HasPrevious => CurrentIndex > 0;
public event Action? QueueChanged;
/// <summary>
/// Binds the queue to the player instance the provider owns, and subscribes to its track-ended
/// signal so the queue auto-advances. Idempotent and re-bindable: re-attaching detaches the prior
/// player first, so the queue never holds a stale subscription after a player swap. Owned by the
/// provider's lifecycle; <see cref="Dispose"/> unsubscribes.
/// </summary>
public void Attach(IStreamingPlayerService player)
{
if (ReferenceEquals(_player, player)) return;
if (_player != null)
_player.TrackEnded -= OnTrackEnded;
_player = player;
_player.TrackEnded += OnTrackEnded;
}
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
{
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
if (list.Count == 0) return;
var start = Math.Clamp(startIndex, 0, list.Count - 1);
_items.Clear();
_items.AddRange(list);
CurrentIndex = start;
QueueChanged?.Invoke();
await PlayCurrent();
}
public void Enqueue(TrackDto track)
{
_items.Add(track);
QueueChanged?.Invoke();
}
public void EnqueueRange(IEnumerable<TrackDto> tracks)
{
var before = _items.Count;
_items.AddRange(tracks);
if (_items.Count != before)
QueueChanged?.Invoke();
}
public async Task Next()
{
if (!HasNext) return;
CurrentIndex++;
QueueChanged?.Invoke();
await PlayCurrent();
}
public async Task Previous()
{
if (!HasPrevious) return;
CurrentIndex--;
QueueChanged?.Invoke();
await PlayCurrent();
}
public void Clear()
{
if (_items.Count == 0 && CurrentIndex == -1) return;
_items.Clear();
CurrentIndex = -1;
QueueChanged?.Invoke();
}
// 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.
private void OnTrackEnded()
{
if (!HasNext) 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();
}
private async Task PlayCurrent()
{
var track = Current;
if (track is null || _player is null) return;
await _player.SelectTrackStreaming(track);
}
public void Dispose()
{
if (_player != null)
{
_player.TrackEnded -= OnTrackEnded;
_player = null;
}
}
}