Merge p11-w1-queue-service into dev (P11 11.F: play-queue IQueueService + skip controls)
This commit is contained in:
@@ -21,6 +21,10 @@ else
|
|||||||
Fixed="Fixed"
|
Fixed="Fixed"
|
||||||
TogglePlayPause="@TogglePlayPause"
|
TogglePlayPause="@TogglePlayPause"
|
||||||
Stop="@Stop"
|
Stop="@Stop"
|
||||||
|
HasNext="HasNext"
|
||||||
|
HasPrevious="HasPrevious"
|
||||||
|
SkipNext="@SkipNext"
|
||||||
|
SkipPrevious="@SkipPrevious"
|
||||||
Class="transport-zone"/>
|
Class="transport-zone"/>
|
||||||
|
|
||||||
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
|||||||
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||||
{
|
{
|
||||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||||
|
[CascadingParameter] public IQueueService? QueueService { get; set; }
|
||||||
[Parameter] public bool Fixed { get; set; } = false;
|
[Parameter] public bool Fixed { get; set; } = false;
|
||||||
|
|
||||||
[Parameter] public EventCallback<bool> OnMinimized { get; set; }
|
[Parameter] public EventCallback<bool> OnMinimized { get; set; }
|
||||||
@@ -19,6 +20,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
private bool _isSeeking = false;
|
private bool _isSeeking = false;
|
||||||
private double _seekPosition = 0;
|
private double _seekPosition = 0;
|
||||||
private IStreamingPlayerService? _subscribedService;
|
private IStreamingPlayerService? _subscribedService;
|
||||||
|
private IQueueService? _subscribedQueue;
|
||||||
|
|
||||||
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
|
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
|
||||||
// spacer reserves its space. We mirror this element's live height into a CSS
|
// spacer reserves its space. We mirror this element's live height into a CSS
|
||||||
@@ -48,6 +50,11 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
private double LoadProgress => PlayerService?.LoadProgress ?? 0;
|
private double LoadProgress => PlayerService?.LoadProgress ?? 0;
|
||||||
private string? ErrorMessage => PlayerService?.ErrorMessage;
|
private string? ErrorMessage => PlayerService?.ErrorMessage;
|
||||||
|
|
||||||
|
// Skip affordances reflect live queue state. With no queue (null) or an empty queue both are
|
||||||
|
// false, so the buttons sit disabled and the bar behaves exactly as it did before the queue.
|
||||||
|
private bool HasNext => QueueService?.HasNext ?? false;
|
||||||
|
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -76,10 +83,35 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||||
_subscribedService = PlayerService;
|
_subscribedService = PlayerService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The queue cascade is also IsFixed, so re-render the skip affordances off its own
|
||||||
|
// change signal — same posture as the player StateChanged subscription above.
|
||||||
|
if (QueueService != null && !ReferenceEquals(QueueService, _subscribedQueue))
|
||||||
|
{
|
||||||
|
if (_subscribedQueue != null)
|
||||||
|
_subscribedQueue.QueueChanged -= OnQueueChanged;
|
||||||
|
|
||||||
|
QueueService.QueueChanged += OnQueueChanged;
|
||||||
|
_subscribedQueue = QueueService;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
private void OnQueueChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
private async Task SkipNext()
|
||||||
|
{
|
||||||
|
if (QueueService == null) return;
|
||||||
|
await QueueService.Next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SkipPrevious()
|
||||||
|
{
|
||||||
|
if (QueueService == null) return;
|
||||||
|
await QueueService.Previous();
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
// Only the docked, expanded shape needs a spacer: the Fixed embed is
|
// Only the docked, expanded shape needs a spacer: the Fixed embed is
|
||||||
|
|||||||
@@ -2,12 +2,25 @@
|
|||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
@if (!Fixed)
|
||||||
|
{
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Large"
|
||||||
|
OnClick="@SkipPrevious"
|
||||||
|
Disabled="!HasPrevious"/>
|
||||||
|
}
|
||||||
<PlayStateIcon Size="Size.Large"
|
<PlayStateIcon Size="Size.Large"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
Disabled="!CanPlay"
|
Disabled="!CanPlay"
|
||||||
OnToggle="@TogglePlayPause"/>
|
OnToggle="@TogglePlayPause"/>
|
||||||
@if (!Fixed)
|
@if (!Fixed)
|
||||||
{
|
{
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Large"
|
||||||
|
OnClick="@SkipNext"
|
||||||
|
Disabled="!HasNext"/>
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
Size="Size.Large"
|
Size="Size.Large"
|
||||||
|
|||||||
@@ -16,4 +16,13 @@ public partial class PlayerControls : ComponentBase
|
|||||||
[Parameter] public bool Fixed { get; set; } = false;
|
[Parameter] public bool Fixed { get; set; } = false;
|
||||||
[Parameter] public required EventCallback TogglePlayPause { get; set; }
|
[Parameter] public required EventCallback TogglePlayPause { get; set; }
|
||||||
[Parameter] public required EventCallback Stop { get; set; }
|
[Parameter] public required EventCallback Stop { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the queue has a track to skip forward to. Drives the skip-next affordance.</summary>
|
||||||
|
[Parameter] public bool HasNext { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the queue has a track to step back to. Drives the skip-previous affordance.</summary>
|
||||||
|
[Parameter] public bool HasPrevious { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public EventCallback SkipNext { get; set; }
|
||||||
|
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
CanPlay="CanPlay"
|
CanPlay="CanPlay"
|
||||||
Fixed="Fixed"
|
Fixed="Fixed"
|
||||||
TogglePlayPause="TogglePlayPause"
|
TogglePlayPause="TogglePlayPause"
|
||||||
Stop="Stop"/>
|
Stop="Stop"
|
||||||
|
HasNext="HasNext"
|
||||||
|
HasPrevious="HasPrevious"
|
||||||
|
SkipNext="SkipNext"
|
||||||
|
SkipPrevious="SkipPrevious"/>
|
||||||
@if (IsLoading && !IsStreaming)
|
@if (IsLoading && !IsStreaming)
|
||||||
{
|
{
|
||||||
<MudProgressCircular Color="Color.Tertiary"
|
<MudProgressCircular Color="Color.Tertiary"
|
||||||
|
|||||||
@@ -14,5 +14,9 @@ public partial class PlayerTransportZone : ComponentBase
|
|||||||
[Parameter] public bool Fixed { get; set; } = false;
|
[Parameter] public bool Fixed { get; set; } = false;
|
||||||
[Parameter] public EventCallback TogglePlayPause { get; set; }
|
[Parameter] public EventCallback TogglePlayPause { get; set; }
|
||||||
[Parameter] public EventCallback Stop { get; set; }
|
[Parameter] public EventCallback Stop { get; set; }
|
||||||
|
[Parameter] public bool HasNext { get; set; }
|
||||||
|
[Parameter] public bool HasPrevious { get; set; }
|
||||||
|
[Parameter] public EventCallback SkipNext { get; set; }
|
||||||
|
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||||
[Parameter] public string? Class { get; set; }
|
[Parameter] public string? Class { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,7 @@
|
|||||||
If instance swapping at runtime is ever needed, change IsFixed to false (adds subscription
|
If instance swapping at runtime is ever needed, change IsFixed to false (adds subscription
|
||||||
overhead on every parent re-render, but allows children to see the new reference). *@
|
overhead on every parent re-render, but allows children to see the new reference). *@
|
||||||
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
|
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
|
||||||
|
<CascadingValue Value="@(_queueService)" IsFixed="true">
|
||||||
@ChildContent
|
@ChildContent
|
||||||
</CascadingValue>
|
</CascadingValue>
|
||||||
|
</CascadingValue>
|
||||||
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
|||||||
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
||||||
|
|
||||||
private IStreamingPlayerService? _audioPlayerService;
|
private IStreamingPlayerService? _audioPlayerService;
|
||||||
|
private QueueService? _queueService;
|
||||||
|
|
||||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
|
||||||
@@ -29,6 +30,13 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
|||||||
// Children must not wrap or replace this callback.
|
// Children must not wrap or replace this callback.
|
||||||
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
|
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
|
||||||
// OnTrackSelected will be set by individual child components that need it
|
// OnTrackSelected will be set by individual child components that need it
|
||||||
|
|
||||||
|
// The queue orchestrates above the single-slot player. The player is not DI-registered
|
||||||
|
// (constructed here), so the queue binds to it via Attach rather than constructor injection —
|
||||||
|
// no construction cycle, no IServiceProvider. Cascaded alongside the player so the bar and a
|
||||||
|
// future up-next panel both read it.
|
||||||
|
_queueService = new QueueService();
|
||||||
|
_queueService.Attach(_audioPlayerService);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -38,6 +46,11 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
// Dispose the queue first so it unsubscribes from the player's TrackEnded before the
|
||||||
|
// player tears down.
|
||||||
|
_queueService?.Dispose();
|
||||||
|
_queueService = null;
|
||||||
|
|
||||||
if (_audioPlayerService is IAsyncDisposable disposable)
|
if (_audioPlayerService is IAsyncDisposable disposable)
|
||||||
{
|
{
|
||||||
await disposable.DisposeAsync();
|
await disposable.DisposeAsync();
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public event Action? StateChanged;
|
public event Action? StateChanged;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event Action? TrackEnded;
|
||||||
|
|
||||||
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
|
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
|
||||||
{
|
{
|
||||||
_audioInterop = audioInterop;
|
_audioInterop = audioInterop;
|
||||||
@@ -268,6 +271,12 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
|||||||
CurrentTime = 0;
|
CurrentTime = 0;
|
||||||
Duration = null;
|
Duration = null;
|
||||||
await NotifyStateChanged();
|
await NotifyStateChanged();
|
||||||
|
|
||||||
|
// Fire AFTER the state notification so any queue orchestrator that advances on this
|
||||||
|
// signal selects the next track against a fully-settled idle state. Raised only on
|
||||||
|
// organic end-of-stream — stop/unload/track-switch go through ResetToIdle, which does
|
||||||
|
// not raise this — so a subscriber can treat it unambiguously as "advance the queue."
|
||||||
|
TrackEnded?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ public interface IPlayerService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
event Action? StateChanged;
|
event Action? StateChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised once when the current track reaches its natural end of playback (the JS
|
||||||
|
/// end-of-stream callback), distinct from a stop/unload/track-switch. This is the single
|
||||||
|
/// hook the play-queue subscribes to in order to auto-advance to the next track. It does
|
||||||
|
/// NOT fire when playback is stopped, the track is switched, or the player is unloaded —
|
||||||
|
/// only on organic completion — so an orchestrator can treat it as "advance the queue."
|
||||||
|
/// </summary>
|
||||||
|
event Action? TrackEnded;
|
||||||
|
|
||||||
// Control methods
|
// Control methods
|
||||||
Task InitializeAsync();
|
Task InitializeAsync();
|
||||||
Task SelectTrack(TrackDto track);
|
Task SelectTrack(TrackDto track);
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using DeepDrftModels.DTOs;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates ordered playback ("what plays next") <em>above</em> the single-slot
|
||||||
|
/// <see cref="IStreamingPlayerService"/>. The player stays a single-track device; the queue owns the
|
||||||
|
/// track list, the current position, skip-forward/back, and auto-advance on natural track end. It
|
||||||
|
/// drives playback solely through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>
|
||||||
|
/// — it adds no new playback semantics.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are
|
||||||
|
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/
|
||||||
|
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/>
|
||||||
|
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> +
|
||||||
|
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only
|
||||||
|
/// adding new ones — so consumers written against today's surface keep working.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// With an empty queue (<see cref="CurrentIndex"/> == -1) the queue is dormant: it drives nothing and
|
||||||
|
/// auto-advances nothing, so direct single-track play through the player behaves exactly as it did
|
||||||
|
/// before the queue existed.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IQueueService
|
||||||
|
{
|
||||||
|
/// <summary>The ordered tracks currently queued. Empty when nothing is enqueued.</summary>
|
||||||
|
IReadOnlyList<TrackDto> Items { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index into <see cref="Items"/> of the track the queue considers current, or -1 when the
|
||||||
|
/// queue is empty. Always a valid index into <see cref="Items"/> when non-negative.
|
||||||
|
/// </summary>
|
||||||
|
int CurrentIndex { get; }
|
||||||
|
|
||||||
|
/// <summary>The current track, or null when the queue is empty.</summary>
|
||||||
|
TrackDto? Current { get; }
|
||||||
|
|
||||||
|
/// <summary>True when there is a track after <see cref="CurrentIndex"/> to advance to.</summary>
|
||||||
|
bool HasNext { get; }
|
||||||
|
|
||||||
|
/// <summary>True when there is a track before <see cref="CurrentIndex"/> to step back to.</summary>
|
||||||
|
bool HasPrevious { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised whenever the queue's contents or current position change. The player bar subscribes
|
||||||
|
/// to re-render its skip-forward/back affordances. Fires on enqueue, advance, step-back, and clear.
|
||||||
|
/// </summary>
|
||||||
|
event Action? QueueChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming
|
||||||
|
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts
|
||||||
|
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses
|
||||||
|
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to
|
||||||
|
/// the end from there. No-op when <paramref name="tracks"/> is empty.
|
||||||
|
/// </summary>
|
||||||
|
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
|
||||||
|
|
||||||
|
/// <summary>Appends a track to the end of the queue without changing what is currently playing.</summary>
|
||||||
|
void Enqueue(TrackDto track);
|
||||||
|
|
||||||
|
/// <summary>Appends tracks to the end of the queue without changing what is currently playing.</summary>
|
||||||
|
void EnqueueRange(IEnumerable<TrackDto> tracks);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances to the next track and streams it. No-op when <see cref="HasNext"/> is false.
|
||||||
|
/// </summary>
|
||||||
|
Task Next();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Steps back to the previous track and streams it. No-op when <see cref="HasPrevious"/> is false.
|
||||||
|
/// </summary>
|
||||||
|
Task Previous();
|
||||||
|
|
||||||
|
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
|
||||||
|
void Clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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.
|
||||||
|
//
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
|
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
|
||||||
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
|
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
|
||||||
|
<!-- Referenced for the client-side queue orchestrator (QueueService / IQueueService).
|
||||||
|
The queue is pure domain logic, unit-testable against a fake IStreamingPlayerService
|
||||||
|
with no browser/JS. -->
|
||||||
|
<ProjectReference Include="..\DeepDrftPublic.Client\DeepDrftPublic.Client.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
using DeepDrftModels.DTOs;
|
||||||
|
using DeepDrftPublic.Client.Services;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the play-queue orchestrator (<see cref="QueueService"/>). The queue is pure
|
||||||
|
/// domain logic over the single-slot player, so it is exercised here against a recording fake
|
||||||
|
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage:
|
||||||
|
/// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and
|
||||||
|
/// auto-advance on the player's <see cref="IPlayerService.TrackEnded"/> signal.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class QueueServiceTests
|
||||||
|
{
|
||||||
|
private FakeStreamingPlayer _player = null!;
|
||||||
|
private QueueService _queue = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_player = new FakeStreamingPlayer();
|
||||||
|
_queue = new QueueService();
|
||||||
|
_queue.Attach(_player);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown() => _queue.Dispose();
|
||||||
|
|
||||||
|
private static List<TrackDto> Tracks(int count) =>
|
||||||
|
Enumerable.Range(1, count)
|
||||||
|
.Select(i => new TrackDto { EntryKey = $"track-{i}", TrackName = $"Track {i}", TrackNumber = i })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// --- Empty-queue invariants (no regression to single-track play) ---
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void NewQueue_IsEmptyWithCurrentIndexNegativeOne()
|
||||||
|
{
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.Items, Is.Empty);
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||||
|
Assert.That(_queue.Current, Is.Null);
|
||||||
|
Assert.That(_queue.HasNext, Is.False);
|
||||||
|
Assert.That(_queue.HasPrevious, Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task NextAndPrevious_OnEmptyQueue_AreNoOpsAndDriveNoPlayback()
|
||||||
|
{
|
||||||
|
await _queue.Next();
|
||||||
|
await _queue.Previous();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||||
|
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PlayRelease: enqueue + ordered start ---
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task PlayRelease_LoadsTracksInOrderAndStreamsFirst()
|
||||||
|
{
|
||||||
|
var tracks = Tracks(3);
|
||||||
|
|
||||||
|
await _queue.PlayRelease(tracks);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||||
|
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||||
|
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-1"));
|
||||||
|
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task PlayRelease_WithStartIndex_StartsMidAlbumAndKeepsRemainderQueued()
|
||||||
|
{
|
||||||
|
var tracks = Tracks(4);
|
||||||
|
|
||||||
|
await _queue.PlayRelease(tracks, startIndex: 2);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||||
|
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
||||||
|
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-3"));
|
||||||
|
Assert.That(_queue.HasNext, Is.True);
|
||||||
|
Assert.That(_queue.HasPrevious, Is.True);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task PlayRelease_ClampsOutOfRangeStartIndex()
|
||||||
|
{
|
||||||
|
var tracks = Tracks(3);
|
||||||
|
|
||||||
|
await _queue.PlayRelease(tracks, startIndex: 99);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||||
|
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-3"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task PlayRelease_WithEmptyTracks_IsNoOp()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Enumerable.Empty<TrackDto>());
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.Items, Is.Empty);
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||||
|
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task PlayRelease_ReplacesAnExistingQueue()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
var second = new List<TrackDto>
|
||||||
|
{
|
||||||
|
new() { EntryKey = "x-1", TrackName = "X1" },
|
||||||
|
new() { EntryKey = "x-2", TrackName = "X2" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await _queue.PlayRelease(second);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.Items, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" }));
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Next / Previous mechanics and bounds ---
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Next_AdvancesThroughTracksInOrder()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
|
||||||
|
await _queue.Next();
|
||||||
|
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||||
|
|
||||||
|
await _queue.Next();
|
||||||
|
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
||||||
|
|
||||||
|
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
|
||||||
|
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Next_AtLastTrack_IsNoOp()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(2), startIndex: 1);
|
||||||
|
|
||||||
|
await _queue.Next();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||||
|
Assert.That(_queue.HasNext, Is.False);
|
||||||
|
// Only the initial PlayRelease selection — Next at the end drove no further playback.
|
||||||
|
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Previous_StepsBackThroughTracks()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(3), startIndex: 2);
|
||||||
|
|
||||||
|
await _queue.Previous();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||||
|
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||||
|
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Previous_AtFirstTrack_IsNoOp()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
|
||||||
|
await _queue.Previous();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||||
|
Assert.That(_queue.HasPrevious, Is.False);
|
||||||
|
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Enqueue / EnqueueRange ---
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Enqueue_AppendsWithoutChangingCurrentOrStartingPlayback()
|
||||||
|
{
|
||||||
|
_queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" });
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.Items, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||||
|
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Enqueue_AfterPlayRelease_ExtendsTheQueueAndEnablesHasNext()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(1));
|
||||||
|
Assert.That(_queue.HasNext, Is.False);
|
||||||
|
|
||||||
|
_queue.Enqueue(new TrackDto { EntryKey = "appended", TrackName = "Appended" });
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.HasNext, Is.True);
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void EnqueueRange_AppendsAllTracks()
|
||||||
|
{
|
||||||
|
_queue.EnqueueRange(Tracks(3));
|
||||||
|
|
||||||
|
Assert.That(_queue.Items, Has.Count.EqualTo(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Clear ---
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Clear_EmptiesQueueAndResetsIndexWithoutStoppingPlayer()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
|
||||||
|
_queue.Clear();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.Items, Is.Empty);
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||||
|
Assert.That(_queue.Current, Is.Null);
|
||||||
|
// Clear is a queue-state reset; it must not tear the player down.
|
||||||
|
Assert.That(_player.StopCount, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QueueChanged notifications ---
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task MutatingOperations_RaiseQueueChanged()
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
_queue.QueueChanged += () => count++;
|
||||||
|
|
||||||
|
await _queue.PlayRelease(Tracks(3)); // 1
|
||||||
|
await _queue.Next(); // 2
|
||||||
|
await _queue.Previous(); // 3
|
||||||
|
_queue.Enqueue(new TrackDto { EntryKey = "z", TrackName = "Z" }); // 4
|
||||||
|
_queue.Clear(); // 5
|
||||||
|
|
||||||
|
Assert.That(count, Is.EqualTo(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Clear_OnAlreadyEmptyQueue_DoesNotRaiseQueueChanged()
|
||||||
|
{
|
||||||
|
var raised = false;
|
||||||
|
_queue.QueueChanged += () => raised = true;
|
||||||
|
|
||||||
|
_queue.Clear();
|
||||||
|
|
||||||
|
Assert.That(raised, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto-advance on TrackEnded ---
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TrackEnded_AutoAdvancesToNextTrack()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
|
||||||
|
_player.RaiseTrackEnded();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||||
|
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||||
|
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TrackEnded_OnLastTrack_DoesNotAdvanceOrReplay()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(2), startIndex: 1);
|
||||||
|
|
||||||
|
_player.RaiseTrackEnded();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||||
|
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TrackEnded_OnEmptyQueue_IsIgnored()
|
||||||
|
{
|
||||||
|
_player.RaiseTrackEnded();
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||||
|
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression guard for the cross-context spurious-advance bug: a direct single-track play
|
||||||
|
/// (Session, StreamNowButton, resume) overwrites the player's CurrentTrack without touching the
|
||||||
|
/// queue. When that external track reaches its natural end, TrackEnded fires — but the queue's
|
||||||
|
/// Current no longer matches the player's CurrentTrack, so the queue must NOT advance.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task TrackEnded_WhenPlayerCurrentTrackIsNotQueueCurrent_DoesNotAdvance()
|
||||||
|
{
|
||||||
|
// Load a 3-track album into the queue (queue.Current → track-1, player.CurrentTrack → track-1).
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||||
|
|
||||||
|
// Simulate a direct play (e.g. SessionDetail streams an unrelated track by Id = 99).
|
||||||
|
// The player's CurrentTrack is now the session track, but the queue is still on track-1.
|
||||||
|
var sessionTrack = new TrackDto { Id = 99, EntryKey = "session-track", TrackName = "Session Mix" };
|
||||||
|
_player.SimulateDirectPlay(sessionTrack);
|
||||||
|
|
||||||
|
// The session track finishes naturally — player raises TrackEnded.
|
||||||
|
_player.RaiseTrackEnded();
|
||||||
|
|
||||||
|
// The queue must not have advanced: index still 0, and no additional SelectTrackStreaming
|
||||||
|
// calls beyond the initial PlayRelease selection.
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "Queue must not advance when a direct-play track ends");
|
||||||
|
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "No further streaming must be triggered by the queue");
|
||||||
|
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"), "Only the original PlayRelease selection must have been streamed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
|
||||||
|
_player.RaiseTrackEnded(); // → track-2
|
||||||
|
_player.RaiseTrackEnded(); // → track-3
|
||||||
|
_player.RaiseTrackEnded(); // last track: no advance
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||||
|
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
|
||||||
|
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Attach lifecycle ---
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Dispose_UnsubscribesFromTrackEnded_SoNoAutoAdvanceAfterDispose()
|
||||||
|
{
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
_queue.Dispose();
|
||||||
|
|
||||||
|
_player.RaiseTrackEnded();
|
||||||
|
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Attach_ToNewPlayer_RedirectsPlaybackAndAutoAdvance()
|
||||||
|
{
|
||||||
|
var second = new FakeStreamingPlayer();
|
||||||
|
_queue.Attach(second);
|
||||||
|
|
||||||
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
Assert.That(second.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"));
|
||||||
|
|
||||||
|
// The old player's TrackEnded must no longer drive this queue.
|
||||||
|
_player.RaiseTrackEnded();
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||||
|
|
||||||
|
// The newly attached player does.
|
||||||
|
second.RaiseTrackEnded();
|
||||||
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the tracks the queue asks the player to stream and lets a test raise the
|
||||||
|
/// player's organic end-of-stream signal. Implements the full <see cref="IStreamingPlayerService"/>
|
||||||
|
/// surface but only the members the queue actually drives carry behavior; the rest are inert
|
||||||
|
/// — the queue touches nothing else, which is exactly the seam this fake pins down.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeStreamingPlayer : IStreamingPlayerService
|
||||||
|
{
|
||||||
|
public List<TrackDto> SelectedTracks { get; } = new();
|
||||||
|
public int StopCount { get; private set; }
|
||||||
|
|
||||||
|
public void RaiseTrackEnded() => TrackEnded?.Invoke();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets <see cref="CurrentTrack"/> to <paramref name="track"/> without recording a
|
||||||
|
/// <see cref="SelectedTracks"/> entry. Models a direct single-track play (SessionDetail,
|
||||||
|
/// StreamNowButton, resume) that overwrites the player state without going through the queue.
|
||||||
|
/// </summary>
|
||||||
|
public void SimulateDirectPlay(TrackDto track) => CurrentTrack = track;
|
||||||
|
|
||||||
|
public Task SelectTrackStreaming(TrackDto track)
|
||||||
|
{
|
||||||
|
SelectedTracks.Add(track);
|
||||||
|
CurrentTrack = track;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Stop()
|
||||||
|
{
|
||||||
|
StopCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action? TrackEnded;
|
||||||
|
|
||||||
|
// Part of the implemented contract but the queue never subscribes to it, so it is
|
||||||
|
// intentionally never raised here.
|
||||||
|
#pragma warning disable CS0067
|
||||||
|
public event Action? StateChanged;
|
||||||
|
#pragma warning restore CS0067
|
||||||
|
|
||||||
|
// Inert remainder of the contract — the queue never invokes these.
|
||||||
|
public bool IsInitialized => false;
|
||||||
|
public bool IsLoaded => false;
|
||||||
|
public bool IsLoading => false;
|
||||||
|
public bool IsPlaying => false;
|
||||||
|
public bool IsPaused => false;
|
||||||
|
public double CurrentTime => 0;
|
||||||
|
public double? Duration => null;
|
||||||
|
public double Volume => 1.0;
|
||||||
|
public double LoadProgress => 0;
|
||||||
|
public string? ErrorMessage => null;
|
||||||
|
public TrackDto? CurrentTrack { get; private set; }
|
||||||
|
public double[]? WaveformProfile => null;
|
||||||
|
public EventCallback? OnStateChanged { get; set; }
|
||||||
|
public EventCallback? OnTrackSelected { get; set; }
|
||||||
|
public bool IsStreamingMode => false;
|
||||||
|
public bool CanStartStreaming => false;
|
||||||
|
public bool HeaderParsed => false;
|
||||||
|
public int BufferedChunks => 0;
|
||||||
|
|
||||||
|
public Task InitializeAsync() => Task.CompletedTask;
|
||||||
|
public Task SelectTrack(TrackDto track) => SelectTrackStreaming(track);
|
||||||
|
public Task Unload() => Task.CompletedTask;
|
||||||
|
public Task TogglePlayPause() => Task.CompletedTask;
|
||||||
|
public Task Seek(double position) => Task.CompletedTask;
|
||||||
|
public Task SetVolume(double volume) => Task.CompletedTask;
|
||||||
|
public Task ClearError() => Task.CompletedTask;
|
||||||
|
public Task WarmAudioContext() => Task.CompletedTask;
|
||||||
|
public Task StageTrack(TrackDto track) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user