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();
}
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. 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;
}
}
}