2b42e01cd0
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.
137 lines
4.5 KiB
C#
137 lines
4.5 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|