feat(player): add IQueueService orchestrating album playback above the single-slot player (P11 11.F)

Queue owns ordered tracks, current index, skip-fwd/back, and auto-advance via the player's TrackEnded hook; binds through Attach (no ctor growth, no service-locator). Player-bar skip controls; empty-queue play unchanged. Adds QueueService unit tests.
This commit is contained in:
daniel-c-harvey
2026-06-16 00:04:44 -04:00
parent 56e205082d
commit 2b42e01cd0
14 changed files with 771 additions and 2 deletions
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
private IStreamingPlayerService? _audioPlayerService;
private QueueService? _queueService;
[Parameter] public RenderFragment? ChildContent { get; set; }
@@ -29,6 +30,13 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
// Children must not wrap or replace this callback.
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
// 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>
@@ -38,6 +46,11 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
/// </summary>
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)
{
await disposable.DisposeAsync();