From e9f4411fdf2d839010aa16f1b39b52f41aa58c41 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 15 Jun 2026 23:30:28 -0400 Subject: [PATCH] =?UTF-8?q?docs(plan):=20revise=20Phase=2011=20=E2=80=94?= =?UTF-8?q?=20ordinal,=20full=20stack=20retirement,=20shared=20cards,=20re?= =?UTF-8?q?lease-share,=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold Daniel's 2026-06-15 decisions into PLAN.md §11 and the product note: 4→7 commitments, six waves. Headline: the track ordinal already shipped in Phase 8, so commitment 5 is verify-and-consume, not a new migration. Queue half of §1.3 absorbed; preload stays deferred. --- PLAN.md | 42 +- .../phase-11-public-site-enhancements.md | 713 +++++++++++++----- 2 files changed, 560 insertions(+), 195 deletions(-) diff --git a/PLAN.md b/PLAN.md index 9e8bfca..d633fd9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -25,11 +25,19 @@ These were flagged during the audit but classified as feature work, not defect f ### 1.3 Preload / prefetch of the next track -- **What:** No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch. +> **Split as of 2026-06-15.** This item bundled two things: (a) a **queue model** ("a notion of next +> track") and (b) **preload/prefetch** (begin the next track's bytes during the current tail). The +> **queue half (a) is now absorbed into Phase 11** (commitment 7 — Daniel: "now is the natural time +> for that"; full spec in `product-notes/phase-11-public-site-enhancements.md §3c`). The **preload +> half (b) remains deferred here** and still gates crossfade (1.4) and gapless (1.5). The open +> question below — queue in `IPlayerService` vs. a separate orchestrator — is **answered in the +> Phase 11 spec** (strong steer: a separate `IQueueService` above the single-slot player; final call +> staff-engineer's at implementation). When Phase 11's queue lands, the preload below becomes "add a +> subscriber to the queue's already-known next track," not a fresh queue design. + +- **What (deferred — preload only):** No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch. - **Why it matters:** Prerequisite for both crossfade (1.4) and gapless (1.5). Also a perceived-latency win on its own — track-change feels instant when the bytes are already in flight. -- **Shape:** A second `HttpClient` request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged `StreamDecoder` instance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next. -- **Prerequisite:** Requires a notion of "next track" — today the player only knows the current one. That implies either a playlist/queue model in `IPlayerService` or a passive "what was the next row in the gallery" inference. -- **Open question:** Does a queue model belong in `IPlayerService`, or is the player a single-slot device that a future `PlaylistService` orchestrates above? Worth a design note before implementation. Capture in product notes when picked up. +- **Shape:** A second `HttpClient` request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged `StreamDecoder` instance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next. **The "next track" it prefetches comes from Phase 11's `IQueueService`** — that dependency is now satisfied by the queue work, not an open question. ### 1.4 Crossfade @@ -194,24 +202,28 @@ Full design, renderer architecture, the four effects, acceptance criteria, and p ## Phase 11 — Public Site Enhancements -The next pass over the public listening surface, after Phase 9 + Wave 8 moved the site to release-cardinal browse (`/archive`) and per-medium detail. The spine of the phase: **make the release the cardinal unit of every public navigation, and make every navigation an addressable, shareable URL.** Four Daniel commitments — two structural (a Cuts detail page; the release-title click resolves medium → dedicated detail page), two normalization/polish (retire the redundant `/tracks?album` view; encode Archive filters in the URL). Full design, framing corrections, wave decomposition, and the gap analysis: `product-notes/phase-11-public-site-enhancements.md`. +The next pass over the public listening surface, after Phase 9 + Wave 8 moved the site to release-cardinal browse (`/archive`) and per-medium detail. The spine of the phase: **make the release the cardinal unit of the public site, make every navigation an addressable shareable URL, and make the album a first-class playable object** (ordered, queue-able, shareable). **Seven Daniel commitments** (the original four plus three added 2026-06-15 when he resolved the open questions and expanded scope): (1) a Cuts detail page `/cuts/{id}`; (2) the player-bar release-title resolves medium → dedicated detail page; (3) retire the **whole** track-cardinal stack **and** normalize release-card rendering into shared components; (4) encode Archive filters in the URL; (5) explicit track ordinal editable from the CMS; (6) release-level Share; (7) a play-queue system (absorbs the queue half of §1.3). Full design, framing corrections, wave decomposition, gap analysis: `product-notes/phase-11-public-site-enhancements.md`. -**State it inherits (verified 2026-06-15).** `/sessions/{id}` and `/mixes/{id}` detail pages **exist and are mature** (both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes `ReleaseDetailScaffold`, `SessionDetail` deliberately diverges). `/archive` is **already** a release-cardinal searchable browser (search + medium + genre). The two real gaps: **Cuts have no single-release detail page** (`/cuts` cards open `/tracks?album={title}` — the track gallery filtered), and **`/archive` holds its filters in component fields, not the URL**. +**State it inherits (verified 2026-06-15).** `/sessions/{id}` and `/mixes/{id}` detail pages **exist and are mature** (both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes `ReleaseDetailScaffold`, `SessionDetail` deliberately diverges). `/archive` is **already** a release-cardinal searchable browser (search + medium + genre). `ReleaseGallery` is the shared release-card grid — but **only Sessions/Mixes use it**; Archive and Cuts re-implement equivalent card markup inline. The real gaps: **Cuts have no single-release detail page** (`/cuts` cards open `/tracks?album={title}`), and **`/archive` holds its filters in component fields, not the URL**. The queue/playlist **does not exist** (single-slot player). -**Three framing corrections (brief vocabulary vs. live routes).** (1) There is **no `/tracks/{id}` route** — the track-cardinal detail is `/track/{EntryKey}`, and the player-bar title links there. The brief's "`/tracks/{id}` becomes a router" is best realized as a **medium→route resolver** applied at click sites (the player bar already carries the release id + medium, so no round-trip is needed), plus a thin `/tracks/{id}` redirect page for bare-release-id deep links. (2) The new `/cuts/{id}` album page is the phase's center of gravity — the first **multi-track** release detail. (3) Requirement 4 is a **URL-binding pass over the existing `ArchiveView`**, borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern verbatim — not a new browser. +**Headline correction — commitment 5 is already built.** The brief framed the track ordinal as a new column + EF migration + a Daniel-gated apply step. **The read shows it already shipped in Phase 8:** `TrackEntity.TrackNumber` (1-based, non-null), migration `20260611005700` **already applied**, `TrackDto` mirror, API write path (validated `> 0`), CMS reorder (`BatchEdit` assigns ordinal from list position on submit), and the read already `.OrderBy(t => t.TrackNumber)`. **No new schema, no migration to gate.** Commitment 5 collapses to *verify-and-consume*: confirm the public read projects/sorts `TrackNumber` and that `CutDetailViewModel` orders by it (a one-line fix if not). See spec §3a. -**Design discipline.** The medium→route resolver is **one table** (promote `ArchiveView.DetailHref` to a shared `ReleaseRoutes` helper) consumed by the player bar, Archive cards, and Cuts cards — not three switches (memory *One source, multiple views*). The `/cuts/{id}` page composes `ReleaseDetailScaffold` via a generalized `Header` slot + new `BodyContent` slot for the track list — **not** a boolean layout flag (Phase 9 §5.3's named rule: layout variance rides a slot, never a flag). The Archive URL-binding makes the URL the source of truth (history-driven re-fetch in `OnParametersSet`), so back/forward + shareable links fall out structurally. +**Framing corrections (brief vocabulary vs. live routes).** (1) There is **no `/tracks/{id}` route** — the track-cardinal detail is `/track/{EntryKey}`. The brief's "`/tracks/{id}` becomes a router" is best realized as a **medium→route resolver** at click sites (the player bar already carries release id + medium — no round-trip), plus a thin `/tracks/{id}` redirect page for deep links. (2) The new `/cuts/{id}` album page is the phase's center of gravity — the first **multi-track** release detail. (3) Requirement 4 is a **URL-binding pass over the existing `ArchiveView`**, borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern — not a new browser. -Sequenced as four tracks across two parallel waves. +**Design discipline.** The medium→route resolver is **one table** (`ReleaseRoutes.DetailHref`) consumed by the player bar, Archive, and Cuts cards. The shared `ReleaseGallery` becomes the **one** release-card grid across all four browse surfaces (Archive/Cuts fold in via a new per-card `HrefResolver`), not three inline copies (memory *One source, multiple views*). The `/cuts/{id}` page composes `ReleaseDetailScaffold` via a generalized `Header` slot + a `BodyContent` slot for the track list — **not** a boolean layout flag (Phase 9 §5.3). The queue is a separate `IQueueService` orchestrating above the single-slot player (strong steer; final call staff-engineer's). Header Play binds to a single handler that swaps single-track → `QueueService.PlayRelease` with no page change (memory *Design for adaptability up front*). -- **11.A — `/cuts/{id}` album-detail page.** Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list with per-row play, header Play → track 1. New `CutDetailViewModel`; reuses `GetById` + the existing `releaseId`-filtered track page (both exist). **Load-bearing prerequisite for 11.B's Cut resolution.** -- **11.B — medium→route resolver + repoint.** Shared `ReleaseRoutes.DetailHref`; Cut resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title, Archive cards, `AlbumsView` cards; thin `/tracks/{id}` redirect page. **Depends on 11.A.** -- **11.C — normalization / reduction.** Retire the `/tracks?album` filter branch (safe once 11.B repoints both consumers). Decide with Daniel whether to retire the whole track-cardinal stack (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`) now that Cuts have a proper detail page and `/tracks` is already nav-demoted — the phase's biggest reduction lever. Consolidate duplicate medium-label lookups. **Depends on 11.B.** -- **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`. Fully independent — touches only `ArchiveView`. **No dependency; free-floating.** +Sequenced as **six waves**; the critical path is `11.A → 11.B → 11.C`, with 11.D / 11.E / 11.F hanging off it. -**Dependency shape:** `11.A → 11.B → 11.C`; **11.D parallel**. +- **11.A — `/cuts/{id}` album-detail page.** Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list (by `TrackNumber`) with per-row play, header Play. New `CutDetailViewModel`; reuses `GetById` + the `releaseId`-filtered track page (both exist). Ordinal is a **verification** (§3a), not a dependency. Header/row Play consume 11.F when present, else degrade to single-track (§3.4 seam). **Load-bearing prerequisite for 11.B's Cut resolution.** +- **11.B — `ReleaseRoutes` resolver + repoint.** Promote `ArchiveView.DetailHref` to a shared `ReleaseRoutes.DetailHref`; Cut resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title (→ release), Archive cards, `AlbumsView` cards; thin `/tracks/{id}` redirect page. **Depends on 11.A.** +- **11.C — retire + normalize (the heart).** With §2 removing every inbound link: **delete the whole track-cardinal stack** (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`/`GalleryViewMode` + `/tracks`, `/track/{EntryKey}` routes) **and** fold Archive + Cuts inline cards into the shared `ReleaseGallery` (new `HrefResolver`); consolidate the medium-label lookup. **Depends on 11.B.** (Cut track-row is a separate small `TrackRow`, not `ReleaseGallery`.) +- **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`, history-driven (§5). Touches only `ArchiveView`. **Free-floating — but coordinate with 11.C** (both edit `ArchiveView`). +- **11.E — release-level Share.** `SharePopover` gains a release mode that copies `ReleaseRoutes.DetailHref(release)`; wire the Cut header Share to it. **Depends on 11.B** (resolver) + a release detail to share. +- **11.F — queue model.** `IQueueService` above the single-slot player + one new player `TrackEnded` hook + player-bar skip controls. **Free-floating, can start cold day one.** Gates the Cuts "play album" affordance (11.A header Play). **Preload (§1.3 half b) stays OUT** — design the seam, defer the feature. -**Open questions (Daniel — in the spec §7):** player-bar title now points at release detail not track detail (recommend yes — it is the premise of req 1); `/cuts/{id}` scaffold strategy (generalized `Header` slot vs. bespoke page); Cut header affordance idiom (icon vs. labeled buttons); **track ordering** — `TrackEntity` has no `TrackNumber` column, so album order is insertion/`Id` order today (recommend ship on insertion order, raise `TrackNumber` as its own small phase only if needed — `[do not assume into Phase 11]`); `/tracks` retirement scope (album-branch only vs. whole track-cardinal stack). **Adjacent gaps surfaced, not in scope:** the Cuts album page is the strongest case yet for the deferred **play queue** (`§1.3`) — "play album" is the expected affordance; design header Play so a future enqueue-album is a handler swap, not a rewrite. `SharePopover` is track-keyed — a Cut header Share arguably shares the **release** URL, a new share target. +**Dependency shape:** `11.A → 11.B → 11.C`; `11.B → 11.E`; **11.D and 11.F parallel** (11.D coordinates with 11.C on `ArchiveView`; 11.F's "play album" is consumed by 11.A). The two cold-start items are **11.A** and **11.F** — kick both off first so "play album" works on first ship of the Cut page. + +**Resolved by Daniel (2026-06-15), kept visible per file convention:** player-bar title → release detail (was OQ1); track ordinal in scope **and already built** (was OQ4, reversed then found done); retire the **whole** track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred). **Still open (spec §7.2):** `/cuts/{id}` scaffold strategy (generalized `Header` slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate `IQueueService` — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); `/genres` fate (out of scope, flag as adjacent). --- diff --git a/product-notes/phase-11-public-site-enhancements.md b/product-notes/phase-11-public-site-enhancements.md index 1f6d6d9..25fd393 100644 --- a/product-notes/phase-11-public-site-enhancements.md +++ b/product-notes/phase-11-public-site-enhancements.md @@ -1,13 +1,14 @@ # Phase 11 — Public Site Enhancements -Status: spec / design. Author: product-designer. Date: 2026-06-15. -**Plan only — no code edits made by this doc.** +Status: spec / design. Author: product-designer. Date: 2026-06-15 (revised same day after Daniel +resolved the open questions and expanded scope). **Plan only — no code edits made by this doc.** -Cross-references: `PLAN.md §11` (the concise phase entry), `product-notes/phase-9-release-medium-types.md` -(the medium model — `ReleaseMedium` enum, the per-medium detail-page strategy, the -`ReleaseDetailScaffold` contract), `COMPLETED.md §9.8` (Wave 8 remediation — the `/archive` -release-cardinal browser, the inline nav, the `TracksView` demotion), memory *One source, multiple -views* and *Design for adaptability up front*. +Cross-references: `PLAN.md §11` (the concise phase entry), `PLAN.md §1.3` (preload/queue — now +**absorbed into this phase**), `product-notes/phase-9-release-medium-types.md` (the medium model — +`ReleaseMedium` enum, the per-medium detail-page strategy, the `ReleaseDetailScaffold` contract), +`COMPLETED.md §9.8` (Wave 8 remediation — the `/archive` release-cardinal browser, the inline nav, +the `TracksView` demotion), memory *One source, multiple views* and *Design for adaptability up +front*. --- @@ -19,14 +20,42 @@ the nav flattened to ARCHIVE + Cuts/Sessions/Mixes, and the track-cardinal `/tra demoted from the nav (route kept). That work landed and is stable on dev (2026-06-13/14). Phase 11 is the **next coherent pass over the public listening surface**. Daniel's hands-on use -surfaced four commitments. They share one spine: **make the release the cardinal unit of the public -site, and make every navigation an addressable, shareable URL.** Two of the four are structural -(a Cuts detail page; `/tracks` collapses to a router); two are normalization/polish (retire the -`/tracks?album` view; encode Archive filters in the URL). +surfaced an initial four commitments; on 2026-06-15 he resolved the open questions and expanded the +scope to **seven**. They share one spine: **make the release the cardinal unit of the public site, +make every navigation an addressable, shareable URL, and make the album a first-class playable +object** (ordered, queue-able, shareable). The seven: -This is not a greenfield phase — most of the scaffolding it needs already exists. The job is -**closing the one asymmetry Phase 9 left** (Cuts are the only medium with no single-release detail -page) and **finishing the URL-addressability story** the Archive browser started. +1. **Cuts detail page** (`/cuts/{id}`) — structural; the phase's center of gravity. +2. **Player-bar release-title → release detail** via a medium→route resolver — structural. +3. **Retire the whole track-cardinal stack** *and* **normalize release-card rendering into shared + components** — reduction + normalization (the heart of 11.C). +4. **Archive filters in the URL** — addressability polish. +5. **Explicit track-ordinal column** on the track model, editable from the CMS — **new scope**; + data-model + migration; gates correct `/cuts/{id}` ordering. +6. **Release-level Share** — **new scope**; a Cut/Session/Mix header Share shares the release URL. +7. **Play-queue system** — **new scope**; absorbs the deferred `PLAN.md §1.3`. The Cuts "play album" + affordance is its first consumer. + +This is not a greenfield phase — most of the scaffolding it needs already exists (the medium browse +pages already share a `ReleaseGallery` card component; the detail pages already share +`ReleaseDetailScaffold`; the Archive already has all filter state). The structural new work is the +**Cuts detail page** and the **queue model** (the one real architecture decision). Everything else +is closing asymmetries and consolidating rendering that drifted into per-surface copies. + +> **Headline correction on commitment 5 (read against live source, 2026-06-15).** Daniel asked for +> "an explicit ordinal column, editable from the CMS, with a Daniel-gated migration." **That column +> already exists and shipped in Phase 8.** `TrackEntity.TrackNumber` is an explicit 1-based, non-null +> ordinal (default 1) — *not* insertion-order — with its migration (`20260611005700`) **already +> applied**, its DTO mirror, its API write path (validated `> 0`), and CMS reorder +> (`BatchEdit` assigns ordinal from list position on submit). The read path already sorts on it +> (`ReleaseRepository.OrderBy(t => t.TrackNumber)`). **So commitment 5 carries no new column, no new +> migration, and no Daniel-gated apply step.** What remains is *verify-and-consume*: confirm +> `TrackDto.TrackNumber` is populated on the public read path the Cut page uses, and ensure +> `CutDetailViewModel` orders by it. The reorder-UX question Daniel asked me to surface is also +> already answered in the CMS — `BatchEdit` reorders by list position, not a numeric field. This +> reframing is the most important single change in this revision; §3a carries the detail. If hands-on +> use reveals a gap (e.g. the *public* track read does not project `TrackNumber`), that is a small +> wiring fix, not the schema project the brief anticipated. ### What already exists (verified against live source, 2026-06-15) @@ -36,10 +65,15 @@ page) and **finishing the URL-addressability story** the Archive browser started | `/mixes/{id}` detail | **Exists, mature.** Full-page WebGL waveform background + scaffold; bridged prerender. | `Pages/MixDetail.razor` | | `ReleaseDetailScaffold` | **Exists.** Invariant trio (back link, masthead, play/share) + `Hero`/`MetaContent` slots. Composed by `TrackDetail` and `MixDetail`. | `Controls/ReleaseDetailScaffold.razor` | | `ReleaseDetailBase` | **Exists.** Shared load + prerender-bridge for single-release detail pages (id-addressed, resolves the playable track via `releaseId`-filtered track page). | `Pages/ReleaseDetailBase.cs` | -| `/cuts` gallery | **Exists** as `AlbumsView` (medium-parameterized card grid). Cards open `/tracks?album={title}`. | `Pages/AlbumsView.razor` | -| `/archive` browser | **Exists, release-cardinal.** Debounced search + medium chips + genre filter. Cards route per-medium. **Filters held in component fields, NOT in the URL.** | `Pages/ArchiveView.razor(.cs)` | +| `/cuts` gallery | **Exists** as `AlbumsView` (medium-parameterized card grid). Cards open `/tracks?album={title}`. **Renders its card markup inline (`album-card`), does NOT use `ReleaseGallery`.** | `Pages/AlbumsView.razor` | +| `/sessions`, `/mixes` galleries | **Exist** as `SessionsView`/`MixesView`, both inheriting `MediumBrowseBase` and **both composing the shared `ReleaseGallery` card grid** (with a `DetailRoute` param). | `Pages/{Sessions,Mixes}View.razor` | +| `ReleaseGallery` | **Exists.** The shared release-card grid — cover (+ `--fallback`), title, artist. Cards link ``. **Consumed only by Sessions/Mixes today**; Archive and Cuts re-implement equivalent markup inline. | `Controls/ReleaseGallery.razor` | +| `/archive` browser | **Exists, release-cardinal.** Debounced search + medium chips + genre filter. Cards route per-medium via a private `DetailHref` switch. **Renders card markup inline (`archive-release-card`), does NOT use `ReleaseGallery`. Filters held in component fields, NOT in the URL.** | `Pages/ArchiveView.razor(.cs)` | | `/track/{EntryKey}` detail | **Exists** as `TrackDetail` — the *track-cardinal* detail the player-bar title links to. | `Pages/TrackDetail.razor` | | `/albums` | **Exists** as a permanent redirect → `/cuts`. | `Pages/AlbumsRedirect.razor` | +| `SharePopover` | **Exists, track-keyed.** Takes an `EntryKey`; "Copy link" + "Embed player". No release-level share target. | `Controls/SharePopover.razor` | +| Play queue / playlist | **Does not exist.** Player is single-slot (`StreamingAudioPlayerService` holds one `CurrentTrack`). No notion of "next." `PLAN.md §1.3` (preload + queue) is deferred — **now absorbed here**. | — | +| `TrackEntity` ordinal | **ALREADY EXISTS — landed in Phase 8.** `TrackEntity.TrackNumber` (int, 1-based, default 1, non-null), column `track_number`, migration `20260611005700_AddReleaseTypeAndTrackNumber` **already applied**. `TrackDto.TrackNumber` mirrors it; `TrackConverter` round-trips it; `UpdateTrackMetadataRequest.TrackNumber` + `TrackController` validate (`> 0`) and persist it; `BatchEdit` already sets it from reorderable list position on submit; `ReleaseRepository.GetTracks` already `.OrderBy(t => t.TrackNumber)`. **Commitment 5 is not new schema — it is verify-and-consume.** | `TrackEntity.cs:17`, `TrackConfiguration.cs:37`, `BatchEdit.razor:192/225` | ### Three framing corrections (the brief's vocabulary vs. the live routes) @@ -69,26 +103,55 @@ these up front so the implementer is not misled: --- -## 1. The four commitments (Daniel, faithful capture) +## 1. The seven commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in) -1. **`/tracks/{id}` becomes a pure router** → resolve a release's `ReleaseMedium` to the correct - dedicated detail page (`/cuts/{id}` | `/sessions/{id}` | `/mixes/{id}`). Hit only when a user - clicks the release title in the player bar; its sole job is medium → page resolution. +The original four (1–4) plus three Daniel added when he resolved the open questions (5–7). + +1. **Player-bar release-title → release detail, via a medium→route resolver.** **DECIDED + (2026-06-15):** the release-title click resolves the release's `ReleaseMedium` → the correct + dedicated detail page (`/cuts/{id}` | `/sessions/{id}` | `/mixes/{id}`). Realized as a resolver + helper at click sites (the player bar already carries release id + medium), plus a thin + `/tracks/{id}` redirect page for bare-release-id deep links. (Was OQ1; resolved — see §2.) 2. **New `/cuts/{id}` page — album view.** - Header section, **left-aligned**: release name, artist, genre, release year, plus **Play** and **Share** buttons. - Cover art **large-ish, right**, with a **theme border** around the image. - - Below: an **ordered track list**, each row with a play button. - - Header Play starts the **first track**. + - Below: an **ordered track list** (ordered by the new ordinal column — commitment 5), each row + with a play button. + - Header Play **enqueues the whole album in ordinal order** (commitment 7) and starts track 1. -3. **Code normalization / reduction.** Eliminate the now-redundant `/tracks?album` page view (the - Archive provides all filtering/searching, so `/tracks` collapses to a pure router). Identify and - normalize other UI redundancy. +3. **Retire the whole track-cardinal stack + normalize release-card rendering.** **DECIDED + (2026-06-15):** retire `TracksView` / `TrackDetail` / `TrackCard` / `TracksGallery` and the + `/tracks` + `/track/{EntryKey}` routes entirely (not just the `?album` branch). **And** normalize + release-card presentation into shared component(s) consumed across `/archive`, `/cuts`, + `/sessions`, `/mixes` (and the Cuts detail track-row where applicable). The shared + `ReleaseGallery` already exists but only Sessions/Mixes use it — Archive and Cuts re-implement + equivalent markup inline. This is the normalization heart of 11.C. (Was OQ5; resolved — see §4.) 4. **Archive search params in the URL.** Search term, medium filter, genre filter all encoded in `/archive?…` so filter actions create navigable history anchors (back/forward, shareable links). +5. **Explicit track-ordinal column, editable from the CMS.** **IN SCOPE (2026-06-15) — but + ALREADY SATISFIED by Phase 8.** Daniel asked for an explicit ordinal (NOT insertion-order), + editable from the CMS. The column (`TrackEntity.TrackNumber`, 1-based, non-null), its migration + (already applied), DTO mirror, API write path, and CMS reorder (`BatchEdit`) **all already exist**. + No new column, no new migration, no Daniel-gated apply step. Remaining work is *verify-and-consume*: + confirm the public read path projects `TrackNumber` and that `CutDetailViewModel` orders by it. + (Was OQ4 "do not assume into Phase 11"; Daniel reversed to in-scope — and the read confirms it is + already built. See §3a.) + +6. **Release-level Share.** **NEW SCOPE (2026-06-15):** a Cut/Session/Mix header Share shares the + *release* URL, not a single track's embed. `SharePopover` is track-keyed today; add a + release-level share target. (Was an adjacent gap; promoted to scope. See §3b.) + +7. **Play-queue system.** **NEW SCOPE (2026-06-15):** Daniel — "now is the natural time for that." + Absorbs the deferred `PLAN.md §1.3` (preload/queue). A queue model the player consumes; the Cuts + "play album" affordance (header Play → enqueue the release's tracks in ordinal order) is the first + consumer. Carries an unresolved architecture question (queue inside `IPlayerService` vs. a + separate orchestrating service) — framed with a recommendation in §3c; the final call is + staff-engineer's at implementation. (Was an adjacent gap; promoted to scope. See §3c.) + --- ## 2. Requirement 1 reframed — the medium→detail resolver @@ -133,12 +196,13 @@ reuses it.** This satisfies the brief's literal "`/tracks/{id}` is a pure router the common case (player-bar click, where the medium is already in hand) a zero-round-trip path. The redirect page is ~15 lines and shares the resolver, so it is not a second source of truth. -> **Open question (Daniel):** the player-bar title currently links to **`/track/{EntryKey}`** (the -> track-cardinal `TrackDetail`). The brief says the title click should resolve medium → dedicated -> page. **Confirm the player-bar title should now point at the release detail (via the resolver), -> not the track detail.** If yes, `TrackMetaLabel`'s `` changes from `/track/{EntryKey}` to -> `ReleaseRoutes.DetailHref(Track.Release)` — and `TrackDetail`'s role shrinks (see §4, the -> normalization question on whether `TrackDetail` survives at all). +> **DECIDED (Daniel, 2026-06-15):** the player-bar title click now points at the **release detail** +> (via the resolver), **not** the track detail. `TrackMetaLabel`'s `` changes from +> `/track/{Track.EntryKey}` to `ReleaseRoutes.DetailHref(Track.Release)`. Implementation note: the +> link today sits on the **track name** (`TrackMetaLabel.razor` line 9). With the repoint it +> resolves to the release page; the artist/genre/year already render from `Track.Release`. This also +> removes `TrackDetail`'s last inbound link — with commitment 3 retiring the whole track-cardinal +> stack, `TrackDetail` and `/track/{EntryKey}` are deleted outright (§4). ### What "resolver" means for Cut @@ -239,12 +303,12 @@ The Cut page needs the release **and its ordered tracks**. Both primitives exist the single track for Session/Mix. The Cut page issues the same call with a **larger page size** (cover the whole album — `pageSize: 100` matches the gallery convention) to get the ordered list. -**Ordering.** The track list must render "in order." Verify the track page's default sort yields a -stable, meaningful order for an album (track-number if it exists; otherwise insertion/`Id` order). -*Open question:* does `TrackEntity` carry a track-number / ordinal field? From the current schema it -does **not** (`Id, EntryKey, TrackName, Artist, Album, Genre, ReleaseDate, ImagePath`). If album -track order matters beyond insertion order, that is a **data-model gap** (a `TrackNumber` column) — -flagged in §7 as a Daniel decision, *not* assumed into this phase. +**Ordering. DECIDED (2026-06-15): order by the new explicit ordinal column** (commitment 5, §3a), +not insertion order. The Cut track list reads tracks for the release sorted ascending by ordinal. +This makes the ordinal column a **hard dependency of correct `/cuts/{id}` ordering** — 11.A's track +list cannot render in the right order until the column exists and the read sorts on it. See §3a for +the column's full cross-stack spec and §6 for the wave dependency (the ordinal work gates 11.A's +ordering, so it sequences first). **New `CutDetailViewModel` vs. extend `ReleaseDetailViewModel`.** `ReleaseDetailViewModel` resolves *one* track. The Cut page needs *many*. Two options: @@ -263,77 +327,306 @@ medium conditional into the base. Flag the choice; don't pre-commit the implemen ### 3.4 Play wiring - **Row play:** stream the row's track (the `TracksView.PlayTrack` idiom — toggle if already current, - else `SelectTrackStreaming`). -- **Header Play:** stream **track 1** (`Tracks.FirstOrDefault()`). If a queue/playlist model existed - (`PLAN.md §1.3`), header Play would enqueue the whole album — **it does not today**, so header Play - starts track 1 and the rest is manual. *Adjacent opportunity, not in scope* — see §7. + else `SelectTrackStreaming`). Row play **also sets the queue context** to the album from that row + forward (so the queue continues into the rest of the album after a mid-album row start) — see §3c + for the enqueue semantics. If the queue work lands after 11.A's first cut, row play degrades + cleanly to single-track streaming (the `Design for adaptability up front` seam). +- **Header Play: enqueue the whole album in ordinal order, start track 1.** **DECIDED (2026-06-15):** + this is the first consumer of the queue system (commitment 7, §3c). Header Play calls the queue's + `PlayRelease(tracks-in-ordinal-order)` rather than a bare `SelectTrackStreaming(track1)`. Because + 11.A and the queue (11.F) may land in either order, **design header Play as a single handler call + that swaps from `SelectTrackStreaming(Tracks.First())` to `Queue.PlayRelease(Tracks)` with no + other change to the page** — the seam Phase 11's earlier draft already anticipated, now made real + by bringing the queue into scope. --- -## 4. Requirement 3 — normalization and reduction +## 3a. Commitment 5 — the track-ordinal column (already built; verify-and-consume) -The brief: eliminate the `/tracks?album` page view; `/tracks` collapses to a pure router; identify -other redundancy. +**Recommendation: do not spec a schema project. Verify the existing `TrackNumber` reaches the public +Cut read, and consume it.** The brief asked to "spec the implications across the stack" for a new +ordinal column. The honest answer from the read is that the stack is already wired end to end; the +only open risk is the *public* read projection. -### 4.1 The `/tracks?album` retirement is gated on §2 and §3 +### 3a.1 What already exists (each verified, 2026-06-15) -`/tracks?album={title}` has **two live consumers** today: +| Layer | State | Evidence | +|---|---|---| +| Entity | `TrackEntity.TrackNumber` — `int`, 1-based, non-null, default 1. Explicit ordinal, **not** insertion-order. | `TrackEntity.cs:17` | +| EF mapping | `track_number` column, configured. | `TrackConfiguration.cs:37` | +| Migration | `20260611005700_AddReleaseTypeAndTrackNumber` — **already applied** (it is in the snapshot and predates current `dev`). | `Migrations/` | +| DTO | `TrackDto.TrackNumber` mirrors it; `TrackConverter` maps both directions. | `TrackDto.cs:18`, `TrackConverter.cs:75/91` | +| API write | `UpdateTrackMetadataRequest.TrackNumber` (`int?`); `TrackController` validates `> 0` (400 otherwise), persists to the track row. Upload path resolves to 1 when unset. | `TrackController.cs:263/395-403` | +| CMS edit/reorder | `BatchEdit` loads tracks `sortColumn: "TrackNumber"`, holds them as a reorderable row list (`BatchRowModel.TrackNumber`), and **assigns each row its ordinal from list position on submit**. | `BatchEdit.razor:192/225` | +| Read sort (release tracks) | `ReleaseRepository.GetTracks` already `.OrderBy(t => t.TrackNumber)`. `TrackManager` sort switch includes `"TrackNumber"`. | `ReleaseRepository.cs:117`, `TrackManager.cs:122` | -- `AlbumsView` (`/cuts`) cards → `OpenAlbum` → `/tracks?album={title}` (line 62). -- `ArchiveView` Cut cards → `DetailHref` → `/tracks?album={title}` (line 125). +### 3a.2 The reorder-UX question Daniel asked me to surface — already answered -Both must be **repointed to `/cuts/{id}`** before the `?album=` view can be retired. So the ordering -is: §3 (`/cuts/{id}` exists) → §2 (resolver returns `/cuts/{Id}` for Cut) → §4.1 (repoint both -consumers) → **then** the `?album=` filter branch in `TracksView` can be removed. +Daniel asked: drag-reorder vs. numeric field, recommend the simplest shippable. **It is already +shipped, and it is neither a bare numeric field nor drag-and-drop — it is list-position ordinal +assignment:** `BatchEdit` presents the release's tracks as an ordered row list and writes +`TrackNumber = position` on submit. Editing order = reordering rows. That is the simplest correct +model (the ordinal is derived, never hand-typed, so it cannot collide or skip). **No change wanted +here** unless Daniel specifically wants drag affordance polish in the CMS — which is out of Phase 11 +(CMS, not the public site) and should be raised separately if desired. -> **Scope subtlety.** "Eliminate the `/tracks?album` page view" means remove the **album-filtered -> *mode*** of the track gallery, not necessarily the whole `/tracks` route. `TracksView` also -> supports `?genre=` and `?q=` and a plain unfiltered gallery. Decide (Daniel) whether: -> - **(A)** remove only the `album` query branch (keep `/tracks` reachable with genre/search), or -> - **(B)** retire `TracksView` **entirely** — the Archive (§release-cardinal search) subsumes it, -> and Phase 9 §8.I already demoted `/tracks` from the nav. Cuts now have a proper detail page; -> the track-cardinal gallery may have no remaining job. -> -> **Recommend (B) as the eventual target, (A) as the safe first step.** If `/cuts/{id}` + `/archive` -> cover every browse/play path, `TracksView`, `TrackDetail`, `TrackCard`, `TracksGallery`, and -> `GalleryViewMode` become removable surface — a meaningful reduction. But that is a bigger cut than -> the brief literally asks; confirm before deleting. See §4.3. +### 3a.3 The one real verify step — the *public* read projection -### 4.2 What `TrackDetail` becomes +The CMS read (`CmsTrackService.GetPagedAsync`) sorts and carries `TrackNumber`. The Cut page uses the +**public** track-data service's `releaseId`-filtered page. The single concrete task for 11.A is to +**confirm that public read both sorts by `TrackNumber` and projects it onto `TrackDto`**, so +`CutDetailViewModel` renders rows in saved order and can label the track number. If the public read +already orders by `TrackNumber` (likely — it shares `TrackManager`'s sort switch), 11.A's ordering is +free. If it does not, the fix is a one-line sort argument, not a migration. **This is why the "ordinal +gates `/cuts/{id}` ordering" dependency in §6 collapses to a verification, not a wave.** -If §2's resolver repoints the player-bar title to the **release** detail (the recommended -direction), `TrackDetail` (`/track/{EntryKey}`) loses its only inbound link from the player bar. -`TrackCard` still links to it (`/track/{EntryKey}`). If `TracksView`/`TrackCard` are retired (4.1 -option B), `TrackDetail` has **no inbound links** and is dead surface — retire it too. If `/tracks` -survives (option A), `TrackDetail` stays as the track-cardinal detail. +> **Net effect on the waves:** commitment 5 does **not** become its own wave. It folds into 11.A as a +> verification checklist item ("public read projects + sorts `TrackNumber`"). The brief's worry about +> a Daniel-gated migration does not apply — the migration already ran. -**This is the cleanest reduction lever in the phase:** the track-cardinal stack (`TracksView`, -`TrackDetail`, `TrackCard`, `TracksGallery`) exists from the pre-medium era. Phase 9 made the release -cardinal; Phase 11's Cuts page removes the last reason a Cut needed the track gallery. Whether to -pull the whole stack is a Daniel call (§7), but the phase should **name the dead surface explicitly** -so the reduction is deliberate, not accidental. +--- -### 4.3 Candidate redundancy inventory (for the normalization pass) +## 3b. Commitment 6 — release-level Share -Surfaced from the read; each is a *candidate*, not a commitment: +**Recommendation: add a release-keyed mode to `SharePopover`, sharing the resolved release URL; keep +the track-keyed mode for any surface that still shares a single track.** This is an additive +extension of an existing control, not a new control. -1. **`/tracks?album` filter branch** — retire (gated, §4.1). **High confidence.** -2. **`TracksView` + `TrackCard` + `TracksGallery` + `GalleryViewMode`** — track-cardinal gallery - stack. Removable iff Cuts page + Archive cover all paths (§4.1 B). **Daniel decision.** -3. **`TrackDetail` (`/track/{EntryKey}`)** — removable iff its inbound links go (§4.2). **Follows 2.** -4. **`GenresView` (`/genres`)** — already demoted from nav (§8.I), route kept. The Archive has a - genre filter. Is `/genres` still needed? Probably retire-able, but **out of this phase's stated - scope** — flag as adjacent. **Daniel decision, low urgency.** -5. **`AlbumsView` `OpenAlbum`** — repointed from `/tracks?album` to `/cuts/{id}` (§4.1). **Part of - the work, not removable.** -6. **`ArchiveView.DetailHref` + `ArchiveView.MediumLabel`** — `DetailHref` promotes to the shared - `ReleaseRoutes` resolver (§2). `MediumLabel` is a label lookup; check whether it duplicates the - CMS `MediumTypeLabels` (§8.D) or the Archive's own medium-chip labels — if the public side has two - medium-label lookups, **consolidate to one**. **Medium confidence.** +### 3b.1 What `SharePopover` does today -> The phase should land **(1)** for sure, **decide (2)+(3) as a pair** with Daniel, and treat -> **(4)+(6)** as opportunistic tidy-ups. Do not let the reduction sprawl — normalize what the four -> commitments *touch*, surface the rest as adjacent. +`SharePopover` takes an `EntryKey` (a track) and offers "Copy link" + "Embed player." Both targets +are track-scoped. A Cut/Session/Mix header has no way to share *the release page* — only a track's +embed. After §2 every release has a canonical detail URL (`ReleaseRoutes.DetailHref`), so a +release-level share target now has a well-defined thing to copy. + +### 3b.2 Three shapes + +- **(i) New `ReleaseSharePopover` control.** Clean separation, but duplicates the popover chrome, + copy-to-clipboard plumbing, and styling. Two controls to keep in visual sync. +- **(ii) `SharePopover` gains a release mode (RECOMMENDED).** Add an optional release target + (`ReleaseDto` or `(long id, ReleaseMedium medium)`) alongside the existing `EntryKey`. When the + release target is set, "Copy link" copies `ReleaseRoutes.DetailHref(release)` (absolute URL); the + "Embed player" affordance is hidden or repurposed (a release page is not a single-track embed — + see the open sub-question below). One control, two modes, one source of clipboard/chrome logic — + the *One source, multiple views* discipline applied to share. +- **(iii) Make `SharePopover` polymorphic over a share-target abstraction** (`IShareTarget` with + `Title`, `Url`, `EmbedMarkup?`). Most general; over-built for two cases today. Note as the shape to + reach for **only if** a third share target appears (e.g. a playlist/queue share once §3c lands). + +**Recommend (ii)** now, with (iii) noted as the refactor target if a third target appears. The Cut +header's Share button (the labeled-button-vs-icon question in §3.2.2 applies here too) opens the +popover in release mode. + +### 3b.3 Open sub-question (surface, recommend) + +- **Does release-share keep an "Embed player" option?** A release page is multi-track (Cut) or a + single hero track (Session/Mix). For Cuts there is no single embeddable track, so "Embed player" + should be hidden in release mode (copy-link only). For Session/Mix the release *is* effectively one + track, so embed could still make sense — but to keep the contract simple, **recommend release mode + is copy-link-only across all media** and the per-track embed stays available only where a track is + the share subject. Flag for Daniel; trivial either way. + +--- + +## 3c. Commitment 7 — the play-queue system (absorbs `PLAN.md §1.3`) + +This is the phase's **one real architecture decision** and Daniel has explicitly asked for a strong +steer while leaving the final call to staff-engineer. The brief's framing — "queue inside +`IPlayerService` vs. a separate orchestrating service" — is the right axis. Recommendation first, +then the two shapes, then the seams to the existing player and the §1.3 preload relationship. + +**Recommendation: a separate `IQueueService` that orchestrates *above* the single-slot +`StreamingAudioPlayerService`. The player stays a single-slot device; the queue owns "what plays +next" and drives the player via its existing select-and-stream API.** This is the cleaner separation +and it matches the deferred §1.3's own framing ("the player is a single-slot device that a future +`PlaylistService` orchestrates above"). + +### 3c.1 The two shapes + +- **(i) Queue inside `IPlayerService` (single-slot player gains a queue).** The player holds + `CurrentTrack` *and* a `Queue` and a position pointer; `Next`/`Previous`/`enqueue` are + player methods. *Pro:* one service, no coordination; the player already owns the end-of-stream + event that would advance the queue. *Con:* the player's responsibility balloons from "stream one + track" to "manage a playlist," which is the SRP smell — and it forecloses the case where the queue + outlives a player instance or where a non-audio surface (a visible up-next list) wants the queue + state without coupling to the streaming device. + +- **(ii) Separate `IQueueService` orchestrating above the player (RECOMMENDED).** `QueueService` + owns the ordered list, the current index, and `PlayRelease` / `Enqueue` / `Next` / `Previous` / + `Clear`. It drives playback by calling the player's existing `SelectTrackStreaming(track)`. It + subscribes to the player's end-of-stream / track-ended signal to auto-advance. *Pro:* the player + stays single-purpose (the load-bearing streaming seam in `CLAUDE.md` is untouched); the queue is + independently testable and independently observable (the player bar and a future up-next panel both + read `QueueService` state); it is the natural home for the §1.3 preload trigger (the queue knows the + next track, so it owns the "prefetch next at threshold" decision). *Con:* a coordination seam — the + queue must observe the player's track-ended event reliably (the player must expose one; verify the + current `OnTrackEnded`/equivalent hook exists or spec adding it). + +### 3c.2 The player seam (what the queue needs from the player) + +The queue orchestrator needs three things from `StreamingAudioPlayerService`, all of which are either +present or a small addition: + +1. **A way to start a track** — `SelectTrackStreaming(track)` exists (the Cut row-play and + `TracksView` already call it). The queue calls the same path; **no new player surface.** +2. **A track-ended signal** — the queue auto-advances on natural end-of-stream. Verify the player + raises a track-ended/playback-complete event today; if it only exposes position/state, **the one + genuine player addition is a `TrackEnded` event** (or the queue polls "state == ended"). Spec this + as the single new player-side hook. +3. **State for the player bar** — "is there a next?", "is there a previous?" so the bar can enable + skip-forward / skip-back. The bar reads `QueueService.HasNext` / `HasPrevious`. + +### 3c.3 Queue data model (minimal, shippable) + +``` +QueueService + IReadOnlyList Items // ordered; the release's tracks for "play album" + int CurrentIndex // -1 when empty + TrackDto? Current => Items[CurrentIndex] + bool HasNext / HasPrevious + PlayRelease(IEnumerable tracks, int startIndex = 0) // Cuts header Play / row Play + Enqueue(TrackDto) / EnqueueRange(IEnumerable) + Next() / Previous() // advance index, drive player.SelectTrackStreaming + Clear() + event Action QueueChanged // player bar re-renders skip affordances +``` + +`PlayRelease(tracks)` is the Cuts "play album" entry point (commitment 2's header Play): pass the +release's tracks **in ordinal order** (already sorted, §3a), `startIndex: 0`. Row play is +`PlayRelease(tracks, startIndex: clickedRow)` — start mid-album, queue continues to the end (§3.4). + +### 3c.4 Player-bar UI + +The player bar today shows the current track + transport. The queue adds **skip-forward** and +**skip-back** controls (enabled per `HasNext`/`HasPrevious`), wired to `QueueService.Next/Previous`. +Optional, deferred-within-phase: a visible **up-next list** (the queue's `Items` past `CurrentIndex`). +Recommend skip controls in scope; the up-next panel as a `[speculative]` follow-on so the queue +work ships without waiting on a new UI surface. + +### 3c.5 Relationship to §1.3 preload — IN or OUT? + +`PLAN.md §1.3` is two things bundled: (a) a **queue model** ("a notion of next track") and (b) +**preload/prefetch** (begin the next track's bytes during the current track's tail). Commitment 7 +brings **(a) the queue model fully into Phase 11**. **Recommend (b) preload stays OUT of Phase 11** — +ship the queue (correct next-track semantics, skip controls, play-album) first; preload is a +*perceived-latency optimization on top of* a working queue, and it is the prerequisite for crossfade +(§1.4) and gapless (§1.5), which are their own later work. Bringing preload in now couples the queue +ship to a staged-decoder change in the streaming path (the most load-bearing seam in the system). + +**Design the seam, defer the feature** (memory *Design for adaptability up front*): `QueueService` +knows the next track, so it is the natural owner of a future "prefetch next at threshold" trigger. +Spec the queue so that adding preload later is *adding a subscriber to the queue's "next is known" +state*, not restructuring the queue. Note in `PLAN.md §1.3` that the **queue half is absorbed into +Phase 11; the preload half remains deferred** there (and gates 1.4/1.5 as before). + +--- + +## 4. Requirement 3 — full stack retirement + shared release-card normalization + +**DECIDED (2026-06-15):** retire the **whole** track-cardinal stack (not just the `?album` branch), +*and* normalize release-card rendering into shared component(s) consumed across `/archive`, `/cuts`, +`/sessions`, `/mixes`. This is two moves — a **reduction** (delete dead surface) and a +**normalization** (collapse duplicated card markup into one component). The normalization is the +heart of 11.C; the reduction is its precondition cleanup. + +### 4.1 The reduction — retire the track-cardinal stack (DECIDED, no longer a Daniel question) + +Daniel confirmed the full retirement. The surface that goes: + +| Surface | Route | Why it is now dead | +|---|---|---| +| `TracksView` | `/tracks` (+ `?album`/`?genre`/`?q`) | Archive subsumes browse/search; `/cuts/{id}` subsumes album view; nav-demoted since Phase 9 §8.I. | +| `TrackDetail` | `/track/{EntryKey}` | Loses its last inbound link once §2 repoints the player-bar title to the release. | +| `TrackCard` | — | Only consumer is `TracksGallery`, which only `TracksView` uses. | +| `TracksGallery` | — | Only consumer is `TracksView`. | +| `GalleryViewMode` | — | Only the `TracksView` filter-mode enum. | + +**Ordering of the retirement (the dependency chain):** + +1. §3 — `/cuts/{id}` exists. +2. §2 — `ReleaseRoutes` resolves Cut → `/cuts/{id}`; the resolver repoints the player-bar title to + the release (removing `TrackDetail`'s inbound link) and repoints `AlbumsView` + Archive Cut cards + off `/tracks?album`. +3. §4.1 — with no inbound links remaining, delete the whole stack above. + +So the retirement is **gated on §2 and §3 landing first** — you cannot delete `TrackDetail` while the +player bar still links to it, and you cannot delete the `?album` branch while `AlbumsView`/Archive +still point at it. This sequencing is honored in §6. + +> **One inbound-link audit before deletion (verify, don't assume):** grep for every `href`/`NavigateTo` +> targeting `/tracks` and `/track/`. The known ones are the player-bar title (`TrackMetaLabel`), +> `AlbumsView.OpenAlbum`, and `ArchiveView.DetailHref` — all repointed by §2. The `/albums → /cuts` +> redirect (`AlbumsRedirect`) stays. The CMS `/tracks/{id}/edit` family is a **different route tree** +> (`DeepDrftManager`) and is untouched — do not confuse the public `/tracks` gallery with the CMS +> track-edit routes. + +### 4.2 The normalization — one shared release-card component (the heart of 11.C) + +**Recommendation: make `ReleaseGallery` the single release-card grid, and give its card a per-card +href resolver so it can serve Archive's per-medium routing. Repoint `/archive` and `/cuts` onto it; +delete the inline `archive-release-card` and `album-card` markup.** This is the *One source, multiple +views* discipline (memory) applied to card rendering. + +#### 4.2.1 What's redundant today (audited against live source, 2026-06-15) + +| Surface | Card rendering | Verdict | +|---|---|---| +| `/sessions`, `/mixes` (`SessionsView`/`MixesView`) | Compose **`ReleaseGallery`** with a fixed `DetailRoute`. | **Canonical.** Already shared. | +| `/archive` (`ArchiveView`) | Inline `archive-release-card` markup — cover (+`--fallback`), title, artist. **Byte-for-byte the same structure as `ReleaseGallery`'s `release-card`**, only the CSS class prefix and the per-card `DetailHref` differ. | **Redundant copy.** Fold into `ReleaseGallery`. | +| `/cuts` (`AlbumsView`) | Inline `album-card` markup, card click → `OpenAlbum` → `/tracks?album`. | **Redundant copy + wrong target.** Fold into `ReleaseGallery`, repoint to `/cuts/{id}`. | + +Three near-identical card implementations across four browse surfaces. The structure is identical +(cover with `--fallback`, body with truncated title + artist); the only real divergence is **how a +card computes its href**: Sessions/Mixes use a fixed route segment; Archive resolves per-medium. + +#### 4.2.2 The shared component contract + +`ReleaseGallery` already owns the grid, skeleton-loading, empty-state, and card markup. Generalize its +**href computation** so one component serves both the fixed-route case and the per-medium case: + +- **Today:** `[Parameter] string DetailRoute` → card links `/{DetailRoute}/{id}`. +- **Add:** an optional per-card href resolver, `[Parameter] Func? HrefResolver`. + When supplied, the card links `HrefResolver(release)`; otherwise it falls back to the + `DetailRoute`-based href (Sessions/Mixes unchanged). Archive passes + `HrefResolver="@ReleaseRoutes.DetailHref"` (the §2 resolver) — so each Archive card routes by its + own medium through the **same one table**. +- **Optionally fold the medium-route default into the resolver itself:** since `ReleaseRoutes` already + knows medium→route, Sessions/Mixes could *also* drop `DetailRoute` and just pass the resolver. But + keeping `DetailRoute` as the simple default avoids churning two working pages — **recommend adding + `HrefResolver` as the new path, leaving `DetailRoute` as the back-compat default**, and migrating + Sessions/Mixes only if it's free. + +After this: `ReleaseGallery` is the one card grid. `archive-release-card` and `album-card` markup +(and their CSS) are deleted; `ArchiveView` keeps **only** its search/filter chrome above the grid +(the chrome is genuinely Archive-specific — it does not belong in the shared card component). + +> **CSS consolidation:** the inline copies carry parallel CSS (`archive-release-*`, `album-card-*`). +> When the markup folds into `ReleaseGallery`, those rules collapse into the `release-card-*` rules. +> Verify the visual treatments match before deleting (cover sizing, fallback styling, truncation) — +> if Archive/Cuts cards were tuned differently, fold the differences into `ReleaseGallery` via a +> modifier class, not by keeping the duplicate. + +#### 4.2.3 The Cuts-detail track row — same component, or different? + +The brief asks whether the shared card extends to "the Cuts detail track list where applicable." The +Cut detail's track *rows* (§3.1 — `1. ▶ Track One … 3:42`) are a **different shape** from a release +*card* (a horizontal row with ordinal + play + duration, not a cover-forward card). **Recommend a +separate small `TrackRow` component** for the Cut track list rather than forcing it into +`ReleaseGallery` — they share nothing structurally. If a `TrackRow` is extracted, it is the natural +shared row for any future track-list surface, but it is *not* the release-card component. Flag this so +the normalization doesn't over-reach into forcing two unlike things into one component. + +### 4.3 Residual redundancy (opportunistic, surface don't sprawl) + +1. **`ArchiveView.MediumLabel`** — a medium→label lookup. Check whether it duplicates a CMS + `MediumTypeLabels` or the Archive's own medium-chip labels; if the public side has two + medium-label lookups, **consolidate to one**. **Medium confidence; opportunistic.** +2. **`GenresView` (`/genres`)** — already nav-demoted (§8.I); Archive has genre filtering. Likely + retire-able, but **out of this phase's stated scope** — flag as adjacent, low urgency. **Not in + Phase 11** unless Daniel pulls it in. + +> Land the full stack retirement (§4.1) and the card normalization (§4.2) — both decided. Treat 4.3 +> as tidy-ups: do them if they fall out of the normalization for free, surface `/genres` as adjacent. --- @@ -401,104 +694,148 @@ seed-from-URL step just has to run before the restore decision (as §5.2 specifi ## 6. Wave decomposition -Sequenced so the structural dependency (Cuts page → resolver → repoint → retire) is honored, and the -two independent tracks (Archive URL; Cuts page) can run in parallel. +Sequenced so the structural chain (Cuts page → resolver → repoint → retire/normalize) is honored, +and the genuinely independent tracks (Archive URL; the queue model) can run in parallel. Seven +commitments, six waves. The queue (11.F) is the one work item that can start cold on day one and is +the gate for the Cuts "play album" affordance. ``` -Wave A (parallel start) Wave B (parallel start) -┌──────────────────────────┐ ┌────────────────────────────┐ -│ 11.A /cuts/{id} page │ │ 11.D Archive filters →URL │ -│ + CutDetailViewModel │ │ (TracksView-pattern bind) │ -│ + cover theme border │ └────────────────────────────┘ -│ + ordered track list │ (fully independent — -└──────────┬───────────────┘ touches only ArchiveView) - │ - ▼ -┌──────────────────────────┐ -│ 11.B medium→route │ -│ resolver (ReleaseRoutes) │ -│ + thin /tracks/{id} │ -│ redirect page │ -│ + repoint player-bar │ -│ title + Archive + │ -│ AlbumsView cards │ -└──────────┬───────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ 11.C Normalization: │ -│ retire /tracks?album │ -│ branch; decide + (maybe) │ -│ retire track-cardinal │ -│ stack; consolidate │ -│ medium-label lookups │ -└──────────────────────────┘ + ┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐ + │ 11.A /cuts/{id} page │ │ 11.D Archive filters→URL │ │ 11.F Queue model │ + │ + CutDetailViewModel │ │ (TracksView-pattern) │ │ (IQueueService above the │ + │ + cover theme border │ │ INDEPENDENT │ │ single-slot player) │ + │ + ordered track list │ └──────────────────────────┘ │ + player TrackEnded hook │ + │ (verify public read │ │ + player-bar skip controls│ + │ projects+sorts │ │ INDEPENDENT (cold start) │ + │ TrackNumber — §3a) │ └────────────┬─────────────┘ + └──────────┬───────────────┘ │ + │ (11.A header Play & row Play + ▼ consume 11.F's PlayRelease; + ┌──────────────────────────┐ degrade to single-track if 11.F + │ 11.B ReleaseRoutes │◄── needs 11.A (Cut→/cuts/id) later — §3.4 seam) + │ resolver + repoint │ + │ (player-bar title→release,│ + │ Archive + AlbumsView │ ┌──────────────────────────┐ + │ cards) + thin /tracks/id │ │ 11.E Release-level Share │ + │ redirect │ │ (SharePopover release │ + └──────────┬───────────────┘ │ mode, copy ReleaseRoutes │ + │ │ URL) — needs 11.B resolver│ + │◄────────────────────┤ + a release detail to │ + ▼ │ share (11.A/Session/Mix) │ + ┌──────────────────────────┐ └──────────────────────────┘ + │ 11.C Retire + normalize │ + │ • delete track-cardinal │ + │ stack (Tracks*/Track*) │ + │ • fold Archive+Cuts cards │ + │ into shared ReleaseGallery│ + │ • consolidate medium-label│ + └──────────────────────────┘ ``` -- **11.A — `/cuts/{id}` detail page.** The new page, its VM, the cover theme border, the ordered - track list, header Play→track 1, row Play. Independent of everything except the existing - `GetById` + `releaseId`-filtered track page (both exist). **Load-bearing prerequisite for 11.B's - Cut resolution.** -- **11.B — medium→route resolver + repoint.** Promote `ArchiveView.DetailHref` to a shared - `ReleaseRoutes` helper; Cut now resolves to `/cuts/{id}` (needs 11.A); repoint player-bar title, - Archive cards, and `AlbumsView` cards through it; add the thin `/tracks/{id}` redirect page. - **Depends on 11.A.** -- **11.C — normalization.** Retire the `/tracks?album` filter branch (safe once 11.B repoints both - consumers); execute the agreed reduction from §4.3 (decide 4.3#2/#3 with Daniel first). **Depends - on 11.B.** -- **11.D — Archive filters in the URL.** Fully independent — touches only `ArchiveView`. Can land - first, last, or alongside Wave A. **No dependency.** +- **11.A — `/cuts/{id}` album-detail page.** The page, `CutDetailViewModel`, cover theme border, + ordered track list (ordered by `TrackNumber` — §3a), per-row play, header Play, Share button. + **Ordinal is a verification, not a dependency:** §3a confirmed `TrackNumber` already exists and the + read likely already sorts on it; 11.A's one checklist item is *confirm the public read projects + + sorts `TrackNumber`* (one-line fix if not). **Depends only on existing `GetById` + `releaseId`- + filtered track page (both exist).** Header/row Play call into 11.F when present, else degrade to + single-track streaming (§3.4 seam). **Load-bearing prerequisite for 11.B's Cut resolution.** +- **11.B — `ReleaseRoutes` resolver + repoint.** Promote `ArchiveView.DetailHref` to a shared + `ReleaseRoutes.DetailHref`; Cut now resolves to `/cuts/{id}` (needs 11.A); repoint player-bar + title (→ release), Archive cards, `AlbumsView` cards; add the thin `/tracks/{id}` redirect page. + **Depends on 11.A.** Verify `TrackDto.Release` is always populated on the player bar (so the + resolver has a medium — §7 gap). +- **11.C — retire + normalize (the heart).** With §2 having removed every inbound link: **delete the + whole track-cardinal stack** (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`/ + `GalleryViewMode` + `/tracks`, `/track/{EntryKey}` routes — §4.1) **and** fold the Archive + Cuts + inline card markup into the shared `ReleaseGallery` via the new `HrefResolver` (§4.2); consolidate + the medium-label lookup (§4.3). **Depends on 11.B.** (The Cut track-row is a separate small + `TrackRow`, not `ReleaseGallery` — §4.2.3.) +- **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`, history-driven (§5). Fully + independent — touches only `ArchiveView`. **No dependency; free-floating.** *Note:* 11.D and 11.C + both touch `ArchiveView` — sequence them so they don't collide (do 11.D's URL-binding and 11.C's + card-fold in one `ArchiveView` pass, or land 11.D first and rebase 11.C onto it). +- **11.E — release-level Share.** `SharePopover` gains a release mode that copies + `ReleaseRoutes.DetailHref(release)` (§3b). **Depends on 11.B** (needs the resolver for the URL) and + on a release detail existing to share (11.A for Cuts; Session/Mix already exist). Wire the Cut + header Share (and optionally Session/Mix headers) to open the popover in release mode. +- **11.F — queue model.** `IQueueService` orchestrating above the single-slot player; the one new + player-side hook (`TrackEnded`); player-bar skip controls (§3c). **Independent — can start cold on + day one**, in parallel with 11.A/11.D. It is the **gate for the Cuts "play album" affordance**: + 11.A's header Play calls `QueueService.PlayRelease(tracks)` once 11.F lands (degrading to + single-track before then). **Preload (§1.3b) is OUT of this wave** — design the seam, defer the + feature (§3c.5). -**Critical path:** 11.A → 11.B → 11.C. **11.D is free-floating.** +**Dependency shape:** + +``` +11.A ──► 11.B ──► 11.C + └────► 11.E (also needs a release detail to share) +11.D (free-floating; coordinate with 11.C on ArchiveView) +11.F (free-floating cold start; 11.A's "play album" consumes it) +``` + +**Critical path:** `11.A → 11.B → 11.C`. **11.D, 11.E, 11.F hang off it** (11.E after 11.B; 11.D and +11.F fully parallel). The two "can start immediately" items are **11.A** and **11.F** — kicking both +off first shortens the wall-clock to a usable Cut page (page + queue arrive together, so "play album" +works on first ship of 11.A rather than as a later retrofit). + +> **Honest dependency notes (the brief asked to keep these straight):** +> - **Ordinal does *not* gate a wave.** §3a showed `TrackNumber` already exists end-to-end; the +> "ordering" dependency is a one-line verification inside 11.A, not sequencing. +> - **Queue gates the "play album" affordance, not the Cut page.** 11.A ships with a degradable Play +> (single-track) if 11.F is not yet in; the affordance becomes "enqueue album" the moment 11.F +> lands, via the §3.4 handler swap. So 11.A and 11.F are parallel, with a clean late-binding seam. +> - **Shared cards parallel the stack retirement.** Folding Archive/Cuts cards into `ReleaseGallery` +> (§4.2) and deleting the track-cardinal stack (§4.1) are both in 11.C and both depend on 11.B's +> repoint — they are siblings, not sequential. --- -## 7. Open questions and adjacent gaps (need Daniel) +## 7. Resolved decisions and remaining open questions -**Decisions inside the four commitments:** +### 7.1 Resolved by Daniel (2026-06-15) — kept visible as *decided*, not deleted -1. **Player-bar title target (§2).** Confirm the title click should resolve **release** detail (via - the resolver), replacing the current `/track/{EntryKey}` link. *Recommend yes* — it is the premise - of requirement 1. -2. **`/cuts/{id}` scaffold strategy (§3.2).** Compose `ReleaseDetailScaffold` with a generalized +1. **Player-bar title target (§2).** **DECIDED:** the title click resolves **release** detail via the + resolver, replacing the `/track/{EntryKey}` link. (Was OQ1.) +2. **Track ordinal (§3a).** **DECIDED:** explicit ordinal column, editable from the CMS — *and the + read confirms it already exists* (`TrackEntity.TrackNumber`, migration applied, CMS reorder live). + No new schema; verify the public read projects/sorts it. (Was OQ4 "do not assume into Phase 11"; + Daniel reversed to in-scope, and it turned out already-built.) +3. **`/tracks` retirement scope (§4.1).** **DECIDED:** retire the **whole** track-cardinal stack + (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`/`GalleryViewMode` + routes), not just the + `?album` branch. (Was OQ5; the full-cut option chosen.) +4. **Release-level Share (§3b).** **DECIDED:** in scope. `SharePopover` gains a release-keyed mode. + (Was an adjacent gap; promoted.) +5. **Play-queue system (§3c).** **DECIDED:** in scope; absorbs the queue half of `PLAN.md §1.3`. The + Cuts "play album" affordance is its first consumer. (Was an adjacent gap; promoted.) Preload half + stays deferred. + +### 7.2 Still open (need Daniel — recommendations given) + +1. **`/cuts/{id}` scaffold strategy (§3.2).** Compose `ReleaseDetailScaffold` with a generalized `Header` slot (recommended) vs. bespoke page like `SessionDetail`. Sets whether the scaffold contract changes. *Recommend the `Header`-slot generalization; bespoke as fallback.* -3. **Cut header affordance idiom (§3.2.2).** Keep the icon `PlayStateIcon`+`SharePopover` (consistent +2. **Cut header affordance idiom (§3.2.2).** Keep the icon `PlayStateIcon`+`SharePopover` (consistent with Session/Mix) vs. labeled **Play/Share buttons** (the literal spec wording). *Minor — flag.* -4. **Track ordering / `TrackNumber` (§3.3).** Does an album need explicit track ordering beyond - insertion order? If yes, that is a **`TrackEntity.TrackNumber` data-model addition** (new column + - migration + CMS field + ordering in the read) — a real schema gap. *Recommend: ship 11.A on - insertion/`Id` order; raise `TrackNumber` as its own small phase if hands-on use shows albums - landing out of order.* **Daniel call — do not assume into Phase 11.** -5. **`/tracks` retirement scope (§4.1).** Remove only the `album` branch (A, safe) vs. retire the - whole track-cardinal stack — `TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery` (B, bigger - reduction). *Recommend B as the target, A as the floor.* This is the phase's biggest reduction - decision. -6. **`/genres` fate (§4.3#4).** Already nav-demoted; Archive has genre filtering. Retire `/genres` - too? *Out of stated scope — flag as adjacent, low urgency.* +3. **Queue architecture (§3c).** Queue inside `IPlayerService` vs. a separate `IQueueService` + orchestrating above the single-slot player. *Strong steer: the separate `IQueueService` (§3c.1).* + **Final call is staff-engineer's at implementation** — the spec gives the steer, not the verdict. +4. **Release-share keeps "Embed player"? (§3b.3).** *Recommend copy-link-only in release mode across + all media; per-track embed stays where a track is the subject.* Trivial either way. +5. **`/genres` fate (§4.3).** Already nav-demoted; Archive has genre filtering. Retire `/genres` too? + *Out of stated scope — flag as adjacent, low urgency.* **Not in Phase 11** unless Daniel pulls it. -**Adjacent gaps surfaced by the read (not in the four commitments — for the roadmap, not this -phase):** +### 7.3 Small things to get right (not decisions — implementer notes) -- **No album-play queue.** Header Play starts track 1 only; there is no "play the whole album" - because the player is single-slot (no queue — `PLAN.md §1.3` preload/queue is still deferred). A - Cuts album page is the **strongest product argument yet for a queue model** — "play album" is the - expected affordance on an album page. *Recommend surfacing this as a renewed case for §1.3, not - building it in Phase 11.* The Cut page's Play button is the natural future entry point; design the - header Play so a future "enqueue album" is a swap of the handler, not a rewrite (the - *Design for adaptability up front* seam). -- **Share on a Cut shares a track, not the release.** `SharePopover` takes an `EntryKey` (a track). - A Cut's header Share should arguably share the **release URL** (`/cuts/{id}`), not a single track's - embed. *Flag:* the existing `SharePopover` is track-keyed; sharing a release is a new share target. - Decide whether the Cut header Share shares the release page or punts to track-share. *Recommend - release-URL share for the Cut header* — but it is a `SharePopover` extension, so flag it. -- **`TrackDto.Release` nullability on the player bar.** `TrackMetaLabel` guards `Track.Release?` — - if a track ever loads without its release populated, the resolver (§2) has no medium to route on. - Verify the player's `TrackDto` always carries `Release` (the streaming select path). *Low risk; - worth a verification line in 11.B.* -- **No `ReleaseDate`-only-year display helper.** The Cut header shows "release year"; `MixDetail`/ - `SessionDetail` show "MMMM yyyy". Minor inconsistency — the Cut header wants just the year per the - spec. *Trivial; note it so the implementer doesn't copy the month-year format.* +- **`TrackDto.Release` nullability on the player bar (11.B).** `TrackMetaLabel` guards `Track.Release?` + — if a track loads without its release populated, the resolver has no medium to route on. Verify the + streaming select path always carries `Release`. *Low risk; a verification line in 11.B.* +- **Public read projects/sorts `TrackNumber` (11.A).** The §3a verify step — confirm the public + `releaseId`-filtered track page sorts ascending by `TrackNumber` and carries it onto `TrackDto`. +- **Player `TrackEnded` hook (11.F).** The queue auto-advances on natural end-of-stream; verify the + player raises a track-ended event or add one (the single new player-side surface — §3c.2). +- **Year-only display (§3.1).** The Cut header shows just the year; `MixDetail`/`SessionDetail` show + "MMMM yyyy". Don't copy the month-year format into the Cut header. --- @@ -509,9 +846,15 @@ phase):** makes the player-bar→detail path release-cardinal too. After this, the track-cardinal stack is vestigial — which is *why* the reduction (§4) is available. - **One source, multiple views.** The medium→route resolver (§2) is one table consumed by the player - bar, Archive, and Cuts cards — not three `DetailHref` switches. The Cut detail reuses the - scaffold's invariant trio and the existing `GetById` + `releaseId`-filtered track page — no new - data path. (Memory: *One source, multiple views*.) + bar, Archive, and Cuts cards — not three `DetailHref` switches. The shared `ReleaseGallery` (§4.2) + becomes the one release-card grid across all four browse surfaces, not three inline copies. The + queue (§3c) is one orchestrator the player bar and a future up-next panel both observe. The Cut + detail reuses the scaffold's invariant trio and the existing `GetById` + `releaseId`-filtered track + page — no new data path. (Memory: *One source, multiple views*.) +- **Design the seam, defer the feature.** Header Play binds to a single handler that swaps from + single-track to `QueueService.PlayRelease` with no page change (§3.4); preload subscribes to the + queue's "next is known" state rather than restructuring it (§3c.5). (Memory: *Design for + adaptability up front*.) - **URL as source of truth (§5).** Borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern makes the Archive's filters addressable, which is the same shareable-link discipline the embed player and `/tracks?album` deep links already established. @@ -544,8 +887,18 @@ phase):** `ReleaseDto`; the track-data service supports `releaseId`-filtered paging (`ReleaseDetailViewModel.Load` uses `GetPage(…, releaseId: …)`). `ReleaseDto` carries `TrackCount` but the **track list needs the filtered track page** (the DTO has no nested track collection). -- **`TrackEntity` has no track-number / ordinal field** (`Id, EntryKey, TrackName, Artist, Album, - Genre, ReleaseDate, ImagePath`). Album track ordering beyond insertion order is a data-model gap. +- **`TrackEntity` ALREADY HAS an explicit track-number ordinal.** `TrackEntity.TrackNumber` (`int`, + 1-based, non-null, default 1 — `TrackEntity.cs:17`); column `track_number` (`TrackConfiguration.cs:37`); + migration `20260611005700_AddReleaseTypeAndTrackNumber` **already applied**; `TrackDto.TrackNumber` + mirrors it (`TrackDto.cs:18`); `TrackConverter` round-trips it; `UpdateTrackMetadataRequest.TrackNumber` + + `TrackController` validate (`> 0`) and persist; `BatchEdit` assigns ordinal from reorderable list + position on submit (`BatchEdit.razor:225`); `ReleaseRepository.GetTracks` already + `.OrderBy(t => t.TrackNumber)` (`ReleaseRepository.cs:117`). **Commitment 5 is verify-and-consume, + not new schema.** (This corrects the prior draft's "data-model gap" claim — the gap does not exist.) +- **Release-card markup is triplicated.** `ReleaseGallery` (`release-card`) is the canonical card + grid, used only by Sessions/Mixes. `ArchiveView` (`archive-release-card`) and `AlbumsView` + (`album-card`) re-implement the **same cover/title/artist structure inline** — the only real + divergence is per-card href computation (Archive resolves per-medium, the others use a fixed route). - **`Pages.cs` `MenuPages`** = ARCHIVE (→ `/archive`) with Cuts/Sessions/Mixes children; `/tracks` and `/genres` are absent from nav, routes reachable (§8.I). - **`SharePopover` is track-keyed** (takes `EntryKey`) — sharing a release is a new target.