diff --git a/COMPLETED.md b/COMPLETED.md index f8b3f91..4ddafc7 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -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` 30–80%, `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`). diff --git a/DeepDrftAPI/CLAUDE.md b/DeepDrftAPI/CLAUDE.md index 28ffd16..f1edbed 100644 --- a/DeepDrftAPI/CLAUDE.md +++ b/DeepDrftAPI/CLAUDE.md @@ -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. diff --git a/DeepDrftData/CLAUDE.md b/DeepDrftData/CLAUDE.md index d981bd2..0b9c206 100644 --- a/DeepDrftData/CLAUDE.md +++ b/DeepDrftData/CLAUDE.md @@ -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(options => - options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); // or UseSqlite for dev + options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); services.AddScoped(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); diff --git a/DeepDrftPublic.Client/CLAUDE.md b/DeepDrftPublic.Client/CLAUDE.md index ec54c8b..d48ed25 100644 --- a/DeepDrftPublic.Client/CLAUDE.md +++ b/DeepDrftPublic.Client/CLAUDE.md @@ -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` (not wrapped in ApiResultDto envelope). diff --git a/DeepDrftPublic/CLAUDE.md b/DeepDrftPublic/CLAUDE.md index 85e8a60..fe1de61 100644 --- a/DeepDrftPublic/CLAUDE.md +++ b/DeepDrftPublic/CLAUDE.md @@ -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. diff --git a/PLAN.md b/PLAN.md index 3000963..7d837bd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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.1–16.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.** --- diff --git a/TODO.md b/TODO.md index d4aa849..d5d3b2c 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/product-notes/phase-17-player-queue-view.md b/product-notes/phase-17-player-queue-view.md index 85307a9..9d06a16 100644 --- a/product-notes/phase-17-player-queue-view.md +++ b/product-notes/phase-17-player-queue-view.md @@ -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 1–3 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 1–3 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.)