using DeepDrftModels.DTOs; namespace DeepDrftPublic.Client.Services; /// /// Default : a two-level deque 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. PLAY mutations enter /// the front (prepend); add-to-queue mutations enter the back (append) — see /// for the full invariant. /// /// /// 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. The attached player is also the seam by which the queue learns the externally-playing /// track when a dormant needs to seed the head. /// /// 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 PlayTrack(TrackDto track) { PrependForPlay(new[] { track }, prependIndex: 0); QueueChanged?.Invoke(); await PlayCurrent(); } public async Task PlayRelease(IEnumerable tracks, int startIndex = 0) { var list = tracks.ToList(); if (list.Count == 0) return; var start = Math.Clamp(startIndex, 0, list.Count - 1); PrependForPlay(list, start); QueueChanged?.Invoke(); await PlayCurrent(); } // The shared PLAY-prepend mutation (bug #5). Removes the previously-current track, inserts the // played track(s) at the front in order, and points CurrentIndex at the prepended item the caller // chose to start on. Whatever sat AFTER the old current stays intact behind the prepend; the old // back-history (items before the old current) is discarded because a fresh PLAY defines a new // front. Pure state — callers invoke QueueChanged + PlayCurrent. IsArmed clears: playback is real now. private void PrependForPlay(IReadOnlyList played, int prependIndex) { // Drop the previously-current track only (its tail — the up-next after it — is preserved). // Anything before the old current is back-history that a new PLAY supersedes. if (CurrentIndex >= 0 && CurrentIndex < _items.Count) _items.RemoveRange(0, CurrentIndex + 1); _items.InsertRange(0, played); CurrentIndex = prependIndex; IsArmed = false; } 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) { SeedHeadFromPlayerIfDormant(); _items.Add(track); EnsureCoherentDormantIndex(); QueueChanged?.Invoke(); } public void EnqueueRange(IEnumerable tracks) { var toAdd = tracks as IReadOnlyList ?? tracks.ToList(); if (toAdd.Count == 0) return; SeedHeadFromPlayerIfDormant(); _items.AddRange(toAdd); EnsureCoherentDormantIndex(); QueueChanged?.Invoke(); } // Bug #3: the first add into a dormant queue while a track is already playing externally (through // the attached player but not via the queue) must seed the head with that now-playing track, so the // append yields [now-playing, added] instead of a phantom single entry. We read the player's // CurrentTrack — the same seam OnTrackEnded uses — so no extra dependency is introduced. Only seeds // when truly dormant (empty list) AND a player track exists; a non-dormant queue is untouched. private void SeedHeadFromPlayerIfDormant() { if (_items.Count != 0) return; var playing = _player?.CurrentTrack; if (playing is null) return; _items.Add(playing); CurrentIndex = 0; } // After an append, a dormant queue (CurrentIndex == -1, e.g. nothing was playing to seed from) // needs a coherent head so a subsequent play/skip is correct — but add is not play, so we never // stream here. A queue that already has a current index is left untouched. private void EnsureCoherentDormantIndex() { if (CurrentIndex == -1 && _items.Count > 0) CurrentIndex = 0; } 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 async Task JumpTo(int index) { if (index < 0 || index >= _items.Count) return; if (index == CurrentIndex) return; CurrentIndex = index; IsArmed = false; QueueChanged?.Invoke(); await PlayCurrent(); } public void Clear() { if (_items.Count == 0 && CurrentIndex == -1) return; _items.Clear(); CurrentIndex = -1; IsArmed = false; QueueChanged?.Invoke(); } public void ClearUpcoming() { // Keep the currently-playing track, drop everything else. No current track (dormant/empty) or a // queue that already holds only the current → nothing to clear. var current = Current; if (current is null || _items.Count <= 1) return; _items.Clear(); _items.Add(current); CurrentIndex = 0; // Playback is untouched (C2): the current track keeps streaming; we only discarded the up-next. 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. // // Guard: only act 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 touch the queue. Id-based equality is used rather than ReferenceEquals // because DTO copies through serialisation are not reference-equal. // // When the ended track IS the queue's current: advance if there is a next track, otherwise the queue // has reached its end — empty it (bug #2), so the finished last track is not stranded as current and // the queue goes dormant (panel/button gone per HasQueue gating). private void OnTrackEnded() { if (_player?.CurrentTrack?.Id != Current?.Id) return; if (Current is null) return; if (HasNext) { // 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(); } else { // Last track ended naturally → empty the deque. The player is left alone (its stream has // already ended on its own); we only reset queue state. _items.Clear(); CurrentIndex = -1; IsArmed = false; QueueChanged?.Invoke(); } } 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; } } }