Files
deepdrft/product-notes/phase-17-player-queue-view.md
T

25 KiB
Raw Blame History

Phase 17 — Player-Bar Queue View

Status: design spec — open questions pending Daniel. Author: product-designer. Date: 2026-06-19. Plan only — no code has been written by this doc.

This phase makes the play-queue visible and manipulable. Phase 11 (wave 11.F) built the queue engine (IQueueService / QueueService) and wired auto-advance, skip-prev/next, and release embeds — but the queue itself has no UI. A listener can start an album and skip through it, but cannot see what is next, reorder it, drop a track, or hand-build a queue. This phase surfaces it.

Cross-references (read before implementing):

  • PLAN.md §17 — the concise phase entry.
  • DeepDrftPublic.Client/Services/IQueueService.cs / QueueService.cs — the engine this phase drives. Its docstrings already anticipate reorder + removal as additive members (see §1).
  • DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor[.cs] — the bar that gains the Queue button + (Fixed) the embedded panel.
  • DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor / PlayerTransportZone.razor — where transport buttons live; the Queue button sits relative to these.
  • DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor[.cs] — cascades the queue (IsFixed). Player + queue state stays here, at layout level (project memory; do not move into pages).
  • DeepDrftPublic.Client/Pages/FramePlayer.razor + Layout/EmbedLayout.razor — the Fixed/embed surface; the iframe-host sizing constraint lives here (§4).
  • DeepDrftPublic.Client/Controls/SharePopover.razor / WaveformVisualizerControlPopover.razor — the established overlay/dismissal idioms to borrow for the non-Fixed overlay (§3).
  • DeepDrftPublic.Client/Pages/CutDetail.razor, Controls/ReleaseHeroOverlay.razor, Controls/ReleaseGallery.razor, Controls/StreamNowButton.razor — the play-affordance sites the "Add to Queue" button (§5) attaches beside.

0. State it inherits (verified 2026-06-19)

The queue engine is mature and complete for orchestration:

  • IQueueService exposes Items (IReadOnlyList<TrackDto>), CurrentIndex, Current, HasNext, HasPrevious, IsArmed, the QueueChanged event, and commands PlayRelease, Arm, Start, Enqueue, EnqueueRange, Next, Previous, Clear. There is no reorder or remove member yet — but the interface docstring explicitly reserves them as additive ("reordering mutates Items and re-emits QueueChanged"). This phase adds them.
  • The queue is cascaded from AudioPlayerProvider as an IsFixed cascade alongside the player.
  • AudioPlayerBar already subscribes to QueueChanged to re-render skip affordances, and reads HasNext/HasPrevious. Adding a Queue button + panel is the same subscription, more rendering.
  • Fixed (AudioPlayerBar.Fixed) is the embed flag, set true by FramePlayer via EmbedLayout. In Fixed mode the bar is in normal flow (no minimize/spacer/clip); the host iframe is sized by the embed snippet (EmbedSnippetBuilder). Today a release embed arms a shared queue; a single-track embed leaves the queue empty.
  • "Shared queue" today = a multi-track release loaded via PlayRelease (docked) or Arm/Start (embed). The queue is populated from one release's ordered tracks. There is no hand-assembled multi-source queue yet — Enqueue/EnqueueRange exist on the interface but no UI calls them.

The gap this phase closes: the queue is a black box. You can drive it (skip) but not inspect or edit it. Item 4 ("Add to Queue") also activates the dormant Enqueue path — the first time the queue becomes a thing a listener builds, not just a release they play.


1. Goal

Make the queue a first-class, visible, editable object in the player bar — without moving any player or queue state out of the layout-level provider, and without changing the streaming/playback seam.

Three user-visible capabilities, gated by a single new Queue toggle button in the bar:

  1. See the queue — an ordered list of what is queued, with the current track marked.
  2. Edit the queue (where editing is allowed) — drag-reorder and per-track removal.
  3. Grow the queue — an "Add to Queue" affordance everywhere there is a play button.

The two view modes (overlay vs. embedded panel) differ only in presentation and edit-permission, not in the data they read — both bind the same cascaded IQueueService.Items (project memory: one source, multiple views).


2. Constraints (hard)

  • C1 — Player/queue state stays at layout level. The Queue view reads the cascaded IQueueService; it does not own or relocate queue state. No page-level player state. (Project memory.)
  • C2 — No new playback semantics. Reorder/remove mutate Items + CurrentIndex and re-emit QueueChanged; they must not restart, re-stream, or interrupt the currently-playing track (matching the engine's existing "the player stays a single-track device" posture). Reordering the currently-playing item or items around it must keep playback uninterrupted.
  • C3 — Embed = read-only shared queue. In Fixed (embed) mode, a shared (release) queue is fixed-order and non-editable: no drag, no remove. The panel is a display of the up-next list.
  • C4 — The engine is extended additively, never reshaped. New members (Move/RemoveAt) are added to IQueueService; existing members and their contracts are untouched. Consumers written against today's surface keep working (the interface's stated open/closed posture).
  • C5 — Reorder/remove are interop-free state mutations. Like Enqueue/Arm, they touch only the in-memory list + index + QueueChanged. They run safely during prerender. Only PlayRelease/ Start/Next/Previous touch JS.
  • C6 — Drag-and-drop must be touch-viable. The public site's primary listening surface includes mobile (per PLAN.md §1.7). Whatever DnD mechanism is chosen must work with touch, or the design must offer a non-drag fallback for reordering (up/down affordance). Flag — see OQ4.
  • C7 — Embedded panel must not break the iframe host. Adding the panel in Fixed mode grows the player's intrinsic height; the embed snippet's iframe dimensions must account for it (§4, OQ6).

3. Item 1+2 — Queue button & non-Fixed (overlay) mode

3.1 The Queue button

A new icon button in the player bar, shown only when a queue is loaded (Items.Count > 0). Mirror the existing skip-affordance gating: with no queue (null) or empty queue, the button is absent, so the bar is byte-for-byte its pre-queue self for single-track play.

  • Placement (Daniel): to the left of the timestamps and below the other player control buttons. Today PlayerTransportZone is a vertical MudStack of (transport-button row) over (TimestampLabel). The Queue button sits between those two — a second row below the play/skip controls and above the timestamp. That literally satisfies "below the control buttons, to the left of the timestamps."
  • Glyph: Icons.Material.Filled.QueueMusic (or a bespoke DDIcons queue glyph if Daniel wants the hand-rolled aesthetic — OQ7). Color.Primary to match the transport row.
  • Active state: when the overlay/panel is open, the button reads active (filled/highlighted), matching the visualizer popover idiom.

3.2 Non-Fixed overlay panel

When the bar is not Fixed (the docked public player), clicking Queue toggles a screen-centered overlay — borrow the exact idiom Phase 15 settled on for the visualizer controls: MudOverlay (DarkBackground, Modal), panel stops click propagation, scrim-click closes, drag-safe (a position:fixed drag capture sits above the scrim so a drag that ends outside the panel does not dismiss). Not a MudPopover anchored to the bar — a centered modal is the proven pattern here and avoids anchor math against a position:fixed dock.

  • Shape: "mostly square" (Daniel) — a panel roughly min(90vw, 520px) square, scrollable track list inside. Chrome matches NowPlayingCard / the visualizer panel (square corners, lighter-navy, thin border) for family resemblance.
  • Contents: ordered list of Items. Each row: drag handle, track number/position, track name + artist, a now-playing marker on Current (index == CurrentIndex), a remove (×) button. Clicking a row's body jumps playback to that track (PlayRelease(Items, thatIndex) — reuses the existing "play from index" semantics; OQ2 confirms this is desired vs. a dedicated jump).
  • Reorder: drag-and-drop reordering of rows → IQueueService.Move(from, to) (new member, §6).
  • Remove: per-row ×IQueueService.RemoveAt(index) (new member, §6).
  • Header: title ("Queue" / "Up Next"), optional "Clear" action (calls existing Clear(); OQ5 — does Clear belong in the UI, and does it stop playback?).
  • Empty state: the button is hidden when empty, so the overlay is never opened empty by the button. But removal can empty a non-empty queue mid-session — define the terminal state (OQ3).

3.3 Borrowed precedent

The overlay shell, dismissal, and drag-safety are a direct lift of WaveformVisualizerControlPopover (Phase 15 §4). The "mostly square panel, family chrome" is the NowPlayingCard look. Reuse, don't reinvent.


4. Item 3 — Fixed (embedded) mode

When the bar is Fixed (the /FramePlayer iframe embed), the queue list is always shown below the existing player-bar controls — not a toggle overlay. It is part of the embed's standing layout.

  • Permissions (C3): the embedded shared (release) queue is fixed-order, non-editable — no drag handles, no remove buttons. It is a read-only up-next display: ordered track list, current track marked, future tracks listed. Tapping a future row may jump to it (OQ2 — even read-only queues could allow jump-to-track; reorder/remove are the forbidden ops, jump is arguably fine).
  • Container resize (C7): the Fixed bar gains vertical height for the panel. Two coupled changes:
    1. The EmbedLayout / FramePlayer Fixed bar renders the panel inline below the controls.
    2. EmbedSnippetBuilder.ForRelease(...) must mint an iframe tall enough to show the panel. ForTrack(...) stays at today's compact height (single-track embeds have no queue, so no panel). This makes the two embed snippets diverge in height — track-embed compact, release-embed taller. That is correct: only release embeds have a queue to show. (OQ6 — fixed taller height for all release embeds, vs. a height that scales with track count up to a cap. Recommend: a fixed sensible height with the panel internally scrollable past N rows.)

4.1 The Queue button in embed mode (Daniel asked to clarify)

Recommendation: in Fixed mode the panel is always shown, so the Queue button is not needed for toggling — but rather than hide it, repurpose it as a collapse/expand control for the panel so a listener can reclaim the iframe space. Two viable readings, pick one:

  • Option A (recommend) — collapse toggle. Queue button is present in Fixed mode and collapses/expands the always-default-open panel. Default = expanded. Gives the embedder's viewer control without changing the snippet height (collapsed just shows less; the iframe stays sized for expanded). Consistent button meaning across modes ("toggle the queue view").
  • Option B — omit the button in Fixed mode entirely. Panel is always open, no toggle. Simpler, but the button's meaning then differs by mode (present+toggles-overlay when docked, absent when embedded), and a viewer can't reclaim space.

This is OQ1 — Daniel's call. The brief explicitly flagged it.


5. Item 4 — "Add to Queue" affordance

An "Add to Queue" icon button with a tooltip beside every play button for a release or track.

  • Glyph + tooltip: Icons.Material.Filled.QueueMusic / PlaylistAdd with MudTooltip "Add to queue" (track) / "Add release to queue" (release). (OQ7 — Material vs. bespoke glyph.)
  • Behavior:
    • Track context: IQueueService.Enqueue(track) — appends without disturbing current playback (existing member, no UI calls it today; this lights it up).
    • Release context: IQueueService.EnqueueRange(orderedTracks) — appends the whole release's ordered track list (existing member).
  • First-add semantics (define): if the queue is empty/dormant (CurrentIndex == -1), what does "Add to Queue" do? Enqueue appends but does not set a current index or start playback, so adding to an empty queue leaves it with items but Current == null and nothing playing. Options: (a) pure append, listener must press play; (b) if dormant, first add also stages/sets current without auto-playing; (c) if dormant, first add behaves like PlayRelease/play. Recommend (a) — Add-to-Queue is explicitly not play; keep the verbs distinct. But this needs a tiny engine affordance so an appended-into-empty queue has a sensible CurrentIndex for the next skip/play. OQ8.

5.1 Where the button attaches (inventory)

Every site that currently has a play affordance:

Site Current play affordance Add-to-Queue target
CutDetail header "Play" MudButton (whole album) release → EnqueueRange
CutDetail track rows per-row PlayStateIcon track → Enqueue
SessionDetail / MixDetail PlayContent slot in ReleaseHeroOverlay (single track) track → Enqueue
StreamNowButton random-track stream trigger edge case — OQ9 (random has no fixed track until resolved)
ReleaseGallery cards none today (cards are pure links) scope question — OQ10
  • ReleaseGallery cards have no play button today — they navigate to detail. Adding an Add-to-Queue button there is new affordance, arguably out of the literal brief ("anywhere there is a play button"). OQ10 — do cards get one? If yes, they probably want a play button too, which is a bigger card-redesign question. Recommend: defer card-level affordances; scope item 4 to the detail-page play sites (rows 13 above) where a play button genuinely exists today.
  • StreamNowButton is a "surprise me" trigger, not a fixed track — Add-to-Queue has no concrete item until a track resolves. Recommend: exclude it from item 4. OQ9.

So the grounded scope of item 4: the CutDetail header + track rows, and the Session/Mix hero play slot. That is "every play button that targets a known release or track."


6. Engine additions (the only IQueueService changes)

Two new members, both interop-free state mutations that re-emit QueueChanged:

  • void Move(int fromIndex, int toIndex) — reorders Items; adjusts CurrentIndex so the same track stays current across the move (find current track's new position). No re-stream (C2). No-op on out-of-range or equal indices.
  • void RemoveAt(int index) — removes Items[index].
    • Removing a track after current: just remove; CurrentIndex unchanged.
    • Removing a track before current: remove + decrement CurrentIndex (current track stays the same item, new lower index). Playback uninterrupted.
    • Removing the current track: define — recommend stop is not triggered (C2 says mutations don't touch playback), so the playing track keeps playing to its natural end even though it's no longer in the list, and CurrentIndex points at what is now at that slot (the next track) so the next auto-advance / skip is coherent. Edge: removing current when it's the last item → queue becomes shorter; auto-advance simply has nothing to advance to (existing behavior). OQ3/OQ11.
    • Removing the last remaining track → empty queue (CurrentIndex = -1, dormant). The Queue button disappears; if the overlay is open it closes (or shows empty state then closes). OQ3.

Both are additive (C4). Unit-testable against the existing QueueServiceTests fake-player harness with no container (the engine's stated design intent).


7. Use cases

  • UC1 — Inspect up-next (docked). Listener playing an album opens the Queue overlay, sees the ordered remaining tracks with the current one marked, closes it. No edit.
  • UC2 — Reorder (docked). Listener drags track 5 above track 2; playback continues uninterrupted; the new order drives the next auto-advance.
  • UC3 — Remove (docked). Listener removes a track they don't want; if it was upcoming, it's gone from the up-next; if it was current, current keeps playing to natural end (per §6).
  • UC4 — Build a queue (docked). Listener on a Cut detail page clicks "Add to Queue" on three individual tracks across two albums, then opens the queue and plays from the top.
  • UC5 — Embed up-next (Fixed). A blog embeds a release; the iframe shows the player and the fixed-order track list below it; viewer sees what's coming, cannot reorder/remove; (Option A) collapses the panel to reclaim space.
  • UC6 — Single-track embed unchanged. A single-track embed shows no Queue button and no panel — the compact iframe is byte-for-byte today's.
  • UC7 — Jump to track. Listener clicks a queued row → playback jumps to that track and continues from there (if OQ2 = yes).

8. Acceptance criteria

  • AC1 — When Items.Count == 0, the Queue button is absent and the docked bar renders exactly as it does today (no layout shift, no empty panel).
  • AC2 — When Items.Count > 0 and not Fixed, the Queue button appears below the transport controls and to the left of the timestamp; clicking it opens a centered, mostly-square overlay listing the queue with the current track marked.
  • AC3 (docked) — Drag-reordering a row changes the queue order and is reflected in subsequent skip/auto-advance; the currently-playing track is not re-streamed or interrupted by any reorder.
  • AC4 (docked) — Removing an upcoming track removes it from the list and from auto-advance; removing a track before the current one keeps the same track playing; removing the current track does not stop playback (per §6 decision).
  • AC5 (Fixed) — In a release embed, the queue panel renders below the controls by default, shows the ordered tracks with the current marked, and exposes no drag handles or remove buttons.
  • AC6 (Fixed) — The release embed iframe (via EmbedSnippetBuilder.ForRelease) is tall enough to show the panel without clipping; the single-track embed (ForTrack) height is unchanged.
  • AC7 — An "Add to Queue" button with tooltip appears beside the Cut header Play, each Cut track row's play, and the Session/Mix hero play; clicking it appends the track/release to the queue without disturbing the currently-playing track.
  • AC8 — Add-to-Queue on a dormant (empty) queue leaves the queue populated with a coherent CurrentIndex such that the next play/skip behaves correctly (per OQ8 resolution).
  • AC9Move and RemoveAt are additive to IQueueService; all existing QueueServiceTests pass unchanged; no existing member's behavior changes.
  • AC10 — All queue-view rendering and the new engine mutations run during prerender without JS interop (no prerender exceptions); only playback transitions touch JS.

9. Test cases

Engine (QueueServiceTests, fake-player harness — no container):

  • T1 Move(2, 0) on a 4-item queue reorders correctly; Items reflects new order; QueueChanged fired once.
  • T2 Move keeps the current track current: with CurrentIndex at track X, moving other items around it leaves Current == X; moving X itself updates CurrentIndex to X's new slot.
  • T3 Move does not call the player's stream method (assert fake player saw no SelectTrackStreaming).
  • T4 RemoveAt after current: CurrentIndex unchanged, item gone.
  • T5 RemoveAt before current: CurrentIndex decremented, same track current.
  • T6 RemoveAt of current: player not stopped (per §6); CurrentIndex resolves to the new occupant or -1 if it was last.
  • T7 RemoveAt of last remaining item → empty + dormant (CurrentIndex == -1, Items empty, QueueChanged fired).
  • T8 Move/RemoveAt out-of-range / no-op cases do not throw and do not fire QueueChanged.
  • T9 Enqueue into a dormant queue then play behaves per OQ8 resolution.
  • T10 Reordering the currently-playing item to a new slot leaves auto-advance pointing at the correct next track when the current ends.

Component (if/when component test coverage is in scope — today none exists for the client, per DeepDrftPublic.Client/CLAUDE.md):

  • T11 Queue button absent when queue empty (render assertion).
  • T12 Overlay opens/closes via button + scrim; a simulated drag that ends on the scrim does not dismiss.
  • T13 Fixed-mode panel renders inline with no drag/remove controls.
  • T14 EmbedSnippetBuilder.ForRelease height differs from ForTrack (unit test on the builder).

10. Open questions for Daniel

  • OQ1 — Queue button in embed (Fixed) mode. Panel is always shown there. Option A: keep the button as a collapse/expand toggle (recommend). Option B: omit the button entirely. Daniel's call — explicitly flagged in the brief.
  • OQ2 — Click-to-jump. Does clicking a queued row jump playback to that track? Recommend yes in both modes (reuses PlayRelease(Items, index)); jump is not a forbidden edit, only reorder/ remove are. Confirm for the read-only embed too.
  • OQ3 — Terminal/empty states. When removal empties the queue mid-session: close the overlay? Show an empty state then auto-close? And when the current track is removed, what should the panel and the player show?
  • OQ4 — Drag-and-drop mechanism & touch. MudBlazor has MudDropContainer (HTML5 DnD, weak on touch) vs. a JS-interop pointer-drag (like RadialKnob's capturePointer) vs. a non-drag up/down-arrow reorder fallback. Mobile is a primary surface (C6). Which mechanism? Recommend: pointer-based interop reorder (touch-viable, matches the codebase's existing pointer-capture pattern) or ship up/down arrows for v1 and add drag later. Daniel's taste call.
  • OQ5 — "Clear queue" in the UI. Include a Clear action in the overlay header? Does Clear stop playback or just empty the up-next (engine's Clear does not stop the player today)?
  • OQ6 — Embed panel height. Fixed sensible height with internal scroll past N rows (recommend), vs. height that grows with track count up to a cap. Affects EmbedSnippetBuilder.ForRelease.
  • OQ7 — Glyph. Material QueueMusic/PlaylistAdd, or a bespoke hand-rolled DDIcons queue glyph to match the gas-lamp/lava-lamp family aesthetic?
  • OQ8 — Add-to-Queue into a dormant (empty) queue. Pure append (recommend — keep "add" ≠ "play"), vs. first-add also stages current, vs. first-add plays. Affects the engine's index handling.
  • OQ9 — StreamNowButton. Exclude from Add-to-Queue (recommend — no fixed track until resolved)?
  • OQ10 — ReleaseGallery cards. Do browse-grid cards get an Add-to-Queue (and implicitly a play) button? Recommend defer — cards have no play button today; adding one is a card redesign beyond this brief. Scope item 4 to the detail-page play sites.
  • OQ11 — Removing the current track. Confirm the recommended "keep playing to natural end, don't stop" behavior (C2-consistent) vs. "removing current skips to next immediately."

11. Wave decomposition (implementation sequencing)

Three waves; 17.1 is the load-bearing prerequisite (engine + the shared queue-list view that both modes render). 17.2 and 17.3 then hang off it and are largely parallel.

  • 17.1 — Engine additions + shared queue-list view. Add Move/RemoveAt to IQueueService/ QueueService (+ QueueServiceTests). Build a single presentational QueueList component that renders Items with the current marked and takes an Editable flag (drag/remove on when true). This is the one source both modes consume. Cold-start. Gates 17.2 + 17.3. No bar changes yet.
  • 17.2 — Non-Fixed overlay. Queue button in the bar (gated on Items.Count > 0, placed per §3.1), the centered overlay borrowing the visualizer-popover idiom, hosting QueueList Editable. Reorder/remove/jump wired. Depends on 17.1.
  • 17.3 — Fixed embed panel + snippet resize + Add-to-Queue. Inline QueueList (non-editable) below the Fixed bar; the (OQ1) button behavior; EmbedSnippetBuilder.ForRelease height bump; the Add-to-Queue buttons at the detail-page play sites (§5.1 grounded scope). Depends on 17.1. (Add-to-Queue could split into its own wave 17.4 if Daniel wants it shipped independently — it only needs the existing Enqueue/EnqueueRange, not 17.1's new members.)

Dependency shape: 17.1 → {17.2, 17.3}. The cold-start item is 17.1. Most open questions (OQ1OQ11) must be resolved before 17.2/17.3 land, but 17.1 can begin immediately — the engine contract for Move/RemoveAt is settled (§6) and independent of the UI decisions.