e9f4411fdf
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.
905 lines
64 KiB
Markdown
905 lines
64 KiB
Markdown
# Phase 11 — Public Site Enhancements
|
||
|
||
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), `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*.
|
||
|
||
---
|
||
|
||
## 0. Why this phase exists, and the state it inherits
|
||
|
||
Phase 9 made the medium taxonomy real end-to-end and gave each medium a browse + detail surface.
|
||
Wave 8 then remediated the public side: `/archive` became a release-cardinal searchable browser,
|
||
the nav flattened to ARCHIVE + Cuts/Sessions/Mixes, and the track-cardinal `/tracks` gallery was
|
||
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 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:
|
||
|
||
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)
|
||
|
||
| Surface | State | File |
|
||
|---|---|---|
|
||
| `/sessions/{id}` detail | **Exists, mature.** Hero-dominant overlay; bridged prerender; play wiring. | `Pages/SessionDetail.razor` |
|
||
| `/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}`. **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 `<a href="/{DetailRoute}/{id}">`. **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)
|
||
|
||
The brief's framing is directionally right but uses route names that do not match the code. Naming
|
||
these up front so the implementer is not misled:
|
||
|
||
1. **There is no `/tracks/{id}` route.** The track-cardinal detail route is **`/track/{EntryKey}`**
|
||
(`TrackDetail`), keyed by the FileDatabase entry key (a string), not a numeric id. The player-bar
|
||
title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9). When the brief says
|
||
"`/tracks/{id}` becomes a pure router," the *intent* is: **the thing a release-title click lands
|
||
on should resolve medium → the correct dedicated detail page.** See §2 for what this actually
|
||
means against the live routes — the brief's "router" is better realized as a small **medium→route
|
||
resolver** than as a literal `/tracks/{id}` page, because the player bar carries a track, not a
|
||
release id.
|
||
|
||
2. **Cuts genuinely have no single-release detail page.** This is the real asymmetry. `/cuts`
|
||
(`AlbumsView`) cards and `/archive` Cut cards both open `/tracks?album={title}` — the track
|
||
gallery filtered to the album title. Sessions and Mixes route to dedicated `/sessions/{id}` /
|
||
`/mixes/{id}` pages. The new `/cuts/{id}` page (§3) closes this gap and is the heart of the phase.
|
||
|
||
3. **`/archive` filters are already in component state but not the URL.** `ArchiveView` holds
|
||
`_selectedMedium`, `_selectedGenre`, `SearchText` as private fields with no
|
||
`[SupplyParameterFromQuery]` and no `NavigateTo` on filter change. Requirement 4 is therefore a
|
||
**URL-binding pass over an existing browser**, not a new feature. The pattern to borrow already
|
||
lives next door: `TracksView` reads `?album=`/`?genre=`/`?q=` via `[SupplyParameterFromQuery]`
|
||
(`TracksView.razor.cs` lines 21–23).
|
||
|
||
---
|
||
|
||
## 1. The seven commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in)
|
||
|
||
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** (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. **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
|
||
|
||
**Recommendation: do not build a literal `/tracks/{id}` page. Build a `medium → detail route`
|
||
resolver and apply it at the two click sites that need it.** Here is why, and the three shapes
|
||
considered.
|
||
|
||
### What the player-bar title actually carries
|
||
|
||
The player bar holds a **`TrackDto`** (`TrackMetaLabel.razor`). A `TrackDto` carries a nested
|
||
`Release` (with `Id` and `Medium`). So at the click site we already know the release id *and* the
|
||
medium — there is no need for a round-trip "router page" that fetches the release to discover its
|
||
medium. The resolver is a pure function:
|
||
|
||
```
|
||
Release { Id, Medium } → "/cuts/{Id}" | "/sessions/{Id}" | "/mixes/{Id}"
|
||
```
|
||
|
||
### Three shapes
|
||
|
||
- **(i) Literal `/tracks/{id}` router page.** A routable component that fetches the release by id,
|
||
reads `Medium`, and `NavigateTo`s the dedicated page (`replace: true` so the router never sits in
|
||
history). *Cost:* a network round-trip and a flash of an empty page on every release-title click,
|
||
to rediscover a medium the caller already knew. Justified **only** if some entry point has *only*
|
||
a release id and not the medium (e.g. an external deep link to `/tracks/{id}`). *Keep this as a
|
||
thin fallback, not the primary path.*
|
||
|
||
- **(ii) Pure resolver helper, applied at click sites (RECOMMENDED).** A single
|
||
`ReleaseRoutes.DetailHref(ReleaseDto)` (or `(long id, ReleaseMedium medium)`) helper — **one
|
||
table, one location** — that every release-title / release-card click consumes. `ArchiveView`
|
||
already has a private `DetailHref` switch (lines 121–126); **promote it to the shared helper** so
|
||
the Archive, the player bar, and the new Cuts cards all route through one source. No round-trip,
|
||
no flash. This is the *One source, multiple views* discipline applied to routing.
|
||
|
||
- **(iii) Both.** The resolver helper (ii) is the primary path; a thin `/tracks/{id}` redirect page
|
||
(i) exists as the addressable fallback for bare-release-id deep links and for honoring the brief's
|
||
literal route. The redirect page consumes the *same* resolver helper.
|
||
|
||
**Recommend (iii): resolver helper as the spine, plus a thin `/tracks/{id}` redirect page that
|
||
reuses it.** This satisfies the brief's literal "`/tracks/{id}` is a pure router" wording *and* gives
|
||
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.
|
||
|
||
> **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 `<a href>` 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
|
||
|
||
`DetailHref` for a Cut today returns `/tracks?album={title}`. After §3 lands, a Cut resolves to
|
||
**`/cuts/{Id}`** (id-addressed, consistent with Session/Mix). This repoint is the hinge between
|
||
requirements 1, 2, and 3 — see the dependency note in §6.
|
||
|
||
---
|
||
|
||
## 3. Requirement 2 — the `/cuts/{id}` album-detail page
|
||
|
||
This is the phase's center of gravity: the first **multi-track** release detail page. Sessions and
|
||
Mixes are single-track (their detail pages show one play affordance); a Cut is an album/EP/single
|
||
with an ordered track list.
|
||
|
||
### 3.1 Layout (Daniel's spec, literal)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ ← All cuts │
|
||
│ │
|
||
│ ┌─────────────────────────────┐ ┌──────────────────────┐ │
|
||
│ │ RELEASE NAME (h3) │ │ │ │
|
||
│ │ Artist (h6, primary) │ │ COVER ART │ │
|
||
│ │ Genre · 2025 │ │ (large-ish, │ │
|
||
│ │ │ │ theme border) │ │
|
||
│ │ [ ▶ Play ] [ ⤴ Share ] │ │ │ │
|
||
│ └─────────────────────────────┘ └──────────────────────┘ │
|
||
│ ↑ header content LEFT ↑ cover RIGHT │
|
||
│ │
|
||
│ ───────────────────────────────────────────────────────────│
|
||
│ 1. ▶ Track One 3:42 │
|
||
│ 2. ▶ Track Two 4:18 │
|
||
│ 3. ▶ Track Three 2:55 │
|
||
│ … │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Header left:** release name, artist, genre, release year, **Play** + **Share** buttons.
|
||
- **Cover right:** large, **theme border** around the image (a `deepdrft-`-prefixed border using a
|
||
palette token — mirror the existing cover treatments but with an explicit framed border, which is
|
||
the new visual element).
|
||
- **Track list below:** ordered rows, each with a play button. Row click / row play streams that
|
||
track.
|
||
- **Header Play** starts **track 1**.
|
||
|
||
### 3.2 Compose `ReleaseDetailScaffold`, or not? — the load-bearing design call
|
||
|
||
Phase 9 §5.3 established `ReleaseDetailScaffold` as the shared detail scaffold and committed to
|
||
"refactor `TrackDetail` onto it; per-medium variance rides slots." The scaffold owns the **invariant
|
||
trio**: back link, masthead (title + artist), play/share affordance. `MixDetail` composes it;
|
||
`SessionDetail` deliberately diverges (overlay layout). The question for Cuts:
|
||
|
||
- **(i) Compose the scaffold.** The Cut header *is* the invariant trio (title, artist, play, share) —
|
||
almost exactly what the scaffold provides. The cover goes in the `Hero` slot; the genre/year go in
|
||
`MetaContent`; the **track list** rides a new `BodyContent` slot (the scaffold has no body slot
|
||
today — adding one is the cheap, correct extension Phase 9 §5.3 anticipated: *"named slots are fine
|
||
where genuinely needed, e.g. `BodyContent` for the Cut/Album multi-track listing"*).
|
||
- **(ii) Bespoke page** (like `SessionDetail`). Full control over the left/right header split, but
|
||
duplicates the play/share wiring and the back-link/masthead the scaffold already owns.
|
||
|
||
**Recommend (i): compose the scaffold, add one `BodyContent` slot.** Two caveats that decide whether
|
||
this is clean or a fight:
|
||
|
||
1. **The header layout is left-content / right-cover; the scaffold's masthead is a top row.** The
|
||
scaffold today renders masthead-then-Hero vertically (`MixDetail` stacks cover below the
|
||
masthead). The Cut layout wants masthead and cover **side by side**. This is a **layout variance**,
|
||
and Phase 9 §5.3 is explicit: *"a boolean layout parameter on the scaffold is a design failure —
|
||
that variance belongs in a slot."* So the right answer is **not** a `HeroBesideMeta` flag on the
|
||
scaffold. Two clean options:
|
||
- **(a)** The Cut page supplies its whole left+right header as the page content and uses the
|
||
scaffold only for the back link + play/share wiring + body slot — i.e. the scaffold's masthead
|
||
is *one* arrangement and the Cut wants a different one, so the Cut composes a richer header into
|
||
a slot. Risk: the scaffold's built-in masthead then competes with the Cut's own header.
|
||
- **(b)** Generalize the scaffold's header region into a `Header` slot with the current
|
||
masthead+play row as the default content, so `MixDetail`/`TrackDetail` are unchanged (default)
|
||
and `CutDetail` supplies a left/right `Header`. This is the cleaner *One source* move but
|
||
touches the scaffold's shared contract.
|
||
- **Recommend (b)**, but flag it: it is a scaffold-contract change that ripples to every composer.
|
||
If Wave pressure makes (b) risky, **(ii) bespoke page** is the honest fallback — record it as
|
||
deliberate divergence (as `SessionDetail` already is) rather than bending the scaffold with a
|
||
boolean.
|
||
|
||
2. **The play/share affordance differs.** The scaffold renders a `PlayStateIcon` (icon toggle) +
|
||
`SharePopover`. Daniel's Cut spec says **Play and Share buttons** (labeled buttons, per the
|
||
mockup). If the Cut wants text buttons rather than the icon idiom, that is a **slot for the
|
||
affordance row**, not a scaffold edit. Minor — flag for Daniel whether the Cut header keeps the
|
||
icon idiom (consistency with Session/Mix) or uses labeled buttons (the literal spec wording).
|
||
|
||
### 3.3 Data path — the track list
|
||
|
||
The Cut page needs the release **and its ordered tracks**. Both primitives exist:
|
||
|
||
- **Release:** `IReleaseDataService.GetById(id)` → `ReleaseDto` (with `Title`, `Artist`, `Genre`,
|
||
`ReleaseDate`, `ImagePath`, `Medium`, `ReleaseType`, `TrackCount`).
|
||
- **Tracks:** the track-data service already supports a **`releaseId`-filtered** track page —
|
||
`ReleaseDetailViewModel.Load` uses `GetPage(pageNumber: 1, pageSize: 1, releaseId: …)` to resolve
|
||
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. 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:
|
||
|
||
- Extend `ReleaseDetailViewModel` with an optional `Tracks` collection populated when the medium is
|
||
Cut. Risk: the VM grows a medium conditional — the smell Phase 9 fought.
|
||
- A dedicated `CutDetailViewModel` (loads release + full track list). Cleaner SRP; the Cut detail is
|
||
genuinely a different shape (multi-track) from the single-release VM.
|
||
|
||
**Recommend a dedicated `CutDetailViewModel`** + a `CutDetail` page deriving the same prerender-bridge
|
||
discipline `ReleaseDetailBase` encodes (persist release + tracks across the prerender→WASM seam,
|
||
guard restore on id). If the bridge logic is substantial, consider generalizing `ReleaseDetailBase`'s
|
||
bridge into a shared base both single- and multi-track details use — but only if it doesn't force a
|
||
medium conditional into the base. Flag the choice; don't pre-commit the implementer.
|
||
|
||
### 3.4 Play wiring
|
||
|
||
- **Row play:** stream the row's track (the `TracksView.PlayTrack` idiom — toggle if already current,
|
||
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.
|
||
|
||
---
|
||
|
||
## 3a. Commitment 5 — the track-ordinal column (already built; verify-and-consume)
|
||
|
||
**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.
|
||
|
||
### 3a.1 What already exists (each verified, 2026-06-15)
|
||
|
||
| 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` |
|
||
|
||
### 3a.2 The reorder-UX question Daniel asked me to surface — already answered
|
||
|
||
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.
|
||
|
||
### 3a.3 The one real verify step — the *public* read projection
|
||
|
||
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.**
|
||
|
||
> **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.
|
||
|
||
---
|
||
|
||
## 3b. Commitment 6 — release-level Share
|
||
|
||
**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.
|
||
|
||
### 3b.1 What `SharePopover` does today
|
||
|
||
`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<TrackDto>` 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<TrackDto> Items // ordered; the release's tracks for "play album"
|
||
int CurrentIndex // -1 when empty
|
||
TrackDto? Current => Items[CurrentIndex]
|
||
bool HasNext / HasPrevious
|
||
PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0) // Cuts header Play / row Play
|
||
Enqueue(TrackDto) / EnqueueRange(IEnumerable<TrackDto>)
|
||
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<ReleaseDto, string>? 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.
|
||
|
||
---
|
||
|
||
## 5. Requirement 4 — Archive filters in the URL
|
||
|
||
**This is a URL-binding pass over the existing `ArchiveView`, borrowing the `TracksView` pattern
|
||
verbatim.** No new browser, no new data path — the filter state already drives `LoadReleases`; the
|
||
change is making that state **enter and leave via the query string**.
|
||
|
||
### 5.1 Target URL scheme
|
||
|
||
```
|
||
/archive?q={search}&medium={cut|session|mix}&genre={genre}
|
||
```
|
||
|
||
- All three params optional; omitting one means "no filter on that axis" (matches the current
|
||
null-means-all semantics).
|
||
- `medium` uses the same lowercase enum token the data service already speaks
|
||
(`Medium.ToString().ToLowerInvariant()`), parsed back with `Enum.TryParse(ignoreCase:true)` +
|
||
`Enum.IsDefined` — the exact posture `BatchUpload` (§8.E) and the API already use.
|
||
- Plain `/archive` (no params) = the unfiltered first page, which is the bridged/prerendered state.
|
||
|
||
### 5.2 The binding mechanics (borrow `TracksView`)
|
||
|
||
`TracksView.razor.cs` is the template:
|
||
|
||
- **In (URL → state):** add `[SupplyParameterFromQuery]` for `q`, `medium`, `genre`. Seed
|
||
`SearchText` / `_selectedMedium` / `_selectedGenre` from them in `OnInitializedAsync` **before**
|
||
the restore/fetch decision (so a direct nav to a filtered URL fetches filtered, and the bridge
|
||
restore is skipped when a filter is active — `ArchiveView` already has `HasActiveFilter` gating
|
||
exactly this).
|
||
- **Out (state → URL):** each of `OnSearchInput` / `OnMediumSelected` / `OnGenreSelected` calls
|
||
`Navigation.NavigateTo($"/archive?{composed query}")` **instead of** (or before) calling
|
||
`LoadReleases` directly. The query-param change drives the re-fetch.
|
||
|
||
### 5.3 The one real subtlety — same-route query change does not re-run `OnInitialized`
|
||
|
||
Blazor reuses the component on a same-route query-string change and fires `OnParametersSet`, **not**
|
||
`OnInitializedAsync` (the `TracksView.ClearFilter` comment, lines 117–120, documents exactly this
|
||
trap). So the filter→fetch reaction must live where it sees the change:
|
||
|
||
- **Option A (history-driven):** filter handlers only `NavigateTo` the new URL; move the
|
||
state-seeding + `LoadReleases` into `OnParametersSet`/`OnParametersSetAsync` keyed off the query
|
||
params. Cleanest — the URL is the single source of truth; back/forward "just works" because each
|
||
nav re-runs the same seed-and-fetch. **Recommended.**
|
||
- **Option B (dual-write):** handlers both `NavigateTo` *and* `LoadReleases` directly. Simpler diff
|
||
but the URL and the fetch are two writes that can drift, and back/forward needs separate handling.
|
||
|
||
**Recommend Option A.** It makes the URL the source of truth (which is the whole point of the
|
||
requirement) and gets shareable links + back/forward correctness as a structural consequence rather
|
||
than as bolted-on handling. Guard against the debounce/nav interplay: the search field debounces
|
||
(400ms) before firing; ensure a debounced search nav doesn't fight a rapid medium-chip nav (the
|
||
`OnParametersSet` reaction should be idempotent on identical param sets — mirror the
|
||
`_loadedEntryKey` guard idiom).
|
||
|
||
### 5.4 Persistence interaction
|
||
|
||
The bridged unfiltered first page (`PersistKey = "archive-releases"`) must keep restoring **only**
|
||
when no filter is active — `ArchiveView` already gates persist + restore on `HasActiveFilter`. The
|
||
URL-binding pass must preserve that gate: a `/archive?medium=mix` direct load must **fetch**, not
|
||
restore the unfiltered bridge. The existing `HasActiveFilter` check already expresses this; the
|
||
seed-from-URL step just has to run before the restore decision (as §5.2 specifies).
|
||
|
||
---
|
||
|
||
## 6. Wave decomposition
|
||
|
||
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.
|
||
|
||
```
|
||
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
|
||
│ 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}` 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).
|
||
|
||
**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. Resolved decisions and remaining open questions
|
||
|
||
### 7.1 Resolved by Daniel (2026-06-15) — kept visible as *decided*, not deleted
|
||
|
||
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.*
|
||
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.*
|
||
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.
|
||
|
||
### 7.3 Small things to get right (not decisions — implementer notes)
|
||
|
||
- **`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.
|
||
|
||
---
|
||
|
||
## 8. Why this is consistent with the system's grain
|
||
|
||
- **Release-cardinal everywhere.** Phase 9 + Wave 8 moved the public site to release-cardinal browse
|
||
(`/archive`) and per-medium detail. Phase 11 closes the one hole (Cuts had no detail page) and
|
||
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 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.
|
||
- **Extension, not modification.** The resolver and the `ReleaseDetailScaffold` `Header`/`BodyContent`
|
||
slots are additive; a future medium's detail page composes the same scaffold and the resolver gains
|
||
one entry — the Phase 9 Open/Closed discipline, unchanged.
|
||
|
||
---
|
||
|
||
## 9. Verified facts (read against live source 2026-06-15)
|
||
|
||
- **No `/tracks/{id}` route exists.** Track-cardinal detail is `/track/{EntryKey}` (`TrackDetail.razor`
|
||
line 1). Player-bar title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9).
|
||
- **`/sessions/{id}` and `/mixes/{id}` exist and are mature** (`SessionDetail.razor`, `MixDetail.razor`,
|
||
both id-addressed, both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes
|
||
`ReleaseDetailScaffold`, `SessionDetail` deliberately diverges).
|
||
- **Cuts have no single-release detail page.** `/cuts` is `AlbumsView` (medium-parameterized card
|
||
grid); cards open `/tracks?album={title}` (`AlbumsView.razor.cs` line 62). `/albums` → `/cuts`
|
||
redirect exists (`AlbumsRedirect.razor`).
|
||
- **`/archive` is release-cardinal, filters held in component fields not the URL.** `ArchiveView`
|
||
has `_selectedMedium`, `_selectedGenre`, `SearchText` as private state; no `[SupplyParameterFromQuery]`,
|
||
no `NavigateTo` on filter change (`ArchiveView.razor.cs`). It has a private `DetailHref` switch
|
||
(lines 121–126) routing Session→`/sessions/{id}`, Mix→`/mixes/{id}`, Cut→`/tracks?album={title}`.
|
||
- **`TracksView` already reads `?album=`/`?genre=`/`?q=` from the URL** via `[SupplyParameterFromQuery]`
|
||
(`TracksView.razor.cs` lines 21–23) — the pattern requirement 4 borrows. It documents the
|
||
same-route-query-change trap (`OnParametersSet` not `OnInitialized`, lines 117–120).
|
||
- **`ReleaseDetailScaffold`** owns the invariant trio (back link, masthead, play/share) + `Hero` and
|
||
`MetaContent` slots; **no body/track-list slot today** (`ReleaseDetailScaffold.razor`).
|
||
- **Data primitives for the Cut page both exist:** `IReleaseDataService.GetById(id)` returns a full
|
||
`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` 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.
|