# COMPLETED.md — DeepDrftHome Archive of items that have moved out of `PLAN.md` and `CMS-PLAN.md`. Per `CONTEXT.md §6`, completed items are moved here rather than deleted. Each entry preserves the original "What / Why / Shape" body so this file reads as a decision record, not just an outcome list. Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CMS-PLAN.md` themes) when there are enough entries to warrant it. --- ## Phase 8 — CMS Track Browser ### 8.6 CMS cache invalidation + orphaned release deletion (Wave 6) **Landed:** 2026-06-12 on dev. Three linked CMS bug fixes discovered during Phase 8 browser work: 1. **Cache invalidation on mutations** — Added `CmsTrackBrowserViewModel.Invalidate()` method called from `TrackEdit`, `BatchEdit`, and `TrackList.OnAlbumsChanged` after any track/release mutation. Ensures the album/genre browse cache is never stale when tracks are added, edited, or deleted. 2. **Orphaned release handling** — `CmsAlbumBrowser` now handles 0-track (orphaned) releases with a confirmation dialog + `DeleteReleaseAsync` via a new `DELETE api/track/release/{id}` endpoint. Partial-failure album-delete path also invalidates the cache. Admin can now clean up releases that have lost all their tracks. 3. **Cascade-delete on last-track removal** — EF migration `SoftDeleteOrphanedReleases` (data-only, raw SQL) backfills orphaned release rows with soft-delete markers. `UnifiedTrackService.DeleteAsync` now cascades a release soft-delete when the last live track in a release is deleted (non-fatal; orphaned releases do not block track deletion). **Completion note:** `CmsTrackBrowserViewModel.Invalidate()` added and wired into mutation paths. New `DELETE api/track/release/{id}` endpoint implemented on `UnifiedTrackService`. `CmsAlbumBrowser` updated with orphaned release confirmation + delete. `SoftDeleteOrphanedReleases` migration authored and applied. All three fixes integrated; Phase 8 browse modes remain stable with correct cache coherence and release cleanup semantics. --- ### 8.0 `TrackEntity` normalization **Landed:** 2026-06-11 on dev. - **What:** Split the flat `TrackEntity` into two normalized tables. New **`ReleaseEntity`** holds release-cardinal data (`Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `CreatedByUserId?`). Slimmed **`TrackEntity`** holds track-cardinal data only (`Id`, `ReleaseId` FK, `Release` nav, `EntryKey`, `TrackName`, `TrackNumber`, `OriginalFileName?`) — the release fields are removed from it. New `ReleaseDto`; `TrackDto` slims and gains `ReleaseId` + a **nested `Release` (`ReleaseDto`)** (resolved 2026-06-11: nested, not a flat read model — flat fields are removed and every consumer is updated, not denormalized back); `AlbumSummaryDto` is retired in favour of `ReleaseDto`. - **Why:** The flat schema duplicates release-level metadata on every track row — updating an album's cover art or artist means rewriting every track. Phase 8 introduces album-as-a-unit editing (Batch Edit, album-scoped delete), so the model should match the domain: a Release is first-class; Tracks belong to a Release. This collapses several §8 UI open questions (Album-mode parent rows become Release rows directly; no `GROUP BY`-derived summary). - **Shape:** Sequenced as **five mergeable waves** (notes §0.6): (1) data model — `ReleaseEntity`/config/migration in `DeepDrftData`; (2) DTOs/services/repositories/API — `ReleaseDto`, slimmed `TrackDto`, JOIN-projecting repository, upload find-or-create Release; **Waves 1 + 2 are a single deployment unit** (removing the entity fields breaks compile until the DTO/service layer lands — never merge Wave 1 alone); (3) public-client consumers (`TrackCard`, `TrackDetail`, `TrackMetaLabel`, `NowPlayingCard`) re-point to `track.Release.*`; (4) existing CMS surfaces (`TrackEdit`, `TrackNew`, `BatchUpload`, `TrackList`) minimally updated to compile on the normalized model — Waves 3 + 4 run in parallel; (5) the Phase 8 UI (§8.1–§8.5) begins only after 1–4 are stable. The breaking migration: create `releases`, populate from distinct `(album, artist)` groups, add + populate `release_id` FK, drop redundant track columns. Remaining open questions for Daniel: nullable release FK for album-less tracks (recommend yes), upload auto-create-or-find Release (recommend yes — committed in Wave 2 shape). Full spec, wave breakdown, and per-file consumer list: notes §0 / §0.6. **Completion note:** `ReleaseEntity` table and EF configuration implemented in `DeepDrftData`. Two EF migrations landed (`NormalizeReleaseTrack` and `AddReleaseUniqueTitleArtist`) with full data migration backfill. `ReleaseDto` and slimmed `TrackDto` (with nested `Release` property) implemented in `DeepDrftModels`. Repository updated with JOIN-projecting queries. API controllers updated to return nested DTOs. Public-client consumers (`TrackCard`, `TrackDetail`, `NowPlayingCard`) and CMS surfaces (`TrackEdit`, `TrackNew`, `BatchUpload`, `TrackList`) all updated to point to `track.Release.*` fields. All five waves complete and merged to dev. Build clean, 155 tests pass. §8.1–§8.5 now unblocked. ### 8.1 URL scheme + mode toggle **Landed:** 2026-06-11 on dev. - **What:** `/tracks` (Track mode, default), `/tracks/albums`, `/tracks/genres` as route segments; a toggle inside the existing "Tracks" tab switches mode and pushes the matching URL. The Waveform Pre-Processing tab is untouched. - **Why:** The public home page hard-codes these as cross-host deep-links; a route segment reads as a stable address and matches the app's existing segment-based routing (`/tracks/upload`, `/tracks/{id}`). Query-param mode (`?mode=`) was the alternative — rejected as transient-looking view state, optionally tolerated as an alias. - **Shape:** One `TrackList` component carrying three `@page` directives (or three thin wrappers passing an `InitialMode`); the toggle drives `Mode` + `NavigationManager.NavigateTo`. See notes §3, §9. **Completion note:** `TrackList.razor` refactored to support three route modes via `@page` directives (Track/Album/Genre). Mode-toggle control added to the UI, wired to `NavigationManager.NavigateTo` to push the matching URL. Toggle persists selection across navigation. ### 8.2 `CmsTrackGrid` — the reusable flat track table (DRY core) **Landed:** 2026-06-11 on dev. - **What:** Extract today's `MudTable` into a standalone `CmsTrackGrid.razor` taking `AlbumFilter`/`GenreFilter` params. Apply the new column layout: Track # → 40×40 art thumb → Track Name → Artist → Album → Genre → Release Date (`d MMMM, yyyy`) → **Waveform Status** → Actions. Entry Key + File Name move out of the grid into an Info-icon tooltip (monospace). Art thumb reuses the public `TrackCard` fallback pattern, defined locally CMS-side. - **Why:** Single source of truth for the track-table layout — consumed by both Track mode (no filter) and Genre mode (genre filter), so no duplicated table markup. Decluttering Entry Key / File Name into a tooltip keeps the grid scannable while the data stays reachable. The Waveform column replaces the removed Waveform Pre-Processing tab (status visible inline; per-row Generate when no profile; page-level "Generate All Missing" in the Track-mode header). - **Shape:** Owns its own `MudTable` + `LoadServerData` + delete-confirm (lifted from `TrackList`). `GetPagedAsync` gains optional `album`/`genre` filter params — the one filter data-contract change (the endpoint already supports the filters); post-§0 the filter joins through `releases`. Waveform status comes from a new `HasWaveformProfile` bool on `TrackDto` (recommended over a second per-page lookup; fold into the §8.0 DTO pass). Display date format is presentation-only; sort key stays the raw `DateOnly`. See notes §8, §9, §11. **Completion note:** New `CmsTrackGrid.razor` component implemented with full table layout (Track #, art thumb, name, artist, album, genre, release date, waveform status, actions). `ICmsTrackService.GetPagedAsync` extended with optional `album` and `genre` filter parameters. `HasWaveformProfile` bool added to `TrackDto`. Waveform status column displays profile state; per-row and page-level Generate actions wired. Info tooltip displays Entry Key and File Name. Grid consumed by Track mode (no filter) and Genre mode (genre filter); single source of truth for table markup. ### 8.3 Album mode **Landed:** 2026-06-11 on dev. - **What:** `CmsAlbumBrowser` — parent release rows (art, title, artist, track count, genre, release date, release-type chip, Edit + Delete) that expand to child track rows (track # + name only). Edit → Batch Edit page (§8.5); Delete → album-scoped delete of every track. - **Why:** A scannable release catalogue is the CMS analogue of the public `AlbumsView`, and the natural place to manage a release as a unit. - **Shape:** Post-§0, parent rows are `ReleaseEntity`/`ReleaseDto` rows — `GetReleasesAsync` (eager, once) supplies title/artist/genre/date/type directly, no derivation. Child tracks lazy via `GetPagedAsync(album:)` (joins through `releases`) on first expand, cached per row — no new endpoint. Expandable `MudTable` over `MudTreeView` (parent rows are multi-column, not tree-shaped). **The old `AlbumSummaryDto` widening question is dissolved by §8.0 normalization** — the Release table has all the fields, so the parent row is fully populated at rest with no DTO widening and no lazy derivation. See notes §6, §10, §0.5. **Completion note:** New `CmsAlbumBrowser.razor` component implemented as an expandable release-row browser. Parent rows display `ReleaseDto` data (art, title, artist, track count, genre, release date, release-type chip). Child tracks loaded lazily on expand via `GetPagedAsync(album:)`, cached per row. Edit action navigates to Batch Edit page; Delete action removes album and all its tracks with confirmation. Leverages normalized `ReleaseEntity` from §8.0 — Release rows are fully populated at rest, no lazy derivation required. ### 8.4 Genre mode **Landed:** 2026-06-11 on dev. - **What:** `CmsGenreBrowser` — a responsive `MudCard` grid (one card per genre: name + track count); clicking a card expands it (accordion, one open at a time) to reveal a `CmsTrackGrid` filtered to that genre. - **Why:** CMS analogue of the public `GenresView`; the card-to-grid expand is the cheapest second mode because the grid is already built (§8.2). - **Shape:** `GetGenreSummariesAsync` once; the expanded panel renders `CmsTrackGrid` with `GenreFilter` set and the Add button suppressed — zero duplicated table markup. The embedded grid gets the waveform status column + per-row generate for free. See notes §7, §9. **Completion note:** New `CmsGenreBrowser.razor` component implemented as a responsive card-grid accordion. Each card displays genre name and track count. Clicking a card expands it to reveal `CmsTrackGrid` filtered to that genre (Add button suppressed). One card open at a time. Grid embedded within each expanded panel inherits waveform status column and per-row generate actions. Zero duplicated table markup — consumes the single `CmsTrackGrid` source built in §8.2. ### 8.5 Batch Edit page **Landed:** 2026-06-11 on dev. - **What:** New page `/tracks/album/{albumName}/edit`, reached from an Album-mode row's Edit action. `BatchUpload`'s master-detail mechanics with the release's data preloaded; submit swaps per-row `UploadTrackAsync` for `UpdateAsync` on existing tracks (new tracks still upload). Distinct from the existing single-track edit at `/tracks/{id}`. - **Why:** Editing a release as a unit (rename tracks, reorder, swap cover, add tracks) without round-tripping the single-track editor per track. - **Shape:** **Confirmed:** a *new* `BatchEdit.razor` sharing extracted sub-components with `BatchUpload` — album-header fields block (post-§0 edits the `ReleaseDto`), batch track list (move-up/down/remove + status chips), track detail pane — over growing `BatchUpload` with an `isEdit` flag (the flag breeds conditional soup across preload/detail/submit). Cover art uses the established upload-once-then-link-via-`UpdateAsync` two-step. **Open:** does remove-in-edit delete an existing track (with confirm) or just detach? See notes §10, §12(8). **Completion note:** New `BatchEdit.razor` page implemented at `/tracks/album/{releaseName}/edit`. Shares extracted sub-components with `BatchUpload`: `AlbumHeaderFields`, `BatchTrackList`, `BatchTrackDetail`, `BatchRowModel`. Two-panel layout with release-header block (album name, artist, genre, release date, cover art, release type) and left queue + right detail sections. Submit path swaps per-row `UploadTrackAsync` for `UpdateAsync` on existing tracks; new tracks still upload. Cover art uploaded once, linked via `UpdateAsync`. Remove-in-edit deletes existing track with confirmation. Reusable sub-components extracted for consistency across `BatchUpload` and `BatchEdit`. --- ## Phase 1.2 — Audio format diversity **Landed:** 2026-06-11 on dev (all three waves complete). - **What:** Today `AudioProcessor`, `WavOffsetService`, and the JS decoder are PCM/WAV-only. `MimeTypeExtensions` already maps MP3, FLAC, Ogg, AAC, M4A — none are wired. - **Why it matters:** WAV-only is a real ceiling for any non-internal release. Distribution-grade formats (MP3, FLAC at minimum) are table stakes for a music site. - **Shape:** Two seams need a strategy pattern. - Server side: replace `AudioProcessor.ProcessWavFileAsync` with a format-router that selects a per-format processor; replace `WavOffsetService` with a per-format offset strategy (some formats — MP3, Ogg — have natural frame boundaries; FLAC has block headers; AAC has ADTS). - Client side: the JS decoder is currently a WAV byte-walker. For non-WAV, the simplest path is `decodeAudioData` over the full payload (loses streaming-start). The richer path is per-format chunked decoders. Worth a design pass before committing. - **Prerequisite:** None functionally, but consider settling **Phase 4 (HTTP Range)** first — native range/cache is much more important for large MP3s than for WAVs. - **Constraint:** Spectrum FFT tap currently relies on raw `AudioBuffer`s through `decodeAudioData`. If a future path uses `MediaElementAudioSourceNode` (see 4.1), the FFT tap still works but the early-playback story changes. **Completion note:** Fully landed across three waves on 2026-06-11. Server upload now accepts .wav/.mp3/.flac via `AudioProcessorRouter`. Client `StreamDecoder` is format-agnostic; `Mp3FormatDecoder` and `FlacFormatDecoder` provide chunked streaming with frame-boundary alignment and seek. Factory routing in `AudioPlayer.createFormatDecoder` selects decoder by Content-Type. --- ## Phase 7 — Shared UI Components ### 7.1 ParallaxImage component **Landed:** 2026-06-11 on dev. - **What:** A thin viewport-height container that reveals different portions of an image as the user scrolls — the classic CSS parallax window. As the window scrolls up through the viewport, the image pans through it faster than the page scrolls (top of image on entry, bottom of image by the time the window reaches the top of the viewport). An optional second image crossfades in on hover (intended use: grayscale at rest, colour on hover). A critical `FullWidth` flag stretches the window to `100vw`, breaking out of parent padding. Full signature and design in `product-notes/parallax-image-component.md`. - **Why it matters:** A reusable scroll flourish for hero/section surfaces on both the public site and the CMS, landing the visual identity work without bespoke per-page CSS. It is the first genuinely shared presentational component in `DeepDrftShared.Client` — establishes the pattern (and the RCL static-asset JS-module seam) for shared UI that both hosts consume. - **Shape:** `ParallaxImage.razor` (+ `.razor.cs`, `.razor.css`) in `DeepDrftShared.Client/Components/`. Scroll-driven `background-position` (never `background-attachment: fixed` — broken on iOS Safari), gated by an `IntersectionObserver` so off-screen instances cost nothing. Scroll math lives in a small JS module; lifecycle owned by Blazor via `ElementReference` + an imported `IJSObjectReference`, mirroring the existing audio interop seam. Crossfade is pure CSS. `IAsyncDisposable` tears down the listener. Full parameter table, parallax math, interop contract, full-width breakout technique, accessibility (reduced-motion, alt text), and edge cases (mobile Safari, preload timing) are specified in the product note. - **Prerequisite:** None functionally. Additive — no existing surface changes to adopt it. - **Constraint:** Both open decisions resolved (Daniel, 2026-06-11), no blockers remaining — TS toolchain added to the shared RCL with source co-located at `DeepDrftShared.Client/Interop/parallax/parallax.ts` → `wwwroot/js/parallax/parallax.js`, served from `_content/DeepDrftShared.Client/…` to both hosts; and parallax direction is exposed as the `InvertDirection` component parameter rather than hardcoded. See product note §6a/§11.1 and §3/§11.2. **Completion note:** `ParallaxImage.razor` + `.razor.cs` + `.razor.css` implemented in `DeepDrftShared.Client/Components/`. TS interop module at `DeepDrftShared.Client/Interop/parallax/parallax.ts` compiled to `wwwroot/js/parallax/parallax.js`. `Microsoft.TypeScript.MSBuild` 5.9.3 added to `DeepDrftShared.Client.csproj`, matching the pattern in `DeepDrftPublic`. Component exposes `InvertDirection` parameter for parallax direction; scroll-offset math and IntersectionObserver lifecycle owned by the TS module via `IJSObjectReference` interop. --- ## Phase 6 — CMS Enhancements ### 6.3 Batch Upload Page **Landed:** 2026-06-11 on dev. - **What:** Replace the single-track form at `/tracks/new` with a two-panel batch upload page that uploads many WAVs in one session under a shared album header. - **Why:** Uploading an album one track at a time is the current reality — re-entering album, genre, release date, cover art, and artist on every track. Batch upload makes "add a release" a single operation: set the shared header once, queue the tracks, submit. This is the dominant ingestion shape for the collective (releases, not loose singles). - **Shape:** - **Route:** New page at **`/tracks/upload`**. Justification: `/tracks/new` reads as "new single track" and the edit route is `/tracks/{id}`; `/tracks/upload` names the operation (batch ingestion) without colliding with the id-parameterised edit route. Repoint the "Add Track" button in `TrackList.razor` (currently `Href="/tracks/new"`) to `/tracks/upload`. Whether `/tracks/new` is retired or left as a redirect is staff-engineer's call; the committed change is that the button goes to the batch page. - **Data model change — `ReleaseType`:** Add a `ReleaseType` enum to `DeepDrftModels` (`enum ReleaseType { Single, EP, Album }`). Enum over string: three fixed values, and it gates UI (selector) and future grouping logic — a free-text column invites typos. Add a `ReleaseType` property to **`TrackEntity`** and **`TrackDto`**. Decide nullability: recommend **non-null with a default of `Single`** so existing rows backfill cleanly to a sensible value (a release of one track is a single) and the column is never null. This ripples to `TrackConfiguration` (EF mapping — store as string via `HasConversion()` for readable DB values, or as int; recommend string for legibility), `TrackConverter` (assign on round-trip), and the upload/update service signatures. **An EF migration is required** — author it via `dotnet ef migrations add`, never by hand. - **Data model change — `TrackNumber`:** Add a `TrackNumber` property (type `int`, **1-based, non-null**) to **`TrackEntity`** and **`TrackDto`** to store per-track ordinal position within a release. This ripples through `TrackConfiguration` (EF mapping) and `TrackConverter` (assign on round-trip) the same way `ReleaseType` does. **A second EF migration is required** — author it via `dotnet ef migrations add`, never by hand. May be combined into a single migration with the `ReleaseType` change — staff-engineer's call on whether to combine or keep separate. - **Shared-vs-per-track field split:** - *Shared (header strip, applied to every track in the batch):* album name, artist, album cover image (single upload), genre, release date, and `ReleaseType`. One album per batch — the entire batch is one release, and all release-level fields live in the header. - *Per-track (right detail panel):* track name, the individual WAV file, and that row's upload status. - **Layout (two-panel under a header strip):** - **Header strip** (full width, top): album name, artist `MudTextField`, single cover-art `InputFile` (reuse the `MudField` cover-art pattern from `TrackNew`, including the upload-on-submit behaviour), genre `MudTextField`, release-date field, and `ReleaseType` `MudSelect`. These bind to a single batch-header model. - **Left panel** (track queue): an ordered list of queued tracks; the row order *is* the release track order and reflects each track's `TrackNumber`. Each row shows track name, a reorder affordance (up/down `MudIconButton`s are the low-risk choice; drag-and-drop is a nice-to-have — see open questions), a remove button, and a per-row status indicator (queued / uploading / done / failed). A `+`/`InputFile` (with `multiple`) at the top or bottom of the list adds WAV files; each added file becomes a row with track name defaulted from the filename (sans extension). On submit, each track is assigned its `TrackNumber` (1-based) from its position in the list. - **Right panel** (selected-track detail): when a row is selected, show its editable fields — track name and the WAV file name/size/status. Selecting a different row swaps the detail. - **Add-files behaviour:** `InputFile multiple` → append a row per file. Default track name = filename without extension. New rows append to the end of the list, taking the next ordinal position. Keep the 1 GB per-file ceiling and the `.wav` validation from `TrackNew`. - **Submit behaviour:** Sequential, one request at a time — reuse the existing single-track upload path (`CmsTrackService.UploadTrackAsync`) in a loop. This mirrors the deliberately-sequential waveform backfill in `TrackList.GenerateAllMissing` ("one request at a time so a large backfill does not flood the API"). Per-track progress: each left-panel row reflects its state as the loop advances (`StateHasChanged` between rows). Cover-art upload happens **once** before the loop (upload the image, get the entry key, then pass/link it to every track) — do not re-upload the cover per track. On completion, snackbar a summary (`uploaded N, M failed`) and navigate to `/tracks`. Partial failure: completed tracks stay persisted; failed rows remain visible with their error so the admin can retry just those — do **not** roll back the batch. - **CmsTrackService surface:** No new method strictly required — the loop calls the existing `UploadTrackAsync` per track and the existing image upload/link path per batch. `UploadTrackAsync`'s signature gains `releaseType` and `trackNumber` parameters (ripples from the data-model change). If the cover-link follow-up (the `UpdateAsync` step `TrackNew` does today) is kept per track, that's existing surface too. - **API surface:** No new endpoints. Existing `POST api/track/upload` (per track) and `POST api/image/upload` (once per batch) cover it. `api/track/upload` and the metadata update endpoints gain `releaseType` and `trackNumber` in their payloads as a consequence of the entity change. - **Components:** `BatchUpload.razor` (page + header strip + orchestration), and reasonably a `BatchTrackRow` model class plus left-panel/right-panel as child components or inline sections — staff-engineer's structural call. - **Constraint — dual-write orphan risk:** Each track inherits the existing dual-write hazard (audio lands in the vault, SQL persist may fail → orphaned audio, no rollback). Batch upload *multiplies the exposure* (N tracks per session instead of one). The mitigation is **Phase 4.3 (dual-write rollback / dead-letter log)** — not a blocker for this feature, but this is the strongest argument yet for landing 4.3. Flag it as a known constraint; do not attempt per-batch transactional rollback (the dual-database split can't give it). - **Prerequisites:** - `ReleaseType` enum + `TrackNumber` field + `TrackEntity`/`TrackDto` changes + EF migration(s) must land first (it's the data-model floor for the whole feature, and ripples through `TrackConfiguration`/`TrackConverter`/service signatures). Could be a separate prep commit before the page work. - **Not blocked by** Phase 4.3, but 4.3 is the right mitigation for the amplified orphan risk and is worth sequencing alongside. - **Resolved (no longer open):** - **One album per batch.** The whole batch is one release; album name and all release-level fields (artist, genre, release date, `ReleaseType`, cover art) live in the shared header strip. A batch never mixes albums. - **Track ordinals are persistent** — `TrackNumber` (int, 1-based, non-null) stores per-track position within a release. The left-panel row order reflects `TrackNumber`, and each track is assigned its ordinal from its list position on submit. **Completion note:** `BatchUpload.razor` page implemented at `/tracks/upload`; two-panel layout with header strip (shared album/artist/genre/release-date/cover-art/release-type fields) and left queue + right detail sections for per-track track name and file selection. Sequential upload loop via existing `CmsTrackService.UploadTrackAsync`. Cover-art uploaded once at start; per-track progress reflected in left-panel status indicators. `TrackList.razor` "Add Track" button repointed to `/tracks/upload`. `ReleaseType` enum and `TrackNumber` int field added to `TrackEntity`, `TrackDto`, `TrackConfiguration`, `TrackConverter`, and EF migrations authored. `UploadTrackAsync` signature updated with `releaseType` and `trackNumber` parameters. --- ### 6.1 CMS Home Page — catalogue summary dashboard **Landed:** 2026-06-11 on dev. - **What:** Replace the redirect-to-`/tracks` at `Index.razor` (route `/`) with a real dashboard showing a grid of summary cards: total tracks, distinct albums, distinct genres. - **Why:** Quick orientation for the CMS admin — at-a-glance catalogue health on landing, instead of dropping straight into the table. First thing the admin sees, so it carries the bold DeepDrft palette rather than a conservative admin look. - **Shape:** - **Route / component:** Keep `Index.razor` at `/`; remove the `OnInitialized` redirect and render the dashboard. The CMS nav lands here; `/tracks` remains reachable from the nav and from the cards. - **UI:** A responsive `MudGrid` of three `MudCard`s (Tracks / Albums / Genres). Each card: an icon (`LibraryMusic`, `Album`, `Category` or similar), the metric as a large `Typo.h2`/`h3` number, and a label. Cards are clickable (`@onclick` → `Nav.NavigateTo`). Lean into the active MudBlazor palette — `Color.Primary`/`Color.Secondary` fills or accent borders, generous elevation — this is the visual-punch surface, not a muted KPI strip. Loading state: skeleton or per-card `MudProgressCircular` while the three fetches resolve. Each card fetches independently so one slow/failed call doesn't blank the others; a failed card shows a "—" with a retry affordance rather than collapsing the grid. - **Card navigation (Phase 6 scope):** All three cards navigate to `/tracks` (the track maintenance page). **Per-album / per-genre pre-filtering is deferred** — see 6.2. Ship the cards as plain links to `/tracks` now. - **Data model:** No entity changes. `AlbumSummaryDto` and `GenreSummaryDto` already exist in `DeepDrftModels`. - **API surface:** No new API endpoints. The three numbers are already available: - **Albums count** = length of `GET api/track/albums` (exists, unauthenticated, returns `List`). - **Genres count** = length of `GET api/track/genres` (exists, unauthenticated, returns `List`). - **Tracks count** = `TotalCount` from `GET api/track/page` (exists) requested with `pageSize=1` (cheapest paged call that still returns the total). - **CmsTrackService surface (new methods):** `ICmsTrackService` does not currently expose albums/genres. Add three thin proxy methods mirroring the existing pattern (e.g. `GetAlbumSummariesAsync`, `GetGenreSummariesAsync`, and a `GetTrackCountAsync` that calls `page?pageSize=1` and returns `TotalCount`). These are the only new code on the service. No controller work. - **Components:** `Index.razor` (dashboard host) plus, optionally, a small `SummaryCard.razor` for the repeated card — worth extracting given three near-identical cards, but staff-engineer's call. - **Prerequisites:** None. All backing endpoints and DTOs exist. **Completion note:** `DeepDrftManager/Components/Pages/Index.razor` redesigned as a 3-card dashboard grid (Tracks / Albums / Genres counts) with independent per-card fetches. Three new `ICmsTrackService` proxy methods (`GetAlbumSummariesAsync`, `GetGenreSummariesAsync`, `GetTrackCountAsync`) wired to existing public API endpoints. Cards navigate to `/tracks` on click. Failed cards show "—" fallback; each card loads independently. --- ## Phase 1.1 — Extended WAV format support **Status:** Fully landed on 2026-06-10 (IEEE Float SubFormat 0x0003 and Padded 24-in-32 container support implemented, tests passing). - **What:** Two EXTENSIBLE WAV sub-cases that were explicitly scoped out of the `WAVE_FORMAT_EXTENSIBLE` PCM fix (which shipped support for `audioFormat=0xFFFE` with a PCM SubFormat — the Bandcamp WAV download case). Both are currently rejected at `AudioProcessor.ValidateAudioParameters` and fall back to default metadata. The inline comments at `AudioProcessor.cs` (SubFormat check ~L182–188, BlockAlign note ~L225–230) mark them as accepted gaps as of that fix. - **EXTENSIBLE non-PCM SubFormats** — e.g. IEEE Float (32-bit float PCM, common in DAW exports). The SubFormat-GUID check accepts only PCM (`0x0001`) today; anything else is rejected outright. - **Padded-container EXTENSIBLE** — 24-bit valid samples in a 32-bit container (`wValidBitsPerSample=24`, container `bitsPerSample=32`). The BlockAlign check fails because the valid-bit depth (24) doesn't match the container's block align. - **Why it matters:** DAW exports — the dominant shape of source material as the collective uploads more of its own production — tend to be float WAV or padded 24-bit. The shipped fix covers consumer/Bandcamp WAVs but not the producer's working files. - **Shape:** Both live in the same seam as the shipped fix (`AudioProcessor` validation + the `NormalizeToStandardPcm` storage step), but the work differs by case: - *Float SubFormat:* requires float→integer sample conversion during the normalize-to-standard-PCM step (the vault stays integer-PCM so the streaming/decode pipeline is unchanged), or a Web Audio decode path that handles float directly. The conversion-at-storage option keeps the load-bearing streaming seam untouched and is the lower-risk path. - *Padded 24-in-32:* relax `ValidateAudioParameters` to tolerate the BlockAlign mismatch when `IsExtensible`, then normalize to the valid-bit depth (24) during storage so the stored WAV is canonical. - **Prerequisite:** None. Both are self-contained extensions of the WAV path that just landed; neither depends on the broader format-router work in 1.2. - **Relationship to 1.2:** Distinct from it. 1.2 is new *containers* (MP3, FLAC, Ogg) behind a format router; this is additional *WAV variants* on the existing PCM path. If 1.2's router lands first, these become per-variant branches inside the WAV processor rather than new processors. **Completion note:** IEEE Float SubFormat (0x0003) support added via `ConvertFloatTo24BitPcm` conversion at storage time; Padded 24-in-32 container support added via `RepackPaddedContainer` with relaxed `ValidateAudioParameters` BlockAlign check. Both cases tested in 8 new `AudioProcessorTests` cases. Vault stores standard 24-bit PCM in both cases; streaming/decode pipeline unchanged. --- ## Phase 2.2 + 2.3 — Album/genre views and gallery search/filter **Status:** Fully landed on 2026-06-10. - **What:** Free-text search (`?q=`) across TrackName/Artist/Album via `EF.Functions.ILike` (Postgres, case-insensitive); album/genre exact-match filtering (`?album=`, `?genre=`); new `/albums` browsing page (grid of album cards with cover art and track counts, linking to filtered gallery); new `/genres` browsing page (genre list with counts, linking to filtered gallery); search bar with 400ms debounce and filter-pill dismiss on `TracksView`. Nav updated with Albums and Genres links. - **Architecture:** Filter is threaded as a separate `TrackFilter` DTO alongside `PagingParameters` (which is external and cannot carry a where-clause). Repository has new `GetPagedFilteredAsync`, `GetDistinctAlbumsAsync`, `GetDistinctGenresAsync` methods. `PersistentComponentState` restore on `TracksView` is skipped when filter params are active. `ClearFilter` preserves `SearchText` (only clears album/genre pill). - **New types:** `TrackFilter`, `AlbumSummaryDto`, `GenreSummaryDto` in `DeepDrftModels/DTOs/`. - **Tests:** `TrackFilterQueryTests` in `DeepDrftTests` — 4 in-memory cases plus 1 Postgres-gated `ILike` case (skip when `DEEPDRFT_TEST_PG` env var absent). --- ## Phase 4.1 — HTTP Range + CDN caching **Status:** Fully landed on 2026-06-09 (implementation complete, all acceptance criteria met, merged to dev branch `p4-w1-range-streaming`). - **What:** Today's `?offset=` query parameter defeats HTTP caching — a CDN sees `?offset=1234567` as a distinct URL from the un-offset request. The architecture re-invents byte-range on top of a custom query param. Move the player's transport to standard HTTP `Range` headers against one canonical URL. - **Why it matters:** Material once the site has real listener traffic. Also relevant to non-WAV formats (1.2) where decoder-side seek is cheaper natively. - **Chosen approach (design pass 2026-06-09): Option A1 — Range headers in the JS fetch, keep the custom `AudioBuffer` decoder.** Rejected Option B (`MediaElementAudioSourceNode`): it surrenders early-playback (the `minBuffersForPlayback` start-as-soon-as-buffered behaviour, a listed quality feature) and forces a redesign of the waveform-seek and early-play UX, while delivering no caching benefit beyond what the HTTP layer already gives. Also rejected A2 (synthesised header delivered over Range): keeping `WavOffsetService` on the hot path means each `bytes=X-` request produces a distinct synthesised prefix that can't share cache lineage with the canonical `bytes=0-` object, defeating half the caching win. A1 makes the cached object the *real file*, so every Range request is a true sub-range of one entity. Key enabling insight: `StreamDecoder` already synthesises a per-segment 44-byte header internally for every `decodeAudioData` call (`createWavFile`), so a Range continuation only needs to *retain* the parsed `WavHeader` and feed raw PCM — it does not need a header in the network stream. - **Shape (implementation direction):** - **Server (`DeepDrftAPI/Controllers/TrackController.cs` ~L407):** flip `enableRangeProcessing: false → true` on the no-offset seekable `FileStream` path; ASP.NET Core slices natively and emits `206` + `Content-Range`. Leave the `?offset=` / `WavOffsetService` branch reachable but off the player hot path — its removal is a clean follow-up commit, not part of this change. - **Proxy (`DeepDrftPublic/Controllers/TrackProxyController.cs` ~L175):** forward the incoming `Range` request header upstream; pass through upstream status (`206`/`200`/`416`) and the `Content-Range` / `Accept-Ranges` / `Content-Length` response headers verbatim. The proxy is a transparent relay — it does **not** slice the (non-seekable) upstream stream. Keep `ResponseHeadersRead` + `RegisterForDispose`. - **Client transport (`DeepDrftPublic.Client/Clients/TrackMediaClient`):** send `Range: bytes={byteOffset}-` instead of the `?offset=` query param (`byteOffset == 0` → `bytes=0-`, single code path). Confirm `TrackMediaResponse.ContentLength` carries the 206 remaining-length for continuations and full length for the initial request. - **JS decoder (`StreamDecoder.ts` — the real work):** add a continuation mode. Replace `reinitializeForOffset` (which nulls `wavHeader` and re-parses) with a `reinitializeForRangeContinuation(remainingByteLength)` that **retains** the parsed `WavHeader`, resets `rawChunks`/`totalRawBytes`/`processedBytes`/`streamComplete`, and routes incoming bytes straight to `addRawData` (the existing `if (!this.wavHeader)` branch already does this when the header is set). Add an `isContinuation` flag so `updateStreamCompleteFlag()` uses `totalRawBytes` **without** the `+ headerSize` addend on continuations. `createWavFile`, the decode pipeline, and the spectrum/level tap are all unchanged. - **`AudioPlayer.ts` / `index.ts`:** keep the public `reinitializeFromOffset` interop name (so `AudioInteropService` and the C# caller are untouched); internally call the continuation reinit. C# `StreamingAudioPlayerService.SeekBeyondBuffer` is otherwise unchanged. - **Acceptance criteria:** 1. Initial load sends `Range: bytes=0-`; server responds `206`/`200` with `Accept-Ranges: bytes`; time-to-first-audio unchanged (early playback after `minBuffersForPlayback`). 2. Seek-beyond-buffer sends `Range: bytes=X-` (block-aligned, file-absolute X) with **no `?offset=` anywhere**; server responds `206` + `Content-Range`; audio resumes with no click/pop and no header bytes leaking into PCM. 3. Displayed total duration is unchanged across a seek (original full-track duration, not remaining-segment). 4. A track seeked-near-end then played out fires the end callback exactly once (continuation `streamComplete` math correct). 5. Spectrum visualiser and `LevelMeterFab` behave identically pre/post on a loud master (−3 dBFS). 6. Same-URL invariant: two different-offset requests hit an identical URL differing only in the `Range` header (verifiable in the network panel; live CDN cache-hit verification is out of scope — no CDN in dev). 7. No `MediaElement` introduced; the `AudioBufferSourceNode` graph remains the playback path. - **Constraints (non-obvious):** - **Range offset is file-absolute, not audio-relative.** The old `?offset=` contract was audio-data-relative (`WavOffsetService` added `HeaderSize` server-side). The Range offset must be `header.headerSize + blockAlignedAudioOffset`. Omitting `headerSize` lands the seek ~44 bytes early — audible click + position drift. **Most likely bug; verify first.** - Only the *continuation* skips header parse; the initial `bytes=0-` response still flows through `tryParseHeader` unchanged. Don't let the continuation flag bleed into initial load. - Proxy must pass `Accept-Ranges` / `Content-Range` (and a `416`) through verbatim — stripping them blinds the browser and any future CDN. - A1 preserves the multi-format (1.2) seam: the decoder stays the format integration point; the "retain format, skip header, treat bytes as frame data" pattern generalises (frame-boundary alignment differs per format). Add no new WAV-specific coupling in the transport/proxy layers beyond what already exists. --- ## Phase 4.2 — Server-side stream from disk (no buffer materialisation) **Status:** Resolved as a consequence of Phase 4.1 landing on 2026-06-09. No separate implementation required. - **What:** The no-offset path **already** streams from disk — `TrackController` (~L390) takes `mediaStream.Stream` (a `FileStream` from `LoadResourceStreamAsync`), reads `streamLength` from `.Length`, and hands ownership to `File(...)`; no `LoadResourceAsync` buffer materialisation on the default path. The remaining buffer materialisation is **only** the legacy `?offset=` branch (~L414): `GetAudioBinaryAsync` loads the full `AudioBinary` into memory because `WavOffsetService` reslices over the in-memory buffer. - **Why it matters:** Scaling ceiling on the offset path specifically. Once 4.1 (A1) lands, the offset branch is off the player hot path, so its buffer cost stops mattering in practice. - **Shape:** Resolved for the default path. The only outstanding work is retiring the offset branch entirely — which is the 4.1 follow-up commit (remove the `?offset=` server branch, `WavOffsetService`, and the now-unused `ConcatStream`). No separate work item beyond that cleanup. - **Outcome:** With Phase 4.1 landing and Range headers replacing the `?offset=` query param as the transport mechanism, the offset branch is now definitively off the player's hot path. Buffer materialisation on that dormant code path is no longer a scaling concern. 4.2 is closed; the offset-branch cleanup is a follow-up housekeeping item, not a blocker. --- ## Phase 2.4 — Interactivity-gap loading guard on dead-during-prerender controls **Status:** Fully landed on 2026-06-08 (implementation complete, reviewed and merged to dev). Guard controls that are dead during the SSR→interactive handoff window (1–2s on fast loads, 5s+ on cold WASM cache) so they *look* inactive until the Blazor runtime attaches, then re-render into their live form. The listener reaches for **play** first — a play button that looks armed but eats the click reads as "the site is broken," not "the site is loading." This is a credibility/perceived-quality fix on the primary action. **Implementation approach:** Extend the existing `RendererInfo.IsInteractive` pattern already established in `PlayStateIcon.razor` and `DeepDrftHero.razor`. Add `Disabled="@(!RendererInfo.IsInteractive)"` (or the HTML equivalent) to unguarded controls during the SSR phase. No global overlay/scrim (rejected — it fights the prerender's purpose and risks colliding with Blazor's `#components-reconnect-modal`); per-control guarding leaves the working parts (plain `` links, idle UI) live. Each control carries its own inline gate — mild duplication over a shared `` wrapper is deliberately accepted (over-engineering for ~4 call sites; would obscure the per-control rendering differences). Consistent with existing patterns. **Guarded controls (as implemented):** - **`TrackCard.razor` play `MudFab` (grid + list mode) — HIGHEST PRIORITY.** Disabled during the gap (greyed, non-interactive via MudBlazor's built-in disabled state). Card looks *composed but not-yet-armed*, not alarmed. Re-enables once `RendererInfo.IsInteractive` flips. Note: `/tracks` bridges *data* across the seam via `PersistentComponentState` — but bridging data ≠ wiring handlers; the gap still exists on a cold WASM cache load. - **`TracksView.razor` `MudToggleGroup` (grid/list switch) + `MudPagination`.** Both gated to `Disabled="true"` during the gap. Lower priority than play, but cheap to include in the same pass and visually consistent. - **`SharePopover.razor` (on `TrackDetail`).** The Share `MudIconButton` trigger gated to `Disabled="true"` until interactive; the in-popover copy buttons are moot while the trigger is disabled, so the single guard on the trigger suffices. - **`DeepDrftMenu.razor` "Stream Now" CTA.** Folded `!RendererInfo.IsInteractive` into the existing `disabled="@(...)"` expression (e.g. `disabled="@(_streamLoading || !RendererInfo.IsInteractive)"`) on both desktop and mobile buttons. The label-swap precedent here ("Finding a track…") is the house voice — disabling is the floor. **What was deliberately left untouched (mirrors `WASM_SEAMS.md` §2 discipline):** - **Minimized `AudioPlayerBar` dock** — default state shows only `LevelMeterFab`, which is idle (untinted, no animation) until audio plays. Reads correctly during the gap; nothing to guard. - **Expanded `AudioPlayerBar` transport zone** — already routes its play/pause glyph through the guarded `PlayStateIcon`. Already covered by the existing pattern. - **`NowPlaying` / `NowPlayingCard`** — reflect live player state; show "Nothing playing" on both passes on a cold load. No dead control; the player is gesture-gated and intentionally non-persisted. - **Plain `` links** (track titles → `/track/{key}`, nav links, hero CTAs) — work in static SSR. Out of scope by construction. **Coexistence constraint:** This guard targets the *initial* SSR→interactive handoff. It does not duplicate or interfere with Blazor's built-in `#components-reconnect-modal` (dropped-circuit recovery, a different lifecycle event). The two are orthogonal — `RendererInfo.IsInteractive` does not flip back to `false` on a *reconnect*, so the guards correctly stay inactive during a reconnect. **Prerequisite:** None. Pure client-side rendering work in `DeepDrftPublic.Client`; no API or data-layer change. --- ## LevelMeterFab — Continuous vertical fill animation **Status:** Fully landed on 2026-06-08 (feature complete, component + CSS animation, merged to dev). Replaced the discrete three-band tint model with a **continuous vertical fill** inside the music-note SVG silhouette. The fill height tracks live audio level bottom-up (0–100%); a fixed three-zone gradient (`linearGradient` with `gradientUnits="userSpaceOnUse"`) renders green (0–60% of note height), yellow (60–85%), and orange (85–100%) zones. The color at the fill line therefore changes naturally as the level rises. The note shape remains always visible as a dim silhouette at 25% opacity; idle (paused/stopped) shows the silhouette alone. **Implementation details:** - **C# side (`LevelMeterFab.razor.cs`)**: Removed discrete `_bandClass` field; replaced with continuous `_fillPercent` (0–100). dB → fill % uses a linear map over a −30 to 0 dB window (−30 dB = 0% fill, 0 dB = 100%, −12 dB = 60% / yellow boundary, −4.5 dB = 85% / orange boundary). Smoothing envelope operates on the continuous value (attack-fast / release-slow on dB, then map). Computed properties `FillY` and `FillH` expose the rect geometry to the SVG template. - **SVG (`LevelMeterFab.razor`)**: Two layers — always-on dim silhouette (note path at 25% white) and a clipped fill group (rectangle revealed through the note via `clipPath`, painted with the zone gradient). No color cascade; explicit rgba on silhouette, explicit colors in gradient stops. - **Gradient anchoring**: `linearGradient` with `gradientUnits="userSpaceOnUse"` (not `objectBoundingBox`) — x1="0" y1="24" x2="0" y2="0" (bottom to top in viewBox coordinates). This pins the zones to fixed heights so the fill line always crosses the same colors at the same levels. - **CSS (`LevelMeterFab.razor.css`)**: Removed band-tint color transition (no longer applicable). Geometry attributes `y` and `height` are not CSS-animatable in a reliable way; animation is purely the 30fps C# value updates driven by smoothing envelope. Silhouette remains always-on idle visual when `_fillPercent = 0`. - **Re-render gate**: 0.5% change threshold prevents churn on sub-pixel deltas; renders only on meaningful level swings. - **Idle behavior**: `StopAnimation` resets `_fillPercent = 0` and `_smoothedDb = SilenceFloorDb`, dropping the column and leaving only the dim silhouette. Supersedes the earlier discrete-tint `LevelMeterFab` entry from the same component. The new model is load-bearing for real-time level feedback on a commercial dance-music master (−8 to −3 dBFS); the meter "breathes" through the green/yellow zones with peaks reaching orange, rather than holding in one band. --- ## Track Gallery View Toggle **Status:** Fully landed on 2026-06-08 (feature complete, component + layout + CSS, merged to dev). ### Overview Give the track gallery two switchable view modes behind a page-level toggle: **Mode A — Album Art Grid** (the current responsive 4-column `MudGrid` of 250×250 cards, augmented so that art-bearing cards hide their info overlay at rest and reveal it on hover) and **Mode B — Track Detail List** (a vertical stack of full-width horizontal rows, each a compact track line with play FAB, art thumbnail, artist/title text block, and right-aligned genre/year). The toggle is a two-option control at the top of `TracksView`, defaulting to Grid, with ephemeral page-level state (not persisted). Both modes consume the same `ViewModel.Page.Items` and the same per-card play-state inputs — the only divergence is in `TrackCard`'s rendering, consistent with the "one source, multiple views" convention (`CONTEXT.md §6`). ### Component changes - **`TracksView.razor` / `.razor.cs` / `.razor.css`** — Add an ephemeral `ViewMode _viewMode = ViewMode.Grid` field and a handler that flips it and calls `StateHasChanged()`. Render the toggle control above `tracks-content` (see Toggle spec). Pass `ViewMode="@_viewMode"` into ``. No change to data flow, persistence, or player-state subscription. CSS: a flex row for the toggle header (`justify-content: flex-end`). - **`TracksGallery.razor` / `.razor.cs` / `.razor.css`** — Add `[Parameter] public ViewMode ViewMode { get; set; } = ViewMode.Grid;`. Branch the template: for `Grid`, keep the existing `MudGrid` / `MudItem` breakpoint layout unchanged; for `List`, render a single flex-column container (`deepdrft-track-list`) that `@foreach`-es the same `Tracks` into `` rows with no `MudGrid` wrapper. Pass `ViewMode="@ViewMode"` down to each `TrackCard`. The `ActiveTrack` / `IsPlaying` / `IsPaused` / `OnPlay` / `OnPause` wiring is identical in both branches. - **`TrackCard.razor` / `.razor.cs` / `.razor.css`** — Add `[Parameter] public ViewMode ViewMode { get; set; } = ViewMode.Grid;`. Branch the markup at the top: `ViewMode.Grid` renders the existing card body unchanged (plus the hover behaviour below); `ViewMode.List` renders the horizontal row layout (see Mode B spec). The `hasLink` / `trackHref` computation, `PlayClick`, and `PlayPauseIcon` are shared across both. The `ViewMode` enum lives in a small shared file (e.g. `Controls/GalleryViewMode.cs` or alongside `TrackCard.razor.cs` in the `DeepDrftPublic.Client.Controls` namespace) so both `TracksView`, `TracksGallery`, and `TrackCard` reference one definition. ### Mode A — hover spec (pure CSS, no JS) - Applies **only** when the card has album art (`deepdrft-track-card-bg` present). The no-art fallback path (`deepdrft-track-card-fallback`) is untouched — its `deepdrft-track-card-content` stays visible at all times exactly as today. - For art-bearing cards: give `deepdrft-track-card-content` an `opacity: 0` rest state and `opacity: 1` on `.deepdrft-track-card-container:hover .deepdrft-track-card-content`. Add `transition: opacity 180ms ease, background-color 180ms ease`. - Swap the rest gradient for a **solid navy panel on hover**: at rest the content overlay is transparent/hidden; on hover its background becomes `var(--deepdrft-navy-mid, #162437)` (opaque, full-card) so the info reads cleanly over the art rather than through a gradient. Implement by toggling the `background` on the content layer between transparent (rest) and solid navy (hover), or by fading in a sibling navy panel beneath the content — implementer's call; the observable result is a solid navy reveal, not the current always-on gradient. - Distinguish art vs. no-art in CSS without new markup by scoping the hide/reveal rules to a container modifier. Add a class to the container when art is present (e.g. `deepdrft-track-card-container--art`) and gate the `opacity: 0` rest rule on it, so fallback cards never pick up the hidden-at-rest behaviour. - Touch devices have no hover; on coarse pointers the overlay should default to visible. Guard the hidden-at-rest rule with `@media (hover: hover) and (pointer: fine)` so touch users always see the info. ### Mode B — list row spec - Container: `deepdrft-track-list` is `display: flex; flex-direction: column; gap: 8px;` inside the existing `MudContainer MaxWidth="Large"`. Rows are full-width. - Row (`deepdrft-track-row`): `display: flex; flex-direction: row; align-items: center; gap: 16px;` with `height: ~72–88px`, `padding: 8px 16px`, and the same glass treatment as grid cards — `background: var(--deepdrft-navy-mid, #162437)`, off-white text, `border: 1px solid rgba(250,250,248,0.12)`. This reads on both light and dark themes (matches the fallback-panel rationale already documented in `TrackCard.razor.css`). - Columns, left to right: 1. **Play FAB** — fixed-width column, vertically centered. Same `` as grid mode (reuse, do not duplicate logic). 2. **Art thumbnail** — square `~64px` (`flex: 0 0 64px`), vertically centered. Reuse the art `background-image` div for art-present; a `deepdrft-track-card-fallback`-style navy square for art-absent. 3. **Text block** — `flex: 1 1 auto; min-width: 0;` two stacked rows: Artist (`Typo.subtitle1`, `deepdrft-track-artist`-weight) on top, Track Name (`Typo.caption`/body, `deepdrft-track-title`) below. Both `text-truncate`. Note the visual order here is Artist-over-Title, inverse of the grid card — intentional per the row sketch. 4. **Right metadata** — fixed/`flex: 0 0 auto` column, `text-align: right`, two stacked rows: Genre chip (`MudChip`, same green-accent outline styling) top-right, Year caption bottom-right. - Linking: wrap the art + text columns in the same `` pattern used by the grid card, so the row navigates to `/track/{EntryKey}` while the FAB (outside the anchor) remains the sole playback entry point. Preserve the `display: contents` approach so the flex row layout is unaffected by the anchor. - The active-state icon (`PlayPauseIcon` driven by `IsPlaying`/`IsPaused`) works identically — no list-specific play-state logic. ### Toggle spec - Component: `MudToggleGroup` with two `MudToggleItem`s (icon-only), or a pair of `MudToggleIconButton`s — `MudToggleGroup` is the cleaner fit for a 2-value exclusive switch. Icons: `Icons.Material.Filled.ViewModule` (Grid) and `Icons.Material.Filled.ViewList` (List). - Placement: top of `TracksView`, above `tracks-content`, aligned right. Sits in its own header row; does not displace the existing centered gallery or the footer pagination. - Binding: `@bind-Value="_viewMode"` (or `SelectedValue` + `SelectedValueChanged`) on the toggle; the setter triggers re-render. State is a plain page field — **not** persisted to cookie or `PersistentComponentState`. - Default: `ViewMode.Grid`. - Skeleton/loading state (`ViewModel.Page == null`) is unaffected — keep the existing skeleton grid; the toggle may render disabled or hidden while loading (implementer's call). ### Acceptance criteria - The TracksView page shows a two-option grid/list toggle, right-aligned at the top, defaulting to grid. - **Grid mode, art card:** at rest the card shows only album art (no title/artist/genre/year/FAB overlay); on hover a solid navy panel fades in over the art revealing all info and the play FAB; moving the pointer away hides it again. Transition is smooth (~180ms), no flicker. - **Grid mode, no-art card:** the navy fallback card shows title/artist/genre/year/FAB at all times, with no hover change — identical to current behaviour. - **Touch / coarse-pointer devices:** grid art cards show their info overlay by default (no permanently hidden info). - **List mode:** tracks render as a vertical stack of full-width rows, each ≤~88px tall, with play FAB at far left, ~64px art thumbnail (or navy placeholder), artist-over-title text block, and right-aligned genre chip over year. - Clicking a row (outside the FAB) navigates to that track's detail page; clicking the FAB plays/pauses without navigating, in both modes. - The play/pause icon and active state reflect the live player exactly as in grid mode, in both modes. - List rows are legible on both light and dark themes. - Toggling between modes is instant, preserves the current page and player state, and resets to grid on page reload (no persistence). ### Out of scope - Persisting the selected view mode (cookie / `PersistentComponentState` / query string) — explicitly ephemeral this ticket. - Mobile-specific gestures (long-press, swipe) beyond the coarse-pointer hover fallback above. - Keyboard navigation beyond what the anchor + `MudFab` give by default; no roving-tabindex or arrow-key list traversal. - Any change to sorting, filtering, pagination, or the `TracksViewModel` data path. - Album/genre grouping views (covered separately under Phase 2.2). - Animation of mode transitions (cards/rows reflowing) — a plain re-render is acceptable. --- ## Phase 2.5 — "Stream Now" — random-track instant play **Status:** Fully landed on 2026-06-07 (feature complete, endpoints + service methods + menu wiring, merged to dev). - **What:** The nav-bar "Stream Now ▶" CTA (desktop and mobile, in `DeepDrftMenu.razor`) today just navigates to `/tracks`. Change it to **pick a random track from the library and start playing it immediately**, in place, without forcing the user onto the gallery page. - **Why it matters:** It is the single most prominent call-to-action on the site and currently does the least interesting thing — it dumps the listener on a grid and asks them to choose. "Stream Now" should mean *now*: one click, music plays. It is also the lowest-friction way for a first-time visitor to hear the collective's output, which is the whole point of the public site. Borrowed pattern: the "shuffle play" / "I'm feeling lucky" affordance (Spotify's shuffle, Bandcamp's "play random"). #### UX flow 1. User clicks "Stream Now ▶" (desktop CTA or mobile menu item). 2. Button enters a brief loading affordance (disabled + subtle pulse/spinner) while a track is selected — the selection requires at least one HTTP round-trip, so this is not instantaneous. 3. A random track is chosen from the full library via `GET api/track/random` (server-side `ORDER BY RANDOM() LIMIT 1`). 4. The player begins streaming that track via the existing `AudioPlayerBar` dock at the bottom of the layout. The dock is already cascaded into every page by `AudioPlayerProvider` in `MainLayout`, so it appears/animates in exactly as it does when a gallery card is clicked. 5. The user does **not** navigate. They stay on whatever page they were on (most likely `Home`). Music plays; the dock is the player surface. 6. On mobile, the menu closes (`CloseMobileMenu`) as part of the click, same as the existing nav links. #### Edge cases - **Empty library (`TotalCount == 0`):** No track to play. The button surfaces a non-blocking, transient message ("No tracks yet") and does nothing else. Does not navigate, does not error-toast aggressively. This is a legitimate cold-start state, not a failure. - **Metadata fetch fails (HTTP error):** Surfaces a transient error on the button ("Couldn't reach the library — try again"), re-enables the button, does not navigate. Reuses the existing `ApiResult` failure check pattern (`result is { Success: true, ... }`). - **Track fails to stream (selected track is valid metadata but the audio stream errors):** Already handled downstream by `StreamingAudioPlayerService` / error handlers and surfaced through `IPlayerService.ErrorMessage` and the dock. Stream Now does not duplicate stream-error handling in the menu; it hands off to the same `SelectTrackStreaming` path every other play uses, and inherits that path's error behavior. - **Player already playing something:** Stream Now interrupts it and starts the random track. No confirmation prompt — "Stream Now" is an explicit user command to play something new. - **Repeat clicks / same-track-twice:** Acceptable for v1 to occasionally re-pick the currently-playing track. If it becomes annoying, a cheap "exclude `PlayerService.CurrentTrack?.Id`" filter on the candidate set is a one-line follow-up; noted for future. #### Implementation **API endpoint (`DeepDrftAPI`):** - New `GET api/track/random` (unauthenticated, mirroring `GET api/track/page`) returning a single `TrackDto` via `ORDER BY RANDOM() LIMIT 1` (or the EF-Core equivalent) server-side. **Service methods:** - New method on `ITrackDataService` / `TrackClientDataService`: `Task> GetRandomTrack()`, calling `GET api/track/random` via `TrackClient`. **Menu wiring (`DeepDrftMenu.razor`):** - Injects `ITrackDataService` and cascaded `IStreamingPlayerService`. Click handler: calls `GetRandomTrack()`, on success calls `PlayerService.SelectTrackStreaming(track)`, on empty/failure shows transient message. **AudioContext user-gesture constraint:** - Browsers (Safari most strictly) only allow an `AudioContext` to start inside a user-gesture call stack. `SelectTrackStreaming` starts the context. Stream Now does an `await GetRandomTrack()` (network) before calling `SelectTrackStreaming` — an intervening `await` can lose gesture context on Safari. Mitigation: `IStreamingPlayerService.WarmAudioContext()` method added, called synchronous with the gesture at the start of the click handler, before the network await. #### Acceptance criteria — as implemented - Clicking "Stream Now ▶" (desktop CTA) with a non-empty library selects a track uniformly at random (server-side) and begins streaming it via the existing dock, without navigating away. - Clicking "Stream Now ▶" in the mobile menu does the same and closes the mobile menu. - Selection issues **exactly one** HTTP request (`GET api/track/random`). - With an empty library, the button shows a transient "no tracks" message and does not navigate or throw. - With a failed metadata fetch, the button shows a transient error, re-enables, and does not navigate. - A track that streams-errors after selection surfaces through the *existing* player error path — no new error handling in the menu. - The menu component contains no track-fetch logic inline: selection goes through `ITrackDataService.GetRandomTrack()`; playback goes through `PlayerService.SelectTrackStreaming`. No duplication. - Audio plays on the first click after a cold load on Chrome and Safari — user-gesture/AudioContext constraint satisfied via `WarmAudioContext()` hook. - While selection is in flight, the button is disabled to prevent double-launch. --- ## Phase 2.1 — Cover art / image vault wired through **Status:** Fully landed on 2026-06-07 across three waves (Wave 1: API + vault; Wave 2-A: public proxy + TrackCard; Wave 2-B: CMS upload UI), merged to dev. - **What:** `MediaVaultType.Image` is implemented end-to-end and exercised by tests, but the production surface only registers a `tracks` vault of type `Audio`. `ImagePath` on `TrackEntity` is a free-form URL string today; it should resolve to an entry in an image vault served by `DeepDrftContent`. - **Why it matters:** Prerequisite for any album/release/genre view that wants to look like a music site rather than a list of rows. Also closes a free-form-string surface area that will otherwise calcify. - **Shape:** - Register a second vault (`images` or `art`, type `Image`) in `Startup.ConfigureDomainServices` and in the CLI. - Add `GET api/image/{entryKey}` (unauthenticated, mirrors track read) and `PUT api/image/{entryKey}` (ApiKey, mirrors track write) on `DeepDrftContent`. - Change `TrackEntity.ImagePath` semantics from "URL" to "image vault entry key" (column rename optional — could remain `image_path` with semantic shift, or could become `image_entry_key` for clarity). - Add an image processor sibling of `AudioProcessor`. - **Prerequisite:** None. - **Constraint:** This is a small schema-semantics migration. Existing rows have `null` ImagePath in production so there is no data to migrate, but commit before the field has real content to avoid a backfill. --- ## Embeddable iframe player **Status:** Feature complete on 2026-06-07 (commit `c83b132 feature: Embed Frame Player`, merged to dev). A standalone, chrome-free player surface intended for embedding in an `