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); // 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; QueueChanged?.Invoke(); } public void EnqueueRange(IEnumerable 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; QueueChanged?.Invoke(); } public void Move(int fromIndex, int toIndex) { if (fromIndex == toIndex) return; if (fromIndex < 0 || fromIndex >= _items.Count) return; if (toIndex < 0 || toIndex >= _items.Count) return; var moved = _items[fromIndex]; _items.RemoveAt(fromIndex); _items.Insert(toIndex, moved); // Keep the same track current across the reorder. No playback is touched (C2): we only // recompute which index the current track now sits at. if (CurrentIndex == fromIndex) { CurrentIndex = toIndex; } else if (fromIndex < CurrentIndex && toIndex >= CurrentIndex) { // The current track shifted one slot toward the front to fill the vacated lower slot. CurrentIndex--; } else if (fromIndex > CurrentIndex && toIndex <= CurrentIndex) { // An item inserted at/above the current slot pushed the current track one slot back. CurrentIndex++; } QueueChanged?.Invoke(); } public void RemoveAt(int index) { if (index < 0 || index >= _items.Count) return; _items.RemoveAt(index); if (_items.Count == 0) { // Last remaining track removed → empty + dormant. Does not stop the player (C2). CurrentIndex = -1; } else if (index < CurrentIndex) { // A track before the current was removed: the same track stays current at a lower index. CurrentIndex--; } else if (index == CurrentIndex && CurrentIndex >= _items.Count) { // The current track was removed and it was the last slot: there is no "next" occupant to // resolve to, so the queue goes dormant (CurrentIndex == -1). Playback is NOT stopped // (C2) — the just-removed track keeps playing to its natural end; auto-advance simply has // nothing further. Removing current when it is NOT the last leaves CurrentIndex pointing // at the new occupant of that slot (the next track), so no adjustment is needed there. CurrentIndex = -1; } 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; } } }