using DeepDrftModels.DTOs; namespace DeepDrftPublic.Client.Services; /// /// Default : a single-slot orchestrator over an /// . Holds the ordered list and current index as pure state, /// drives playback through the player's existing , /// and auto-advances on the player's signal. /// /// /// The player instance is not DI-registered — AudioPlayerProvider constructs and cascades it. /// So the queue is bound to the player via (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 IServiceProvider. The queue's /// own constructor stays parameterless, so the queue logic is unit-testable against a fake player with /// no container. /// /// public sealed class QueueService : IQueueService, IDisposable { private readonly List _items = new(); private IStreamingPlayerService? _player; public IReadOnlyList Items => _items; public int CurrentIndex { get; private set; } = -1; public bool IsArmed { get; private set; } 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; /// /// 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; unsubscribes. /// 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 tracks, int startIndex = 0) { var list = tracks as IReadOnlyList ?? 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; QueueChanged?.Invoke(); await PlayCurrent(); } public void Arm(IEnumerable tracks) { var list = tracks as IReadOnlyList ?? tracks.ToList(); if (list.Count == 0) return; _items.Clear(); _items.AddRange(list); CurrentIndex = 0; IsArmed = true; // No PlayCurrent: arming is interop-free state only. The first play gesture drives Start(). QueueChanged?.Invoke(); } public async Task Start() { if (!IsArmed) return; IsArmed = false; QueueChanged?.Invoke(); await PlayCurrent(); } public void Enqueue(TrackDto track) { _items.Add(track); QueueChanged?.Invoke(); } public void EnqueueRange(IEnumerable tracks) { var before = _items.Count; _items.AddRange(tracks); if (_items.Count != before) QueueChanged?.Invoke(); } public async Task Next() { if (!HasNext) return; CurrentIndex++; IsArmed = false; QueueChanged?.Invoke(); await PlayCurrent(); } public async Task Previous() { if (!HasPrevious) return; CurrentIndex--; IsArmed = false; QueueChanged?.Invoke(); await PlayCurrent(); } public void Clear() { if (_items.Count == 0 && CurrentIndex == -1) return; _items.Clear(); CurrentIndex = -1; IsArmed = false; 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. // // 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) // 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 // because DTO copies through serialisation are not reference-equal. 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(); } 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; } } }