docs: resolve four Phase 17 open questions (OQ1/OQ4/OQ8/OQ10), defer ReleaseGallery card affordance

This commit is contained in:
daniel-c-harvey
2026-06-19 13:42:19 -04:00
parent a715f4b28d
commit ebbaa3f84f
8 changed files with 177 additions and 63 deletions
+21
View File
@@ -6,6 +6,27 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.1 — Foundation (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The anonymous telemetry **substrate** — foundation end-to-end with nothing reading it yet. No `anonId` written; no home-card/read surface changed (those are waves 16.3 and 16.5). The full capture-and-storage pipeline is in place: client-side play-session tracker and share tracker, `sendBeacon` transport with page-unload handler, proxied and rate-limited intake endpoints, append-only SQL event log with incremental rollup, and server-side release attribution.
- **Why:** The home hero's Plays stat card (`NowPlayingStats.razor`'s third card) has been a static "XXX / Plays (Coming Soon)" placeholder. Phase 16 builds the anonymous, privacy-light substrate that will eventually power it. Wave 16.1 is the cold-start foundation — nothing reads the log yet; correctness and storage are the deliverable, not the visible metric.
- **Shape:**
- **Client — `PlayTracker`** (`DeepDrftPublic.Client/Services/PlayTracker.cs`): opens a play session on playback start, advances a high-water position on each progress tick (instrumented at `StreamingAudioPlayerService`, not at the HTTP layer, so seek-beyond-buffer re-fetches count as the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3s OR ≥5% of duration (whichever is smaller). Three-bucket classification: `partial` < 30%, `sampled` 3080%, `complete` > 80%. Emits at most one event per session via `IPlayEventSink`. Deliberately free of player, HTTP, and JS dependencies for testability.
- **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce so repeated copies of the same link in a session count as one share. Sends via `sendBeacon`. No `anonId` in wave 16.1.
- **Client — `BeaconInterop`** (`DeepDrftPublic.Client/Services/BeaconInterop.cs`): `navigator.sendBeacon` JS interop wrapper + page-unload handler that flushes any pending play event when the page is torn down.
- **Public proxy — `EventProxyController`** (`DeepDrftPublic/Controllers/EventProxyController.cs`): proxies `POST api/event/play` and `POST api/event/share` to DeepDrftAPI. Buffers and relays the small JSON body verbatim; forwards `X-Forwarded-For` for per-IP rate limiting on the API side. Opts out of antiforgery (`[IgnoreAntiforgeryToken]`) — `sendBeacon` cannot attach tokens.
- **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `POST api/event/play` and `POST api/event/share`, unauthenticated, rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, registered in `Program.cs`). Returns `202 Accepted` (fire-and-forget contract). Payload-validates the track key and enum values; delegates writes to `IEventService`.
- **API — rate limiter** (`DeepDrftAPI/Program.cs`): `AddRateLimiter` + `"events"` fixed-window policy keyed on `Connection.RemoteIpAddress`; `UseForwardedHeaders` in production resolves the XFF chain into the real client IP. `UseRateLimiter()` added to the middleware pipeline.
- **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): append-only writes to `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository stamps the release id.
- **Data — `EventManager` / `IEventService`** (`DeepDrftData/EventManager.cs`): `IEventService` boundary (`RecordPlay`, `RecordShare`); `EventManager` wraps `EventRepository` and returns NetBlocks `Result`. Registered scoped in `DeepDrftAPI/Program.cs` alongside the existing track and release domain services.
- **Migration `20260619155610_AddPlayShareTelemetry`**: adds `play_event`, `share_event`, and `play_counter` tables. **Authored but not yet applied** (Daniel-gated).
---
## Home Hero Stats — Live data wiring (landed 2026-06-18)
**Landed:** 2026-06-18 on dev (commits `5f0422a` + `8fa330f`, merged `e9e6b60`).
+22
View File
@@ -307,6 +307,28 @@ Aggregate figures behind the public home hero stat row (`NowPlayingStats`). A si
- Aggregated in `TrackRepository.GetHomeStatsAsync`, surfaced via `ITrackService`/`TrackManager`. Controller is `StatsController` — a thin HTTP boundary; no domain logic lives there.
- Returns 200 on success. Returns 500 on query error.
## The event endpoints (Phase 16 anonymous telemetry)
Both endpoints are unauthenticated and rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, keyed on `Connection.RemoteIpAddress` after `UseForwardedHeaders()` resolves XFF). Returns `202 Accepted` — fire-and-forget contract; the `sendBeacon` client ignores the response. Controller: `EventController`.
### POST api/event/play (unauthenticated, rate-limited)
Records an anonymous play event. Client sends only the track `EntryKey` and a completion bucket; server-side release resolution joins track→release at write time (D4). Wave 16.1 drops any `anonId` the client sends — that field is wired in wave 16.3.
- **Body** (`PlayEventDto`): `{ "trackEntryKey": "...", "bucket": "partial"|"sampled"|"complete" }`.
- Validates: non-empty `trackEntryKey`; `bucket` must be a defined `PlayBucket` enum value.
- Delegates to `IEventService.RecordPlay`, which appends to `play_event` and bumps `play_counter`.
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 when the rate limit is exceeded. Returns 500 on a write failure (logged; beacon ignores it).
### POST api/event/share (unauthenticated, rate-limited)
Records an anonymous share event (a clipboard write from `SharePopover`).
- **Body** (`ShareEventDto`): `{ "targetKey": "...", "targetType": "track"|"release", "channel": "link"|"embed" }`.
- Validates: non-empty `targetKey`; defined `ShareTargetType` and `ShareChannel` enum values.
- Delegates to `IEventService.RecordShare`, which appends to `share_event`.
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 on rate limit. Returns 500 on write failure.
## ApiKey middleware behaviour
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
+12 -5
View File
@@ -20,7 +20,7 @@ Separating domain logic from hosts so DeepDrftAPI can reuse `TrackManager` / `Tr
DeepDrftData/
├── Data/
│ ├── DeepDrftContext.cs # EF DbContext
│ ├── DeepDrftContextFactory.cs # Design-time factory (hard-codes ../Database/deepdrft.db)
│ ├── DeepDrftContextFactory.cs # Design-time factory (reads environment/connections.json; Npgsql dummy fallback)
│ └── Configurations/
│ └── TrackConfiguration.cs # EF fluent configuration for TrackEntity
├── Migrations/ # EF-generated migrations (namespace DeepDrftData.Migrations)
@@ -32,7 +32,7 @@ DeepDrftData/
## EF DbContext and configuration
`DeepDrftContext` targets SQLite, connection string from `appsettings.json` (`ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) hard-codes `../Database/deepdrft.db` for `dotnet ef` commands, so you can run migrations locally without a full app context.
`DeepDrftContext` targets **PostgreSQL** (Npgsql), connection string from `environment/connections.json` (loaded at runtime via `CredentialTools.ResolvePathOrThrow("connections", ...)` in `DeepDrftAPI/Program.cs`, key `ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) reads the same `environment/connections.json` when present and falls back to a Npgsql dummy connection string (`Host=localhost;Database=deepdrft-design-time;Username=dummy`) for CI or environments without the file, so `dotnet ef` commands work without a live database.
`TrackConfiguration` uses EF fluent API:
- Table name: `track` (singular)
@@ -56,6 +56,13 @@ Notable repository / service methods beyond the standard CRUD:
- `TrackRepository.UpdateDurationAsync` / `ITrackService.UpdateDuration`: Null-guarded duration write — skips rows where `DurationSeconds` is already set. Used by the one-time backfill (`POST api/track/duration/backfill`).
- `TrackRepository.SetDurationAsync` / `ITrackService.SetDuration`: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (`POST api/track/{id:long}/replace-audio`) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
## Phase 16 — anonymous telemetry domain (EventRepository / EventManager)
`EventRepository` and `EventManager` (with `IEventService` boundary) are the SQL-side domain for anonymous play/share telemetry (Phase 16 wave 16.1). Unlike `TrackRepository`, these entities have no soft-delete lifecycle and are not `BaseEntity`/`IEntity``EventRepository` is a plain context-backed repository against the same scoped `DeepDrftContext`.
- **`EventRepository`** (`Repositories/EventRepository.cs`): append-only writes to the `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository joins track→release and stamps the `release_id` on the row.
- **`EventManager` / `IEventService`** (`EventManager.cs`): `RecordPlay(trackEntryKey, bucket, anonId, ct)` and `RecordShare(targetType, targetKey, channel, anonId, ct)`. Wraps `EventRepository` and returns NetBlocks `Result`. Registered scoped in `DeepDrftAPI/Program.cs`. Migration: `20260619155610_AddPlayShareTelemetry` (authored; not yet applied — Daniel-gated).
Example:
```csharp
@@ -139,9 +146,9 @@ Migrations live in the `DeepDrftData.Migrations` namespace. Migration files are
## Connection string
- **DeepDrftAPI**: `environment/connections.json``ConnectionStrings:DefaultConnection`
- Points at the same database (PostgreSQL in production, SQLite for local development).
- Always PostgreSQL (Npgsql) — both production and local development.
The design-time factory hard-codes the local path for `dotnet ef` commands.
The design-time factory reads `environment/connections.json` when present; falls back to a Npgsql dummy for CI.
## Service registration
@@ -149,7 +156,7 @@ In `DeepDrftAPI/Program.cs`:
```csharp
services.AddDbContext<DeepDrftContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); // or UseSqlite for dev
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<TrackRepository>();
services.AddScoped<TrackManager>();
services.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
+3
View File
@@ -46,6 +46,9 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load.
- `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.
- `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`.
- `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).
+2
View File
@@ -95,6 +95,8 @@ The proxy forwards public, unauthenticated routes:
- `GET api/track/genres` — distinct genres with counts
- `GET api/track/random` — random track selection
- `GET api/track/meta/by-key/{entryKey}` — metadata lookup by vault entry key
- `POST api/event/play` — anonymous play-event telemetry (Phase 16; `EventProxyController`, `[IgnoreAntiforgeryToken]`)
- `POST api/event/share` — anonymous share-event telemetry (Phase 16; `EventProxyController`, `[IgnoreAntiforgeryToken]`)
All actions use `HttpCompletionOption.ResponseHeadersRead` for streaming efficiency. Audio streaming registers the upstream response with `HttpContext.Response.RegisterForDispose()` so the stream is properly cleaned up after the response body is sent.
+28 -16
View File
@@ -249,7 +249,7 @@ The phase deferred behind the home-hero **Plays** stat card (`NowPlayingStats.ra
**Sequenced BOTTOM-UP (Daniel directive 2026-06-19): foundation first, the live card LAST.** Reverses the earlier "visible win early" framing — Daniel does not care about the live card until the whole substrate and all metrics are finished. Strict chain: `16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5`. 16.1 is the only cold-start wave; 16.5 (the card) is the capstone built last.
- **16.1 — Foundation: capture seam + transport + event log (nothing reads it yet).** Player-service play tracker (high-water mark + engagement floor), share tracker in `SharePopover` (debounced), `sendBeacon` interop + unload handler, `POST api/event/{play,share}` (proxied, rate-limited), append-only `play_event`/`share_event` log + incremental `play_counter` rollup, server-side release resolution + derived release totals. **No `anonId` yet; no card consumption.** Cold-start wave.
- **16.1 — Foundation: capture seam + transport + event log (nothing reads it yet).** Player-service play tracker (high-water mark + engagement floor), share tracker in `SharePopover` (debounced), `sendBeacon` interop + unload handler, `POST api/event/{play,share}` (proxied, rate-limited), append-only `play_event`/`share_event` log + incremental `play_counter` rollup, server-side release resolution + derived release totals. **No `anonId` yet; no card consumption.** Cold-start wave. **Landed:** 2026-06-19 on dev. Migration `20260619155610_AddPlayShareTelemetry` authored but not applied (Daniel-gated).
- **16.2 — Completion-bucket classification + shares.** Three-bucket classification (D1) correct and exhaustive end to end (tracker → payload → log → per-bucket counter columns); share-channel split (link/embed). **Depends on 16.1.**
- **16.3 — Unique-listener `anonId` layer (lowest-priority metric, D5).** Option A: mint/read client first-party `localStorage` id, thread `anonId` onto payloads (nullable in the log), count distinct server-side (all-time, D3). **The last metric layer** — folded into "everything finished," explicitly last-built of the substrate. **Depends on 16.1; builds on 16.2.**
- **16.4 — Per-target / CMS stats surfaces.** `[speculative]``GET api/stats/{track,release}/{key}` + CMS analytics views (bucket/channel splits, leaderboards). Not committed; the event log already supports it. Off the critical path to the card; build before the capstone only if a surface wants it. **Depends on 16.116.3.**
@@ -281,11 +281,15 @@ pre-queue self):
**read-only** for shared release queues (no reorder, no remove); the embed iframe is resized to fit
the panel.
4. **Add to Queue** affordance — an icon button + tooltip beside every detail-page play button for a
release (→ `EnqueueRange`) or track (→ `Enqueue`), lighting up the dormant `Enqueue` path.
release (→ `EnqueueRange`) or track (→ `Enqueue`), lighting up the dormant `Enqueue` path. Scoped
to the detail-page play sites (Cut header, Cut track rows, Session/Mix hero); **`ReleaseGallery`
browse-grid cards are excluded** (no play button today — deferred per OQ10, captured in `TODO.md`).
**Architectural spine.** Engine grows **two additive members only**`Move(from, to)` and
**Architectural spine.** Engine grows **two additive members**`Move(from, to)` and
`RemoveAt(index)` — interop-free state mutations that re-emit `QueueChanged` and **never re-stream or
interrupt the playing track** (the engine's stated open/closed posture; existing members untouched).
interrupt the playing track** (the engine's stated open/closed posture; existing members untouched),
**plus a small additive `Enqueue`-into-dormant affordance** (OQ8: append leaves a coherent
`CurrentIndex` so the next play/skip is correct, without auto-playing).
Both view modes render **one shared `QueueList` presentational component** off the same cascaded
`IQueueService.Items`, differing only in presentation + an `Editable` flag (project memory: *one
source, multiple views*). Reorder/remove run safely during prerender (no JS) — only playback
@@ -293,22 +297,30 @@ transitions touch interop.
**Sequenced as three waves.** `17.1 → {17.2, 17.3}`. **17.1 (engine `Move`/`RemoveAt` + the shared
`QueueList` view) is the cold-start prerequisite**, settled and independent of the UI decisions —
it can begin immediately. 17.2 (docked overlay, editable) and 17.3 (Fixed embed panel + snippet
resize + Add-to-Queue) hang off it and are largely parallel. Add-to-Queue may split to a standalone
17.4 (it needs only the existing `Enqueue`/`EnqueueRange`, not 17.1's new members).
it can begin immediately. 17.2 (docked overlay, editable, `MudDropContainer` reorder) and 17.3 (Fixed
embed panel + snippet resize + Add-to-Queue — **the OQ1 Option-A-vs-B feasibility call is made here**)
hang off it and are largely parallel. Add-to-Queue may split to a standalone 17.4 (it needs only the
existing `Enqueue`/`EnqueueRange`, not 17.1's new members).
Full design — goal, constraints, use cases, acceptance criteria, test cases, wave decomposition, and
the open-question set: `product-notes/phase-17-player-queue-view.md`.
**Open questions (Daniel's call — spec §10).** OQ1 (Queue button behavior in embed mode given the
panel is always shown — recommend repurpose as collapse/expand toggle); OQ2 (click-a-row to jump —
recommend yes, both modes); OQ3 (terminal/empty states after removal); OQ4 (drag mechanism + touch
viability — `MudDropContainer` vs. pointer-interop vs. up/down-arrow fallback; mobile is a primary
surface); OQ5 (Clear-queue in the UI and whether it stops playback); OQ6 (embed panel height —
fixed+scroll vs. grow-to-cap); OQ7 (Material vs. bespoke `DDIcons` glyph); OQ8 (Add-to-Queue into a
dormant/empty queue — recommend pure append, keep "add" ≠ "play"); OQ9 (exclude `StreamNowButton`
no fixed track); OQ10 (`ReleaseGallery` cards — recommend defer; cards have no play button today);
OQ11 (removing the current track — recommend keep playing to natural end). **None block 17.1.**
**Open questions — 4 resolved (Daniel, 2026-06-19), 7 pending (spec §10).**
- **Resolved (Daniel, 2026-06-19):** **OQ1****Option A, conditional** — collapse/expand toggle *if*
the embed snippet can dynamically resize the iframe (`postMessage` → host resize handshake), **else
fall back to Option B** (omit the button); A preferred, B fallback, deciding factor = iframe-resize
feasibility, **determined during 17.3**. **OQ4****`MudDropContainer` for now** (C6 softened —
touch-viability is a known risk with a planned pivot path, not a pre-ship blocker). **OQ8**
**pure append** (add ≠ play; first add into a dormant queue leaves a coherent `CurrentIndex` via the
17.1 engine affordance, no auto-play). **OQ10****deferred** (cards get no Add-to-Queue in Phase
17; deferred card work captured in `TODO.md`).
- **Still pending (recommendations stand, not confirmed):** OQ2 (click-a-row to jump — recommend yes,
both modes); OQ3 (terminal/empty states after removal); OQ5 (Clear-queue in the UI and whether it
stops playback); OQ6 (embed panel height — fixed+scroll vs. grow-to-cap; couples to OQ1's resize
decision); OQ7 (Material vs. bespoke `DDIcons` glyph); OQ9 (exclude `StreamNowButton` — no fixed
track); OQ11 (removing the current track — recommend keep playing to natural end).
- **None block 17.1.**
---
+13 -1
View File
@@ -1,3 +1,15 @@
# TODO.md — Known issues and bugs
# TODO.md — Known issues and deferred work
## Bugs
No open bugs.
## Deferred work
Small, real items deferred from a landed or in-flight phase. Larger directions live in `PLAN.md`.
- **ReleaseGallery card play + Add-to-Queue affordance (deferred from Phase 17 item 4).**
`ReleaseGallery` browse-grid cards have no play button today — they navigate to detail. Revisit
giving them a play + Add-to-Queue affordance (a card-redesign question). Deferred from Phase 17
item 4 per OQ10 (Daniel, 2026-06-19); Phase 17 scopes Add-to-Queue to the detail-page play sites
(Cut header, Cut track rows, Session/Mix hero) only. See `product-notes/phase-17-player-queue-view.md` §5.1.
+76 -41
View File
@@ -1,8 +1,18 @@
# Phase 17 — Player-Bar Queue View
Status: **design spec — open questions pending Daniel.** Author: product-designer. Date: 2026-06-19.
Status: **design spec — 4 of 11 open questions resolved (Daniel, 2026-06-19); 7 still pending.** Author: product-designer. Date: 2026-06-19.
**Plan only — no code has been written by this doc.**
**Resolved (Daniel, 2026-06-19):** OQ1 → **Option A, conditional** (collapse/expand toggle *if* dynamic
iframe resize is achievable in the embed snippet; **else fall back to Option B**, omit the button —
feasibility call made during 17.3). OQ4 → **`MudDropContainer` for now** (pivot to a touch-viable
mechanism later if mobile issues surface; C6 softened — touch-viability is a known risk, not a pre-ship
blocker). OQ8 → **pure append, confirmed** (add ≠ play; first add into a dormant queue leaves a coherent
`CurrentIndex` via a small additive engine affordance, landing with wave 17.1). OQ10 → **deferred,
confirmed** (cards get no Add-to-Queue in Phase 17; the deferred card work is captured in `TODO.md`).
The remaining seven (OQ2, OQ3, OQ5, OQ6, OQ7, OQ9, OQ11) are **still pending** — their §10
recommendations stand but are not confirmed.
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
@@ -88,9 +98,12 @@ source, multiple views*).
- **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.**
- **C6 — Drag-and-drop should be touch-viable (softened — OQ4 resolved).** The public site's primary
listening surface includes mobile (per `PLAN.md §1.7`). **Resolved (Daniel, 2026-06-19): ship with
`MudDropContainer` now.** Touch-viability is **no longer a hard pre-ship blocker** — it is a known
risk with a planned pivot path: if mobile/touch issues surface in practice, pivot to a touch-viable
mechanism (pointer-interop reorder, matching `RadialKnob`'s `capturePointer`, or an up/down-arrow
fallback) in a later pass. See OQ4 (resolved).
- **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).
@@ -169,15 +182,23 @@ the existing player-bar controls** — not a toggle overlay. It is part of the e
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
- **Option A (resolved — preferred) — 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.
control. Consistent button meaning across modes ("toggle the queue view").
- **Option B (fallback) — 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.
**OQ1 — RESOLVED (Daniel, 2026-06-19): Option A, *conditional on feasibility*.** Use Option A **if**
the iframe can be dynamically resized appropriately for collapse/expand — e.g. an embed-snippet
`postMessage` → host resize handshake so the host iframe shrinks when the panel collapses and grows
when it expands. **Otherwise fall back to Option B** (omit the button, panel always open at fixed
height). **A is preferred; B is the fallback; the deciding factor is whether dynamic iframe resize is
achievable in the embed snippet.** This A-vs-B determination is **made during 17.3 implementation**
record it there. Note the interaction with OQ6 (embed panel height): a collapse/expand that does *not*
resize the iframe (the earlier "iframe stays sized for expanded; collapsed just shows less" reading) is
**not** Option A under this resolution — Option A requires the iframe to actually resize. A non-resizing
collapse is cosmetic and does not let the viewer reclaim space, so it collapses into Option B's value.
---
@@ -192,14 +213,14 @@ An **"Add to Queue" icon button with a tooltip** beside **every play button for
(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.**
- **First-add semantics — RESOLVED (Daniel, 2026-06-19): pure append (option (a)).** Add-to-Queue is
explicitly *not* play; keep the verbs distinct. If the queue is **empty/dormant** (`CurrentIndex ==
-1`), the first add appends and **leaves a coherent `CurrentIndex`** (the small additive engine
affordance) so the next play/skip behaves correctly — but **does not auto-play**. The listener still
presses play to start. (Options (b) "first add also stages/sets current without auto-playing" and (c)
"first add behaves like `PlayRelease`/play" are declined — they blur add vs. play.) **This engine
tweak belongs with wave 17.1** (it is part of the `Enqueue`-into-empty index handling, alongside the
`Move`/`RemoveAt` additions). See **OQ8 (resolved)**.
### 5.1 Where the button attaches (inventory)
@@ -214,10 +235,11 @@ Every site that currently has a play affordance:
| `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.
Add-to-Queue button there is *new* affordance, out of the literal brief ("anywhere there is a play
button"). **OQ10 — RESOLVED (Daniel, 2026-06-19): deferred, confirmed.** Cards get **no**
Add-to-Queue affordance in Phase 17. Scope item 4 to the detail-page play sites (rows 13 above)
where a play button genuinely exists today. The deferred work — giving cards a play + Add-to-Queue
affordance (a card-redesign question) — is captured as a `TODO.md` entry.
- **`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.**
@@ -331,32 +353,40 @@ Component (if/when component test coverage is in scope — today none exists for
## 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.
**Four resolved (Daniel, 2026-06-19): OQ1, OQ4, OQ8, OQ10. Seven still pending: OQ2, OQ3, OQ5, OQ6,
OQ7, OQ9, OQ11 — their recommendations below stand but are not confirmed.**
- **OQ1 — Queue button in embed (Fixed) mode. RESOLVED → Option A, conditional.** Use Option A
(collapse/expand toggle) **if** the iframe can be dynamically resized for collapse/expand (embed-snippet
`postMessage` → host resize handshake); **else fall back to Option B** (omit the button). A preferred,
B fallback; deciding factor = whether dynamic iframe resize is achievable in the embed snippet;
determination made during **17.3** (see §4.1).
- **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.
- **OQ4 — Drag-and-drop mechanism & touch. RESOLVED → `MudDropContainer` for now.** Ship with
MudBlazor's `MudDropContainer`. If mobile/touch issues surface in practice, pivot to a touch-viable
mechanism later (pointer-based interop reorder matching `RadialKnob`'s `capturePointer`, or an
up/down-arrow fallback). C6 softened: touch-viability is a known risk with a planned pivot path, **not**
a hard pre-ship blocker (see §2 C6).
- **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.
- **OQ8 — Add-to-Queue into a dormant (empty) queue. RESOLVED → pure append.** Add ≠ play; first add
into a dormant queue appends and leaves a coherent `CurrentIndex` (small additive engine affordance)
so the next play/skip behaves correctly, but does **not** auto-play. The engine tweak lands with wave
**17.1**. (first-add-stages and first-add-plays both declined — see §5.)
- **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.
- **OQ10 — `ReleaseGallery` cards. RESOLVED → deferred, confirmed.** Browse-grid cards get **no**
Add-to-Queue affordance in Phase 17. Item 4 is scoped to the detail-page play sites (Cut header, Cut
track rows, Session/Mix hero). The deferred card work (play + Add-to-Queue, a card-redesign question)
is captured in `TODO.md`.
- **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."
@@ -368,15 +398,20 @@ Three waves; **17.1 is the load-bearing prerequisite** (engine + the shared queu
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.
`QueueService` (+ `QueueServiceTests`). **Plus the OQ8 affordance:** `Enqueue` into a dormant/empty
queue leaves a coherent `CurrentIndex` (pure append, no auto-play) so the next play/skip is correct.
Build a single presentational `QueueList` component that renders `Items` with the current marked and
takes an `Editable` flag (drag/remove on when true; reorder via `MudDropContainer` per OQ4). 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.**
below the Fixed bar; **the OQ1 feasibility determination is made here** — implement Option A
(collapse/expand button + iframe resize handshake) **if** dynamic iframe resize proves achievable in
the embed snippet, else fall back to Option B (omit the button); `EmbedSnippetBuilder.ForRelease`
height bump; the Add-to-Queue buttons at the detail-page play sites (§5.1 grounded scope — **cards
excluded per OQ10**). **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.)