Merge queue-deque-redesign into dev
Two-level deque queue model + five bug fixes, plus review cleanup.
This commit is contained in:
@@ -54,7 +54,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
|
||||
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1). Serializes the play classification and fires it via `BeaconInterop` to `api/event/play`. Synchronous (`EmitPlay` cannot await — it is called from the player close path and the page-unload handler). **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
|
||||
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/telemetry/anonid.ts` (mints GUID on first visit, returns null without throwing when storage is unavailable).
|
||||
- `IQueueService` / `QueueService`: Ordered playback orchestrator above the single-slot player. `PlayRelease(tracks, startIndex)` replaces the queue and starts streaming; `Next`/`Previous` advance or step back; `Enqueue`/`EnqueueRange` append without interrupting the current track; `Clear` empties the queue. **Armed-idle state** added to support prerender-safe release embeds: `Arm(tracks)` loads the track list at index 0 with no JS interop (safe during prerender); `IsArmed` signals the armed-but-not-streaming state; `Start()` begins streaming the current track and clears `IsArmed`, leaving the list and position intact so auto-advance carries on. `AudioPlayerBar` reads `IsArmed` to route the first play gesture through `Start()` instead of streaming the staged track alone. `QueueChanged` event fires on all list/position changes; cascaded via `AudioPlayerProvider`. **Wave 17.1 additions:** `Move(int fromIndex, int toIndex)` reorders `Items` in-place, adjusting `CurrentIndex` so the same track stays current across the move — never re-streams or interrupts playback; `RemoveAt(int index)` removes an item and adjusts `CurrentIndex` (removing the current track does not stop playback; removing the last remaining item leaves the queue empty and dormant). Both are interop-free state mutations that re-emit `QueueChanged`. **Dormant-`Enqueue` coherence (OQ8):** `Enqueue`/`EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) set `CurrentIndex` to 0 so a subsequent play/skip is correct — but do not auto-play. **Wave 17.2 additions:** `ClearUpcoming()` removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0` and re-emitting `QueueChanged` — touches no playback (OQ5: Clear does not stop or remove the current track). `PlayRelease` now always materializes a defensive copy of its input (`tracks.ToList()`) so it can never alias the service's own `Items` list — fixes a row-jump bug where `PlayRelease(Items, index)` could mutate the live list mid-operation.
|
||||
- `IQueueService` / `QueueService`: **Two-level deque** orchestrator above the single-slot player. The deque has two entry ends. **PLAY (manual)** enters the FRONT: `PlayTrack(track)` and `PlayRelease(tracks, startIndex)` prepend the played track/release in order, **remove the previously-current track**, make the new front current, start streaming it, and leave whatever sat after the old current intact behind the prepend (a whole release prepends in order in one op). The detail pages (Cut header/row, Session/Mix hero) and `StreamNowButton` route their PLAY through these. **Add-to-queue** enters the BACK: `Enqueue`/`EnqueueRange` append to the end without interrupting the current track (`AddToQueueButton`). `Next`/`Previous` advance or step back, walking `CurrentIndex` and leaving played tracks behind so `Previous` can reach them; `JumpTo(index)` moves the pointer to a queued row and streams it once (the playlist panel's row-jump — it does NOT prepend or stream the intervening rows). **End-of-track:** auto-advance (`TrackEnded`) advances when there is a next track; when the **last** track ends naturally the queue **empties** and goes dormant (bug #2) rather than stranding the finished track. `Clear` empties the queue. **Bug #3 (dormant-seed):** the first `Enqueue`/`EnqueueRange` into a dormant queue while a track is already playing externally (via the attached player, not through the queue) seeds the head with that now-playing track and then appends — yielding `[now-playing, added]` (even when adding the same track). The queue learns the externally-playing track through the existing `Attach(player)` seam (`_player.CurrentTrack`) — no new dependency, no `IServiceProvider`. **Armed-idle state** (prerender-safe release embeds): `Arm(tracks)` replaces the queue at index 0 with no JS interop; `IsArmed` signals armed-but-not-streaming; `Start()` streams the current track and clears `IsArmed`. `AudioPlayerBar` reads `IsArmed` to route the embed's first play gesture through `Start()`. `QueueChanged` fires on all list/position changes; cascaded via `AudioPlayerProvider`. `Move`/`RemoveAt` are interop-free reorder/remove mutations that adjust `CurrentIndex` and never re-stream. `ClearUpcoming()` keeps the current track and drops the up-next. **Bug #4 (reactivity):** `AudioPlayerBar.QueueItems` snapshots `QueueService.Items` into a fresh list per render (the service exposes its backing list by reference), so Blazor parameter diffing detects in-place Clear/remove/reorder; `QueueList.OnParametersSet` refreshes its `MudDropContainer` snapshot so the open panel re-flows immediately. **Bug #1 (label):** the docked `QueueOverlay` panel header reads **"Playlist"** (the current track stays listed). `PlayRelease` materializes `tracks.ToList()` before mutating so it can never alias the service's own `Items` list.
|
||||
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
|
||||
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
|
||||
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)` → `Stream` with optional Range header support for seek-beyond-buffer.
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
@using DeepDrftPublic.Client.Services
|
||||
|
||||
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the
|
||||
cascaded IQueueService's Enqueue/EnqueueRange (which append without disturbing current playback and
|
||||
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select.
|
||||
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole
|
||||
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@
|
||||
cascaded IQueueService's Enqueue/EnqueueRange (which append to the END without disturbing current
|
||||
playback; a first add into a dormant queue seeds the head from the externally-playing track when one
|
||||
exists, then appends) — never PlayRelease/PlayTrack/Start/Select. Track mode (Track set) appends a
|
||||
single track; release mode (ReleaseTracks set) appends the whole ordered list. Reads queue state from
|
||||
the layout-level cascade (C1); owns no data fetch. *@
|
||||
|
||||
<MudTooltip Text="@Tooltip">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
|
||||
|
||||
@@ -45,7 +45,7 @@ else
|
||||
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
|
||||
controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3).
|
||||
Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked
|
||||
overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started).
|
||||
overlay, which calls JumpTo (moves the pointer and streams the row, clearing IsArmed).
|
||||
Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The
|
||||
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
|
||||
shrunken height to the host iframe. *@
|
||||
|
||||
@@ -85,7 +85,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
|
||||
private bool ShowFixedPanel => Fixed && HasQueue;
|
||||
|
||||
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
|
||||
// Cached snapshot of the queue list (bug #4 fix). QueueService.Items returns the service's
|
||||
// backing list by reference, so passing it straight through means Blazor parameter diffing sees
|
||||
// an unchanged reference after an in-place Clear/remove/reorder and the child (QueueList /
|
||||
// MudDropContainer) keeps its stale snapshot until reopened. We snapshot on first access and
|
||||
// rebuild in OnQueueChanged, so every real mutation hands the child a NEW reference while
|
||||
// progress-tick re-renders (the frequent path) reuse the cached one without allocating.
|
||||
private IReadOnlyList<TrackDto>? _queueItemsCache;
|
||||
private IReadOnlyList<TrackDto> QueueItems =>
|
||||
_queueItemsCache ??= QueueService is null ? [] : QueueService.Items.ToList();
|
||||
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
|
||||
|
||||
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
|
||||
@@ -141,6 +149,11 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
|
||||
private void OnQueueChanged()
|
||||
{
|
||||
// Invalidate the snapshot so QueueItems rebuilds a fresh list on the next render.
|
||||
// This gives Blazor a new reference on every real mutation (bug #4 reactivity preserved)
|
||||
// while progress-tick re-renders that don't go through here keep the cached reference.
|
||||
_queueItemsCache = null;
|
||||
|
||||
// If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close
|
||||
// the overlay so it cannot strand open over an empty queue. The button gate hides the overlay
|
||||
// mount too, so this keeps state and view consistent.
|
||||
@@ -189,12 +202,14 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
|
||||
private void ClearUpcoming() => QueueService?.ClearUpcoming();
|
||||
|
||||
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that
|
||||
// touches playback — it streams the chosen track via the player.
|
||||
// Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY,
|
||||
// not an in-place seek), so a jump cannot route through it without duplicating the queue. JumpTo
|
||||
// moves the pointer to the chosen row and streams it once — preserving deque order. This is the one
|
||||
// queue action besides PLAY/skip that touches playback.
|
||||
private async Task OnQueueJump(int index)
|
||||
{
|
||||
if (QueueService == null) return;
|
||||
await QueueService.PlayRelease(QueueService.Items, index);
|
||||
await QueueService.JumpTo(index);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
|
||||
@@ -71,6 +71,13 @@
|
||||
|
||||
private MudDropContainer<QueueRow>? _dropContainer;
|
||||
|
||||
// MudDropContainer snapshots its Items into internal drop zones and does not re-read them on a
|
||||
// plain re-render — so a Clear/remove/reorder that changes the parent's Items list must be pushed
|
||||
// into the container explicitly, or the panel shows the stale order until reopened (bug #4). The
|
||||
// parent passes a fresh Items reference per mutation; refreshing here on every parameter set re-flows
|
||||
// the container's snapshot to match. Cheap: Refresh only re-reads the bound list.
|
||||
protected override void OnParametersSet() => _dropContainer?.Refresh();
|
||||
|
||||
// Index-tagged view rows. The index is the row's position in Items at render time and is the
|
||||
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
|
||||
private List<QueueRow> Rows =>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
Class="deepdrft-queue-overlay">
|
||||
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
|
||||
<div class="deepdrft-queue-modal-header">
|
||||
<span class="deepdrft-queue-modal-title">Up Next</span>
|
||||
<span class="deepdrft-queue-modal-title">Playlist</span>
|
||||
<MudButton Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace DeepDrftPublic.Client.Controls;
|
||||
public partial class ReleaseDetailScaffold : ComponentBase
|
||||
{
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
[Parameter] public required string Title { get; set; }
|
||||
[Parameter] public string? Artist { get; set; }
|
||||
@@ -96,13 +97,19 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
||||
{
|
||||
if (Track is null || PlayerService is null) return;
|
||||
|
||||
// Toggle if this track is already active (playing or paused); otherwise start a fresh
|
||||
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
|
||||
// Toggle if this track is already active (playing or paused); otherwise PLAY it —
|
||||
// prepend to the queue's front (deque PLAY semantics) so it becomes current and
|
||||
// the existing queue stays intact behind it. Falls back to a direct stream when
|
||||
// the queue cascade is absent (prerender / non-interactive).
|
||||
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
|
||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else if (Queue is not null)
|
||||
{
|
||||
await Queue.PlayTrack(Track);
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(Track);
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
[Parameter] public string LoadingLabel { get; set; } = "Finding a track…";
|
||||
[Parameter] public EventCallback OnStreamStarted { get; set; }
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
[Inject] public required ITrackDataService TrackData { get; set; }
|
||||
|
||||
private bool _streamLoading;
|
||||
@@ -79,7 +80,12 @@
|
||||
_findingTrack = false;
|
||||
StateHasChanged();
|
||||
|
||||
if (PlayerService is not null)
|
||||
// PLAY semantics: prepend to the queue's front so a "stream now" track becomes current and
|
||||
// any existing queue stays intact behind it. Falls back to a direct stream when the queue
|
||||
// cascade is absent.
|
||||
if (Queue is not null)
|
||||
await Queue.PlayTrack(track);
|
||||
else if (PlayerService is not null)
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
@@ -100,10 +100,13 @@ else
|
||||
protected override string PersistKey => "mix-detail";
|
||||
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
// The hero now carries the play affordance (the scaffold's header is suppressed), so the
|
||||
// play-toggle is wired here directly — mirroring SessionDetail. Toggle if this track is already
|
||||
// active, otherwise start a fresh stream.
|
||||
// active, otherwise PLAY it: prepend to the queue's front (deque PLAY semantics) so it becomes
|
||||
// current and the existing queue stays intact behind it. Falls back to a direct stream when the
|
||||
// queue cascade is absent (prerender / non-interactive).
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
var track = ViewModel.Track;
|
||||
@@ -114,6 +117,10 @@ else
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else if (Queue is not null)
|
||||
{
|
||||
await Queue.PlayTrack(track);
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
|
||||
@@ -94,10 +94,13 @@ else
|
||||
protected override string PersistKey => "session-detail";
|
||||
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
// Mirrors the play-toggle wiring the shared scaffold owns. Session detail composes the player
|
||||
// affordance directly (it diverges from ReleaseDetailScaffold for the overlay layout), so the
|
||||
// toggle logic lives here: toggle if this track is already active, otherwise start a fresh stream.
|
||||
// toggle logic lives here: toggle if this track is already active, otherwise PLAY it — prepend to
|
||||
// the queue's front (deque PLAY semantics) so it becomes current and the existing queue stays
|
||||
// intact behind it. Falls back to a direct stream when the queue cascade is absent.
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
var track = ViewModel.Track;
|
||||
@@ -108,6 +111,10 @@ else
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else if (Queue is not null)
|
||||
{
|
||||
await Queue.PlayTrack(track);
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
|
||||
@@ -10,18 +10,35 @@ namespace DeepDrftPublic.Client.Services;
|
||||
/// — 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.
|
||||
/// <b>Two-level deque model (the load-bearing invariant).</b> The queue is a deque whose
|
||||
/// <see cref="Current"/> track (the item at <see cref="CurrentIndex"/>) is the live "front of play".
|
||||
/// Two families of mutation enter the deque from opposite ends:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>PLAY (manual)</b> — <see cref="PlayTrack"/> / <see cref="PlayRelease"/> prepend to the
|
||||
/// <em>front</em>. The previously-current track is removed, the prepended track(s) become the head
|
||||
/// in order, the new head becomes current and starts streaming, and whatever sat after the old
|
||||
/// current stays intact behind the prepend. A whole release prepends in order in one operation.</item>
|
||||
/// <item><b>Add-to-queue</b> — <see cref="Enqueue"/> / <see cref="EnqueueRange"/> append to the
|
||||
/// <em>end</em>. They never interrupt the current track and never start playback.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Advance and end-of-track.</b> <see cref="Next"/> and auto-advance (the player's
|
||||
/// <see cref="IPlayerService.TrackEnded"/>) walk <see cref="CurrentIndex"/> forward, leaving the just-
|
||||
/// played track in the list behind the pointer so <see cref="Previous"/> can step back to it. The one
|
||||
/// exception is the <em>last</em> track: when the current track ends naturally and there is nothing
|
||||
/// after it, the queue <b>empties</b> and goes dormant (<see cref="CurrentIndex"/> == -1) rather than
|
||||
/// stranding the finished track as current.
|
||||
/// </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.
|
||||
/// before the queue existed. The <b>first</b> <see cref="Enqueue"/>/<see cref="EnqueueRange"/> into a
|
||||
/// dormant queue while a track is already playing externally seeds the head from the player's current
|
||||
/// track (learned through the attached player, no extra dependency) and then appends the added item, so
|
||||
/// the resulting deque is <c>[now-playing, added…]</c> rather than a phantom single entry.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IQueueService
|
||||
@@ -41,9 +58,10 @@ public interface IQueueService
|
||||
/// <summary>
|
||||
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet —
|
||||
/// the embed's pre-gesture state. Set by <see cref="Arm"/>; cleared the moment playback actually
|
||||
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="Next"/>/<see cref="Previous"/>)
|
||||
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through
|
||||
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone.
|
||||
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="PlayTrack"/>/<see cref="Next"/>/
|
||||
/// <see cref="Previous"/>) or on <see cref="Clear"/>. The player bar reads this to route the first
|
||||
/// play gesture through <see cref="Start"/> (which begins the armed release) rather than streaming
|
||||
/// the staged track alone.
|
||||
/// </summary>
|
||||
bool IsArmed { get; }
|
||||
|
||||
@@ -55,26 +73,40 @@ public interface IQueueService
|
||||
|
||||
/// <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.
|
||||
/// to re-render its skip-forward/back affordances. Fires on enqueue, prepend, 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.
|
||||
/// Manual PLAY of a single track: prepends <paramref name="track"/> to the <em>front</em> of the
|
||||
/// deque, removes the previously-current track, makes <paramref name="track"/> the new head/current,
|
||||
/// and starts streaming it. The rest of the queue (everything that sat after the old current) stays
|
||||
/// intact behind the new head. Into a dormant queue this simply becomes the sole head and plays.
|
||||
/// This is the deque-front counterpart to the append-only <see cref="Enqueue"/>.
|
||||
/// </summary>
|
||||
Task PlayTrack(TrackDto track);
|
||||
|
||||
/// <summary>
|
||||
/// Manual PLAY of a release: prepends <paramref name="tracks"/> (in the order given) to the
|
||||
/// <em>front</em> of the deque, removes the previously-current track, and starts streaming the
|
||||
/// prepended track at <paramref name="startIndex"/> — which becomes current. Tracks prepended
|
||||
/// before <paramref name="startIndex"/> sit behind the pointer (reachable via <see cref="Previous"/>);
|
||||
/// tracks after it are up-next; whatever sat after the old current stays intact behind the whole
|
||||
/// prepend. This is the "play album" entry point the detail pages consume: a header Play uses
|
||||
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index. No-op when
|
||||
/// <paramref name="tracks"/> is empty.
|
||||
/// </summary>
|
||||
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT
|
||||
/// streaming anything — the queue is "armed". This is the embed's prerender-safe entry point: it
|
||||
/// performs no JS interop, so it runs identically during prerender and after WASM boot. The first
|
||||
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which
|
||||
/// keeps the loaded release queued so it advances through its tracks. No-op when
|
||||
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed).
|
||||
/// performs no JS interop, so it runs identically during prerender and after WASM boot. It replaces
|
||||
/// the queue (an armed embed is a fresh staged release, not a prepend). The first play gesture (see
|
||||
/// <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which keeps the loaded
|
||||
/// release queued so it advances through its tracks. No-op when <paramref name="tracks"/> is empty
|
||||
/// (the queue stays empty and disarmed).
|
||||
/// </summary>
|
||||
void Arm(IEnumerable<TrackDto> tracks);
|
||||
|
||||
@@ -88,18 +120,24 @@ public interface IQueueService
|
||||
Task Start();
|
||||
|
||||
/// <summary>
|
||||
/// Appends a track to the end of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
|
||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
||||
/// Appends a track to the <em>end</em> of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) while a track is already playing
|
||||
/// externally (through the attached player but not via the queue), the append first seeds the head
|
||||
/// with that now-playing track, then appends <paramref name="track"/> — yielding
|
||||
/// <c>[now-playing, track]</c> so the queue reflects what the listener actually hears. Into a fully
|
||||
/// dormant queue with nothing playing, the single appended track becomes the head at
|
||||
/// <see cref="CurrentIndex"/> == 0. Either way it does NOT begin playback (add is not play).
|
||||
/// Interop-free; safe during prerender.
|
||||
/// </summary>
|
||||
void Enqueue(TrackDto track);
|
||||
|
||||
/// <summary>
|
||||
/// Appends tracks to the end of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
|
||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
||||
/// Appends tracks to the <em>end</em> of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue while a track is already playing externally, the append first seeds the head
|
||||
/// with that now-playing track (see <see cref="Enqueue"/>), then appends the range. Into a fully
|
||||
/// dormant queue with nothing playing, the first appended track becomes the head at
|
||||
/// <see cref="CurrentIndex"/> == 0. It does NOT begin playback (add is not play). Interop-free; safe
|
||||
/// during prerender.
|
||||
/// </summary>
|
||||
void EnqueueRange(IEnumerable<TrackDto> tracks);
|
||||
|
||||
@@ -136,6 +174,15 @@ public interface IQueueService
|
||||
/// </summary>
|
||||
Task Previous();
|
||||
|
||||
/// <summary>
|
||||
/// Moves the current pointer to <paramref name="index"/> and streams that track once. This is the
|
||||
/// row-jump primitive the open playlist panel uses: unlike <see cref="PlayRelease"/> it does not
|
||||
/// prepend (the track is already in the deque), and unlike repeated <see cref="Next"/> it does not
|
||||
/// stream the intervening rows. No-op when <paramref name="index"/> is out of range or already
|
||||
/// current.
|
||||
/// </summary>
|
||||
Task JumpTo(int index);
|
||||
|
||||
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
|
||||
void Clear();
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ using DeepDrftModels.DTOs;
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IQueueService"/>: a single-slot orchestrator over an
|
||||
/// Default <see cref="IQueueService"/>: a two-level deque 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.
|
||||
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal. PLAY mutations enter
|
||||
/// the front (prepend); add-to-queue mutations enter the back (append) — see <see cref="IQueueService"/>
|
||||
/// for the full invariant.
|
||||
///
|
||||
/// <para>
|
||||
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
|
||||
@@ -14,7 +16,8 @@ namespace DeepDrftPublic.Client.Services;
|
||||
/// 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.
|
||||
/// no container. The attached player is also the seam by which the queue learns the externally-playing
|
||||
/// track when a dormant <see cref="Enqueue"/> needs to seed the head.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class QueueService : IQueueService, IDisposable
|
||||
@@ -54,23 +57,42 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
_player.TrackEnded += OnTrackEnded;
|
||||
}
|
||||
|
||||
public async Task PlayTrack(TrackDto track)
|
||||
{
|
||||
PrependForPlay(new[] { track }, prependIndex: 0);
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
|
||||
{
|
||||
var list = 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;
|
||||
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<TrackDto> 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<TrackDto> tracks)
|
||||
{
|
||||
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||
@@ -94,27 +116,47 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
|
||||
public void Enqueue(TrackDto track)
|
||||
{
|
||||
SeedHeadFromPlayerIfDormant();
|
||||
_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;
|
||||
EnsureCoherentDormantIndex();
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void EnqueueRange(IEnumerable<TrackDto> 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;
|
||||
var toAdd = tracks as IReadOnlyList<TrackDto> ?? 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;
|
||||
@@ -192,6 +234,16 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
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;
|
||||
@@ -217,23 +269,40 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
|
||||
// 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.
|
||||
// queue.
|
||||
//
|
||||
// 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)
|
||||
// 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 advance. Id-based equality is used rather than ReferenceEquals
|
||||
// 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 (!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();
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user