58 Commits

Author SHA1 Message Date
daniel-c-harvey 1fdbec2533 Merge cors-manager-origin into dev
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m15s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m35s
2026-06-23 08:21:33 -04:00
daniel-c-harvey 70842cb576 docs: add production install checklist 2026-06-23 08:15:56 -04:00
daniel-c-harvey f2a0d39521 config: add app.deepdrft.com to API CORS allowlist 2026-06-23 08:15:55 -04:00
daniel-c-harvey 1bda2b7bea docs: reflect Phase 23 SEO crawl directives as landed
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m29s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m7s
Deploy DeepDrftManager / Deploy (push) Successful in 1m23s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-23 07:40:57 -04:00
daniel-c-harvey 8773803712 feature: og default image 2026-06-23 07:40:42 -04:00
daniel-c-harvey 3cc11bcbb5 Merge p23-w1-t2-cms-noindex into dev
Phase 23 Track B: make DeepDrftManager uncrawlable — static robots.txt (Disallow: /) + blanket noindex meta in the CMS head. No env gate; the CMS is always uncrawlable.
2026-06-23 07:36:01 -04:00
daniel-c-harvey 0ba4fc6597 Merge p23-w1-t1-public-crawl-endpoints into dev
Phase 23 Track A: env-gated /robots.txt + /sitemap.xml on DeepDrftPublic. Thin controller + pure builders, reuses api/release + ReleaseRoutes + SeoOptions.BaseUrl. Non-prod uncrawlable; sitemap loc equals page canonical by construction.
2026-06-23 07:35:52 -04:00
daniel-c-harvey 7a0ccdd784 fix: correct WalkPageSize to 100 (actual server PageSize cap) and update comment 2026-06-23 07:33:24 -04:00
daniel-c-harvey ca057dc630 chore: make DeepDrftManager uncrawlable and noindex (Phase 23.3)
Static robots.txt (Disallow: /) in wwwroot + blanket noindex meta in App.razor head. No env gate — the CMS is always uncrawlable. Defense in depth per spec OQ-C1.
2026-06-23 07:23:49 -04:00
daniel-c-harvey 5f4807cc4a feature: Phase 23 Track A — env-gated /robots.txt + /sitemap.xml public crawl endpoints 2026-06-23 07:23:42 -04:00
daniel-c-harvey 9a4b79d377 docs: spec Phase 23 — SEO crawl directives (sitemap.xml, robots.txt, CMS noindex) 2026-06-23 07:10:20 -04:00
daniel-c-harvey 33383cd675 Merge p22-w2-jsonld-type-fix into dev
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m20s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m25s
Fix JSON-LD @type serialization: concrete nodes were emitting a bare Type alongside @type because the attribute sat only on the abstract base override. Validator now clean.
2026-06-23 06:57:44 -04:00
daniel-c-harvey 56f7013314 fix: put [JsonPropertyName("@type")] on each concrete JsonLdNode override
System.Text.Json emitted both "@type" and a bare "Type" because the attribute was only on the abstract base member. Adds regression assertions for all node types.
2026-06-23 06:57:05 -04:00
daniel-c-harvey 2653e62eeb docs: reflect Phase 22 SEO metadata component as landed 2026-06-23 06:21:52 -04:00
daniel-c-harvey 45bd599bdd Merge p22-w1-seo-metadata-component into dev
Phase 22: parameterized SEO metadata component for the public site — SeoHead + typed JSON-LD builders, per-medium release schema, env-gated noindex (beta uncrawled), inline-safe JSON-LD escaping.
2026-06-23 06:16:31 -04:00
daniel-c-harvey f976af0f7c fix(seo): escape inline JSON-LD, per-release byArtist, soft-404 + env-gated noindex
Escape </>& in JSON-LD body to kill script-breakout; byArtist now uses the release artist; detail-page not-found branches emit noindex; default robots gated to Production via a PersistentState SeoEnvironment bridge.
2026-06-23 06:10:03 -04:00
daniel-c-harvey f3b89ca9d7 feature: Phase 22 SEO metadata component for public site
One presentational SeoHead renders the full OG/Twitter/JSON-LD head surface at prerender via typed schema.org builders. Per-medium release schema, config-sourced canonicals, 404 noindex. Zero CMS change.
2026-06-23 05:41:55 -04:00
daniel-c-harvey 8752fc0c98 docs: resolve Phase 18 OQ7 seek-index granularity to 0.5s buckets 2026-06-23 05:36:25 -04:00
daniel-c-harvey 274d0ace62 Merge install-prep-analysis: installer prompts for AuthBlocks:Email:From 2026-06-23 05:28:17 -04:00
daniel-c-harvey e3a4364b8c docs(plan): Phase 18 OQ resolutions + VBR-safe accurate Opus seek model 2026-06-23 05:26:58 -04:00
daniel-c-harvey 564b704803 fix(installer): prompt for and write AuthBlocks:Email:From
Without this field, DeepDrftAPI throws InvalidOperationException on
startup. Adds the EMAIL_FROM prompt after EMAIL_TOKEN, writes "From"
into the Email JSON object, and unsets the variable on cleanup.
2026-06-23 05:26:48 -04:00
daniel-c-harvey 6af6677a12 docs: spec Phase 22 — parameterized SEO metadata component (public site) 2026-06-23 05:12:31 -04:00
daniel-c-harvey 1bdaeaa164 docs(plan): add Phase 18 Opus low-data streaming; resolve Phase 21 OQ5 (no MSE) 2026-06-23 04:58:21 -04:00
daniel-c-harvey a84a99c309 docs: spec Phase 21 — windowed streaming buffer for bounded client memory 2026-06-23 00:14:44 -04:00
daniel-c-harvey 2c1571057a feature: Manager Menu Styles and Page Titles
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m13s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m23s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m4s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m28s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-22 23:04:49 -04:00
daniel-c-harvey 0b7d8e41e7 Merge account-nav-menu into dev 2026-06-22 22:42:48 -04:00
daniel-c-harvey 4833935925 feature: About Bio text 2026-06-22 22:41:39 -04:00
daniel-c-harvey 7917d56af3 feature: Manager Logos 2026-06-22 22:41:30 -04:00
daniel-c-harvey 1fd63fe368 Add AccountNavMenu to CmsLayout nav drawer 2026-06-22 22:39:21 -04:00
daniel-c-harvey 4e1f540945 Merge bump-cerebellum-final into dev 2026-06-22 22:28:10 -04:00
daniel-c-harvey 1ed518b018 chore: bump Cerebellum stack to NetBlocks 10.3.32 / BlazorBlocks 10.3.35 / AuthBlocks 10.3.39
Delivers the ResultDtoBase.From() null-crash fix to DeepDrft's
Users/Registrations pages.
2026-06-22 22:27:57 -04:00
daniel-c-harvey 7c41aa678d Revert "Merge bisect-match-skipper into dev"
This reverts commit 475e5e671c, reversing
changes made to 0d1da9e63c.
2026-06-22 12:47:02 -04:00
daniel-c-harvey 475e5e671c Merge bisect-match-skipper into dev 2026-06-22 12:24:00 -04:00
daniel-c-harvey 9971474403 bisect: pin DeepDrftHome to Skipper's known-good package versions
AuthBlocks* → 10.3.35, BlazorBlocks* → 10.3.32. Diagnostic downgrade to
isolate null-ref crash on Users/Registrations pages.
2026-06-22 12:23:19 -04:00
daniel-c-harvey 0d1da9e63c docs: note Phase 20 visualizer-flash fix (coalesced --player-height publish) 2026-06-22 08:38:55 -04:00
daniel-c-harvey d47c186045 Merge p20-theater-visualizer-flash into dev 2026-06-22 08:36:05 -04:00
daniel-c-harvey 670eaab34d fix(visualizer): coalesce --player-height publish so Theater ease doesn't thrash the WebGL backing store 2026-06-22 08:19:53 -04:00
daniel-c-harvey c58b1c9386 Merge bump-cerebellum-deps into dev 2026-06-21 11:55:40 -04:00
daniel-c-harvey 450204cdbf Bump Cerebellum packages to fix null-Items crash on Users/Registrations pages
AuthBlocks → 10.3.38, BlazorBlocks → 10.3.34, NetBlocks → 10.3.31.
Pulls server-side null-Items guard (AuthBlocks) and BlazorBlocks render
guard. Direct refs for BlazorBlocks/NetBlocks raised to avoid NU1605
downgrade conflicts with AuthBlocks 10.3.38's transitive requirements.
2026-06-21 11:50:05 -04:00
daniel-c-harvey 5c22c1626a docs: reflect Phase 20 Wave 2 theater refinements (full-screen body, eased collapse, playing-release scoping) 2026-06-21 10:18:19 -04:00
daniel-c-harvey 8628fbf215 Merge Theater Mode refinements (Phase 20 Wave 2) into dev 2026-06-21 09:23:56 -04:00
daniel-c-harvey a23a22a2a3 fix(css): visibility transition 0s->0.45s so allow-discrete defers collapse flip to end of ease-out 2026-06-21 09:20:18 -04:00
daniel-c-harvey 6e12d0161a fix(theater): replace max-height collapse with grid-rows + visibility; fix keyboard-focus leak when collapsed 2026-06-21 09:12:24 -04:00
daniel-c-harvey 9716092805 feat(theater): full-screen detail body, eased content collapse, playing-release scoping
Detail bodies fill 100vh below the nav so the visualizer reads full-screen; Theater toggle eases page content and the player-bar now-showing panel in/out instead of popping (reduced-motion honored); Theater only applies to the currently-playing release.
2026-06-21 08:59:09 -04:00
daniel-c-harvey a577df88dd docs: reflect Phase 20 Theater Mode landing in PLAN, COMPLETED, CLAUDE.md, and spec status 2026-06-20 22:17:58 -04:00
daniel-c-harvey 011dbe8d81 Merge Theater Mode (Phase 20) into dev 2026-06-20 22:12:23 -04:00
daniel-c-harvey 2fc2d4eb6d test: fix PascalCase nit in CoerceTheaterMode_BothOff_TheaterBecomesFalse 2026-06-20 22:09:34 -04:00
daniel-c-harvey 14f3af41e4 fix(theater): auto-exit Theater Mode when both visualizer subsystems are disabled
Adds CoerceTheaterMode() to WaveformVisualizerControlState; ToggleLava/ToggleWaveform
call it before NotifyChanged so all observers see consistent state in one Changed cycle.
Covers the dead-end escape route bug (Phase 20 review finding).
2026-06-20 22:03:39 -04:00
daniel-c-harvey fa01b9c8e0 feat(public): add Theater Mode to release detail pages
Toggle left of the lava popover hides release content so the visualizer fills
the surface; player bar grows to carry the playing release's cover, title, and
share. State on WaveformVisualizerControlState; pages and bar observe it.
2026-06-20 21:51:30 -04:00
daniel-c-harvey 835fb71337 docs(plan): mark Phase 20 Theater Mode scoped after sign-off 2026-06-20 21:40:56 -04:00
daniel-c-harvey 021801999c docs(plan): add Phase 20 Theater Mode spec and roadmap entry 2026-06-20 19:08:44 -04:00
daniel-c-harvey 54cba7eea0 docs(queue): sync client CLAUDE.md to deque cleanup — cached QueueItems, scaffold/StreamNow PLAY routing 2026-06-20 19:05:18 -04:00
daniel-c-harvey fbaf545c90 Merge queue-deque-redesign into dev
Two-level deque queue model + five bug fixes, plus review cleanup.
2026-06-20 19:01:07 -04:00
daniel-c-harvey d3f89c494a fix: Waveform Visualizer Controls layout 2026-06-20 18:56:53 -04:00
daniel-c-harvey c3ec3acafa fix(queue): route scaffold masthead PLAY through queue; cache QueueItems snapshot 2026-06-20 18:51:30 -04:00
daniel-c-harvey 214f708e65 feat(queue): two-level deque model — PLAY prepends, add appends, last-track-end empties
Fixes five queue bugs: Playlist relabel, last-track-empties, dormant-seed-from-player on first add, immediate panel reactivity, and front/back deque semantics. Adds JumpTo for row jumps.
2026-06-20 15:26:37 -04:00
daniel-c-harvey 5058c72375 fix(rcl): commit theme.js so RCL interop JS ships via MapStaticAssets
Deploy DeepDrftManager / Build & Publish (push) Successful in 2m0s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m32s
Deploy DeepDrftManager / Deploy (push) Successful in 1m26s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
theme/ was missing from the per-module .gitignore allowlist (only
parallax/ and knob/ were re-included), so theme.js never got committed,
was absent from publish output, and 404'd at runtime. Broaden the
allowlist to the whole DeepDrftShared.Client/wwwroot/js/ tree so every
compiled RCL interop module ships automatically.
2026-06-20 12:31:49 -04:00
daniel-c-harvey f5edcba7b2 feature: Waveform Controls Restructuring
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m2s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m27s
2026-06-20 03:12:41 -04:00
83 changed files with 5735 additions and 323 deletions
+4 -2
View File
@@ -317,5 +317,7 @@ Database/Vaults/*
!DeepDrftPublic.Client/wwwroot/js/*.js
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest;
# gitignored TS output is absent when manifest is generated, so absent from publish output.
!DeepDrftShared.Client/wwwroot/js/parallax/
!DeepDrftShared.Client/wwwroot/js/knob/
# Re-include the whole RCL js/ tree so every compiled module (parallax, knob, theme, and
# any added later) ships, rather than maintaining a per-module allowlist.
!DeepDrftShared.Client/wwwroot/js/
!DeepDrftShared.Client/wwwroot/js/**
+3 -3
View File
@@ -8,9 +8,9 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
### Core Projects
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release.
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), crawl-directive endpoints (`GET /robots.txt` and `GET /sitemap.xml`, environment-gated via `IWebHostEnvironment.IsProduction()` directly — server-side only, no PersistentState bridge — served by `CrawlDirectiveController` with pure builders in `Seo/RobotsTxt.cs` and `Seo/SitemapXml.cs`), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. **SEO component** (`Controls/SeoHead.razor` + `Common/SeoModel`, `SeoJsonLd`, `SeoOptions`, `SeoUrls`, `SeoEnvironment`): `SeoHead` is a presentational `<HeadContent>` emitter (one line per page, no fetch); `SeoModel` named factories (`ForRelease`/`ForHome`/`ForAbout`/`ForBrowse`/`ForNotFound`) encode the medium→schema.org mapping in one place; `SeoJsonLd` builds typed JSON-LD (MusicGroup / MusicAlbum+LiveAlbum / MusicRecording / CollectionPage) with inline-safe escaping; `SeoOptions` holds site-wide config (`BaseUrl https://deepdrft.com`, title suffix, default OG image seam, IG `sameAs`) registered via the static `Startup` seam; `SeoEnvironment` is a scoped `[PersistentState]` bridge (mirrors `DarkModeSettings`) seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — robots defaults to `index,follow` only in Production, `noindex,nofollow` everywhere else (fail-safe is noindex); per-page `SeoModel.Robots` overrides the default. Tags are present in prerendered HTML (rides the existing `PersistentComponentState` bridge; no new fetch). Canonical/OG origins come from `SeoOptions.BaseUrl` (config), not `window.location` — no `window` at server prerender and the origin cannot be derived behind the nginx proxy. Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. **Always uncrawlable**: a static `wwwroot/robots.txt` (`Disallow: /`, no env gate) plus a blanket `<meta name="robots" content="noindex,nofollow">` in `Components/App.razor` — defense in depth so the CMS is never indexed regardless of how it is discovered. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release.
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles (512-bucket seeker + per-track high-res visualizer datum in the `track-waveforms` vault), release-track join operations, `POST api/track/duration/backfill` (ApiKey-gated one-time backfill of `DurationSeconds` for existing rows from vault audio). Stats endpoints: `GET api/stats/home` (unauthenticated; returns `HomeStatsDto` with cut track count, per-`ReleaseType` cut release counts, mix release count, and total mix runtime seconds). Release endpoints: paged list with medium filter, single read, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
+68
View File
@@ -6,6 +6,74 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 23 — SEO Crawl Directives (landed 2026-06-23)
**Landed:** 2026-06-23 on dev.
- **What:** Server-side crawl-directive endpoints for `DeepDrftPublic` (`GET /robots.txt` and `GET /sitemap.xml`) plus a defense-in-depth noindex layer for `DeepDrftManager`. The endpoint/file-shaped follow-on to Phase 22's per-page `SeoHead` component. Phase 22 is the *content* of discoverability; Phase 23 is the *directives* layer above it — telling crawlers **which** pages exist and **whether** to crawl at all. No new `DeepDrftAPI` endpoint, no schema change.
- **Why:** Without robots.txt a crawler has no machine-readable signal about which routes to include or exclude (e.g. `/FramePlayer`, `/api/*`). Without sitemap.xml Google/Bing must discover release detail pages by link-following alone. Without noindex/robots protection the CMS could be inadvertently crawled if an admin link ever appeared on a public page.
- **Shape:**
- **`DeepDrftPublic/Controllers/CrawlDirectiveController.cs`** (new): thin controller serving both endpoints. Reads `IWebHostEnvironment.IsProduction()` **directly** — no `SeoEnvironment` PersistentState bridge needed because these are server-side only (nothing crosses the server→WASM seam). Env gate is fail-safe closed: non-production robots.txt emits `Disallow: /` and the sitemap returns 404.
- **`DeepDrftPublic/Seo/RobotsTxt.cs`** (new): pure builder for the robots.txt body. Production: `Allow: /` + `Disallow: /FramePlayer` + `Disallow: /api/` + `Sitemap:` pointer. Non-production: `Disallow: /`.
- **`DeepDrftPublic/Seo/SitemapXml.cs`** (new): pure builder for the sitemap XML body. Walks `GET api/release` (server-to-server via the existing `"DeepDrft.API"` named client, paged) and emits a sitemaps.org `urlset`. Six explicit static roots (`/`, `/about`, `/cuts`, `/sessions`, `/mixes`, `/archive`) plus one `<url>` per release — `<loc>` = `SeoOptions.BaseUrl` + `ReleaseRoutes.DetailHref`, equal to the page's `SeoHead` canonical by construction; `<lastmod>` from `ReleaseDate`. Resilient: a partial/failed release read yields a well-formed roots-only document, never a 500.
- **`DeepDrftManager/wwwroot/robots.txt`** (new static file): `Disallow: /` with no environment gate — the CMS is always uncrawlable, including in production.
- **`DeepDrftManager/Components/App.razor`** (updated): blanket `<meta name="robots" content="noindex,nofollow">` in the CMS host `<head>` — defense in depth against de-indexing URLs discovered via external links, complementing the robots.txt directive.
- **Design memo:** `product-notes/phase-23-seo-crawl-directives.md`.
---
## Phase 22 — SEO Metadata Component (landed 2026-06-23)
**Landed:** 2026-06-23 on dev.
- **What:** A parameterized, reusable SEO head component (`SeoHead.razor`) that emits the full modern-SEO head surface — standard meta, canonical, robots, Open Graph, Twitter Card, and schema.org JSON-LD — for every public page in one line of markup. **Public listener site only** (`DeepDrftPublic` host + `DeepDrftPublic.Client`); the CMS is explicitly out of scope. No data-model/schema change, no new API endpoint.
- **Why:** `App.razor` had a static `<head>` with no description, canonical, OG, Twitter Card, or JSON-LD anywhere; pages set only an ad-hoc `<PageTitle>` with an inconsistent suffix. A shared `/mixes/{key}` link unfurled as a bare title + URL. Crawlers and social unfurlers saw nothing useful.
- **Shape:**
- **`Controls/SeoHead.razor`** (new): purely presentational `<HeadContent>` + `<PageTitle>` emitter. Accepts a single `SeoModel` parameter; owns no data fetch. Each page wires it in one line.
- **`Common/SeoModel.cs`** (new): typed per-page input with named factories — `ForRelease(release, baseUrl, options)` (medium-dispatched), `ForHome`, `ForAbout`, `ForBrowse`, `ForNotFound`. Factories encode the medium→schema mapping in one place. Explicit `SeoModel.Robots` override available; default is environment-gated (see `SeoEnvironment`).
- **`Common/SeoJsonLd.cs`** (new): typed schema.org JSON-LD builders. Cut → `MusicAlbum` with ordered `MusicRecording` track list; Session → `MusicAlbum`/`LiveAlbum`; Mix → single `MusicRecording` with ISO-8601 duration; Home/About → `MusicGroup` (with `sameAs: ["https://instagram.com/deepdrft.music"]`); Browse → `CollectionPage`. `byArtist` wired per-release. JSON-LD body is inline-safe-escaped (`<`/`>`/`&``\uXXXX`) to prevent script-breakout from CMS-authored text.
- **`Common/SeoOptions.cs`** (new): site-wide config — `BaseUrl` (`https://deepdrft.com`), title suffix (`Deep DRFT`, middot separator), default OG image seam (uses `ImageProxyController` route), IG handle in `sameAs`, no Twitter handle. Registered via the static `Startup` seam (runs in both server and WASM `Program.cs`).
- **`Common/SeoUrls.cs`** (new): URL helpers for canonical and `og:image` construction from `SeoOptions.BaseUrl` (config, not `window.location` — no `window` at server prerender and the origin can't be derived behind the nginx proxy).
- **`Common/SeoEnvironment.cs`** (new): scoped `[PersistentState]` bridge seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — mirrors the `DarkModeSettings` bridge. Default robots is `index,follow` only in Production; `noindex,nofollow` in every non-production environment so the beta/staging site stays uncrawled. Explicit per-page `SeoModel.Robots` overrides this default. Fail-safe default is `noindex`.
- **Wired into:** Home, About, Cut/Session/Mix detail pages (incl. their not-found branches → `noindex`), the browse views (Albums/Sessions/Mixes/Archive), and the 404 NotFound page.
- **Render-mode correctness:** `SeoHead` rides the existing `PersistentComponentState` bridge (the same `ReleaseDto` the detail pages already bridge) — no new fetch. The `InteractiveAuto` double-render produces identical head content across prerender and WASM passes (fed from bridged state, guarded on id/key equality).
- **Design memo:** `product-notes/phase-22-seo-metadata-component.md`.
---
## Phase 20 — Theater Mode (landed 2026-06-20)
**Landed:** 2026-06-20 on dev. Pending: final manual browser/GPU smoke-test on dev.
- **What:** A presentation-only Theater Mode toggle on the three public Release Detail views (`CutDetail.razor`, `MixDetail.razor`, `SessionDetail.razor`). Toggling ON hides the release page content via `@if` so the lava-lamp + waveform visualizer fills the surface unobstructed; the player bar grows to surface the playing release's cover art, release title (linked), and a release-mode `SharePopover`. Toggling OFF restores the page byte-for-byte. The top action row (back link, lava-lamp popover, Theater toggle) stays visible in both states. Behavior is identical across all three mediums. Persists across SPA navigation within a session; resets to OFF on fresh page load.
- **Why:** The visualizer is the site's most distinctive feature (Phases 10/12/15). Theater Mode makes it the *whole* thing on demand — a "lean back and watch the lamp" experience — and relocates the minimum release identity to the one piece of chrome that stays (the player bar), so nothing essential is lost.
- **Shape:**
- **`Controls/TheaterModeToggle.razor`** (new): shared toggle button placed immediately left of the lava-lamp `WaveformVisualizerControlPopover` on all three detail pages inside a `.dd-detail-top-actions` cluster. Material `Theaters` glyph; `.dd-accent-icon` for green-accent in both themes. Visible only when `LavaEnabled || WaveformEnabled`; disabled until interactive. Flips `WaveformVisualizerControlState.TheaterMode` and calls `NotifyChanged()`. Subscribes to `State.Changed` for its own active-state re-render; disposes cleanly.
- **`Controls/AudioPlayerBar/NowShowingPanel.razor`** (new): presentational "now showing" band rendered by `AudioPlayerBar` only when `TheaterMode && CurrentTrack?.Release is not null`. Shows cover art (`deepdrft-track-detail-cover-art` / `deepdrft-gradient-soft-secondary` placeholder), release title link (`ReleaseRoutes.DetailHref`), and release-mode `SharePopover` in `.dd-accent-icon`. Layout CSS in `AudioPlayerBar.razor.css` (`.now-showing-*`); surface/text bind `--deepdrft-page-*` aliases — no new dark overrides.
- **`Services/WaveformVisualizerControlState.cs`** (widened): gained `TheaterMode` bool + `DefaultTheaterMode = false` const, and `CoerceTheaterMode()` — enforces the invariant that Theater Mode cannot remain on when both subsystems are off. Called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` before `NotifyChanged()` so all observers see a consistent coerced state in the same `Changed` cycle.
- **`Controls/AudioPlayerBar/AudioPlayerBar.razor` + `.razor.cs` + `.razor.css`**: subscribes to `WaveformVisualizerControlState.Changed`; mounts `<NowShowingPanel>` above transport controls when Theater is on and a release is playing.
- **Three detail pages** (`CutDetail.razor`, `MixDetail.razor`, `SessionDetail.razor`): page-level `@if (!VisualizerControlState.TheaterMode)` gates content regions on each page individually (not in `ReleaseDetailScaffold`, so Session — which does not use the scaffold — is covered identically). Each page's top action cluster hosts `<TheaterModeToggle />` in a `.dd-detail-top-actions` flex wrapper.
- **`deepdrft-styles.css`**: new `.dd-detail-top-actions` layout-only class (`display:flex; align-items:center; gap:0.25rem`) — no colour; shared by all three pages.
- **`DeepDrftTests/WaveformVisualizerControlStateTests.cs`** (new): unit tests for the `CoerceTheaterMode()` auto-exit invariant.
- **Design memo:** `product-notes/phase-20-theater-mode.md`.
### Phase 20 — Wave 2 — Theater Mode refinements (landed 2026-06-21)
**Landed:** 2026-06-21 on dev.
- **What:** Three refinements to the base Phase 20 feature. (1) **Full-screen detail body:** each detail page's foreground container gained `.dd-detail-fill` (`min-height: calc(100vh - var(--deepdrft-nav-height, 88px))`), so the visualizer reads as full-screen and the footer is pushed below the fold regardless of Theater Mode. (2) **Eased collapse (no pop):** the hard `@if` content-hide on the three detail pages was replaced by a `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` wrapper pair that receives `.dd-theater-collapsed` when `IsContentHidden` is true — animates `grid-template-rows: 1fr → 0fr`, `opacity`, and `visibility` (deferred via `transition-behavior: allow-discrete`) so Theater ON/OFF eases rather than pops; `prefers-reduced-motion` collapses instantly. The same wrapper pattern drives the player-bar `NowShowingPanel`, which is now kept mounted whenever a release is playing and collapsed (not `@if`-removed) when Theater is OFF — enabling the ease-in when Theater turns ON (resolves OQ2 design intent for a mounted-but-dormant panel). (3) **Playing-release scoping:** Theater Mode now only applies to the currently-playing release. `ReleaseDetailBase` and `CutDetailBase` each gained a cascaded `IStreamingPlayerService PlayerService`, a reference-guarded `StateChanged` subscription (disposed in `Dispose`), and three predicates: `IsThisReleasePlaying` (`CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). `TheaterModeToggle.razor` gained an `Available` parameter (default `true`) folded into its render gate; all three pages pass `Available="ShowTheaterToggle"`. A detail page whose release is not playing shows no toggle and ignores the global `TheaterMode` flag.
---
## Phase 18 — Theme / Dark-Mode Remediation (landed 2026-06-19)
**Landed:** 2026-06-19 on dev (Wave 1 + Wave 2 + Wave 3).
+1 -1
View File
@@ -15,7 +15,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.37" />
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.39" />
</ItemGroup>
<ItemGroup>
+2 -1
View File
@@ -16,7 +16,8 @@
"https://localhost:5004",
"http://localhost:5003",
"https://deepdrft.com",
"https://www.deepdrft.com"
"https://www.deepdrft.com",
"https://app.deepdrft.com"
]
},
"ForwardedHeaders": {
+3 -3
View File
@@ -18,9 +18,9 @@
</PackageReference>
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.30" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.35" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.35" />
</ItemGroup>
<ItemGroup>
+1
View File
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/styles/deepdrft-tokens.css"]" />
<ImportMap />
<link rel="icon" type="image/ico" href="deepdrft-logo.ico" />
<meta name="robots" content="noindex,nofollow" />
<HeadOutlet @rendermode="ServerMode" />
</head>
@@ -6,9 +6,23 @@
<MudLayout>
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
Deep Drft — Admin
</MudText>
<a href="/" class="mx-2">
<MudStack Row AlignItems="AlignItems.Center">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "
Style="filter: invert(1);"/>
<MudText Typo="Typo.button" Style="color: var(--deepdrft-white);">Deep DRFT Management</MudText>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24"
Style="filter: invert(1);"/>
</MudStack>
</a>
</MudAppBar>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Small"
@@ -13,9 +13,23 @@
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
Deep Drft — Admin
</MudText>
<a href="/" class="mx-2">
<MudStack Row AlignItems="AlignItems.Center">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "
Style="filter: invert(1);"/>
<MudText Typo="Typo.button" Style="color: var(--deepdrft-white);">Deep DRFT Management</MudText>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24"
Style="filter: invert(1);"/>
</MudStack>
</a>
<MudSpacer />
<MudTooltip Text="Catalogue">
<MudIconButton Icon="@Icons.Material.Filled.Home"
@@ -34,6 +48,7 @@
<MudNavLink Href="/useradmin/users/new" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PersonAdd">Provision User</MudNavLink>
</Authorized>
</HierarchicalRoleAuthorizeView>
<AccountNavMenu />
</MudNavMenu>
</MudDrawer>
<MudMainContent Class="pt-14 pb-8">
+1 -1
View File
@@ -1,6 +1,6 @@
@page "/404"
<PageTitle>SkipperHaven - Page Not Found</PageTitle>
<PageTitle>Deep DRFT Management - Page Not Found</PageTitle>
<MudText Typo="Typo.h1" Color="Color.Primary">
404 - Resource Not Found
+1 -1
View File
@@ -1,7 +1,7 @@
@page "/"
@layout Layout.CmsHomeLayout
<PageTitle>Deep Drft — Admin</PageTitle>
<PageTitle>Deep DRFT Management</PageTitle>
<HierarchicalRoleAuthorizeView>
<Authorized>
+1 -1
View File
@@ -7,7 +7,7 @@
@inject ICmsReleaseService CmsReleaseService
@inject ILogger<Index> Logger
<PageTitle>DeepDrft CMS</PageTitle>
<PageTitle>Deep DRFT Management - Catalogue</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
@@ -14,7 +14,7 @@
@inject IDialogService DialogService
@inject ILogger<BatchEdit> Logger
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
<PageTitle>Edit Release — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
@@ -12,7 +12,7 @@
@inject ISnackbar Snackbar
@inject ILogger<BatchUpload> Logger
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
<PageTitle>Upload Release — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
@@ -18,7 +18,7 @@
}
else
{
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
<PageTitle>Mixes — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
@@ -19,7 +19,7 @@
}
else
{
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
<PageTitle>Sessions — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
@@ -11,7 +11,7 @@
@inject NavigationManager NavigationManager
@attribute [Authorize]
<PageTitle>Releases — DeepDrft CMS</PageTitle>
<PageTitle>Releases — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
+1 -1
View File
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="MudBlazor" Version="8.15.0" />
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.37" />
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.39" />
</ItemGroup>
<ItemGroup>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+2 -2
View File
@@ -7,8 +7,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.30" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.35" />
</ItemGroup>
</Project>
+22 -6
View File
@@ -10,17 +10,18 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
## Actual structure
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"` with `.dd-detail-fill` so the ambient visualizer reads full-screen and the footer is pushed below the fold; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; **Phase 20:** top action row carries `<TheaterModeToggle Available="ShowTheaterToggle" />` immediately left of the lava-lamp popover in a `.dd-detail-top-actions` cluster — the toggle only appears when this page's release is the one currently playing (`ShowTheaterToggle` from `ReleaseDetailBase` folds in the subsystem gate + release-playing check); hero overlay and `<ReleaseDescription>` are wrapped in a `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` pair that gets `.dd-theater-collapsed` when `IsContentHidden` is true — eased collapse via `grid-template-rows: 1fr → 0fr` + `opacity` + `visibility` (no hard `@if` pop); renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; the foreground container carries `.dd-detail-fill`; renders `<ReleaseDescription>` below the hero for the release's description blurb; **Phase 20:** `TopRightAction` slot holds `<TheaterModeToggle Available="ShowTheaterToggle" />` + lava-lamp popover in a `.dd-detail-top-actions` cluster — toggle only appears when this Mix is the playing release; hero overlay and description are wrapped in `.dd-theater-collapsible` / `.dd-theater-collapsed` eased collapse driven by `IsContentHidden`), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; the scaffold is wrapped in a `.dd-detail-fill` div; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`; **Phase 20:** `TopRightAction` slot holds `<TheaterModeToggle Available="ShowTheaterToggle" />` + lava-lamp popover in a `.dd-detail-top-actions` cluster — toggle only appears when this Cut is the playing release; header and track-list body are each wrapped in a `.dd-theater-collapsible` / `.dd-theater-collapsed` eased collapse driven by `IsContentHidden`, replacing the prior hard `@if`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list), `DeepDrftFooter.razor` (site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal via `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) carrying the anonymous-listener privacy note; trigger-button styling in the co-located `DeepDrftFooter.razor.css`, overlay chrome in the global `deepdrft-styles.css`; follows the `QueueOverlay`/`WaveformVisualizerControlPopover` `MudOverlay` idiom — scrim-click closes, panel stops propagation).
- `Controls/`: Reusable components.
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
- `AppNavLink.razor`: Nav link with active-page highlight.
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored).
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming — routes through `IQueueService.PlayTrack` (deque PLAY semantics) when the queue cascade is present, falls back to `IStreamingPlayerService.SelectTrackStreaming` when absent. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored). **Phase 20:** injects `WaveformVisualizerControlState` and subscribes to `Changed` (added alongside the existing `IPlayerService.StateChanged` subscription — same reference-guard + dispose pattern); mounts `<NowShowingPanel Release="CurrentTrack.Release" />` above the transport controls when `CurrentTrack?.Release is not null` — the panel is kept **always mounted** whenever a release is playing and wrapped in the shared `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` pair; it gets `.dd-theater-collapsed` when Theater Mode is OFF, so the bar grows/shrinks via the same eased collapse that the detail-page content regions use rather than popping via `@if` (Phase 20 Wave 2).
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
- `AudioPlayerBar/NowShowingPanel.razor`: Phase 20 "now showing" presentational band rendered by `AudioPlayerBar` **only when** `VisualizerControlState.TheaterMode && CurrentTrack?.Release is not null`. Carries the release identity the hidden detail page would otherwise show: cover art thumbnail (`deepdrft-track-detail-cover-art` / `deepdrft-gradient-soft-secondary` placeholder), release title linked via `ReleaseRoutes.DetailHref(Release)`, and a release-mode `SharePopover` (`ReleaseEntryKey` + `ReleaseMedium`) wrapped in `.dd-accent-icon`. `[Parameter, EditorRequired] ReleaseDto Release` — non-null by the bar's mount gate. Purely presentational: owns no player logic, no Theater state, and no data fetch. Layout CSS lives in `AudioPlayerBar.razor.css` (`.now-showing` / `.now-showing-cover` / `.now-showing-cover-art` / `.now-showing-cover-placeholder` / `.now-showing-title-link` / `.now-showing-title` / `.now-showing-share`); all surface/text binds `--deepdrft-page-*` theme-aware aliases — no new dark overrides.
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0100%), with fixed three-zone gradient (green 060%, yellow 6085%, orange 85100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
@@ -29,6 +30,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `WaveformVisualizer.razor`: The single WebGL2 lava-lamp visualizer engine. Hosts the waveform of whatever track is currently playing/selected. Three hosting modes: mode A (Mix detail — full-bleed centerpiece), mode B (Cut/Session detail — ambient layer behind hero+content via `ReleaseDetailScaffold`'s `Ambient` slot), mode C (NowPlaying hero panel — full-bleed background for the home hero's right side, mounted by `NowPlaying.razor` inside `.np-visualizer-bg`). `[Parameter] bool Fill` switches from fixed-viewport positioning to container-relative sizing (CSS-only; the renderer is identical in both modes). The bridge resolves the current track's `EntryKey` and re-fetches the high-res datum on track change. Subscribes to `WaveformVisualizerControlState.Changed` and pushes each updated dial to the WebGL module via JS interop. Follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`).
- `WaveformVisualizerControls.razor`: The waveform visualizer control panel (content hosted by `WaveformVisualizerControlPopover`). Phase 15 re-layout: a deterministic **three-row sectioned layout** encoding the visualizer's two subsystems. Row 1 (MODE, always visible): two iconographic lamp toggles (lava on/off, waveform on/off) left-aligned + collisions knob (conditional — only when both subsystems on) + color knob pinned far-right. Row 2 (LAVA, visible only when `LavaEnabled`): "LAVA:" section label + Gravity / Heat / FluidAmount / FluidViscosity knobs. Row 3 (WAVE, visible only when `WaveformEnabled`): "WAVE:" section label + scroll-speed `MudSlider` (not a knob) + width knob pinned far-right. Total: two lamp toggles, seven `RadialKnob`s, one `MudSlider`. Colour principle: lamp toggles / knob arcs / slider are green (`Color.Primary` — interactive); section labels / knob caption icons are light (static). Each control has a playful `MudTooltip`. `[Parameter] bool PanelChrome` scopes panel chrome (NowPlayingCard look — square corners, lighter-navy, thin border) to the popover mount; chrome classes live in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). `[Parameter] bool Visible` gates the rows via `@if` while the container holds reserved min-height. Owns no JS interop: mutates the injected `WaveformVisualizerControlState` and raises `Changed`. No control is a seek surface (read-only contract).
- `WaveformVisualizerControlPopover.razor`: Pairs the lava-lamp icon button with `WaveformVisualizerControls` as a **screen-centered tinted modal** (Phase 15). The primitive is `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) — **not** `MudPopover`; `AnchorOrigin`/`TransformOrigin` parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: `RadialKnob`'s `position:fixed` capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. `[Parameter] Size IconSize` controls the trigger-icon size (default `Large`). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in `.np-visualizer-controls` at the panel's top-right corner, not inside `NowPlayingCard`).
- `TheaterModeToggle.razor`: Phase 20 Theater-Mode toggle button. Visible only when `Available && (State.LavaEnabled || State.WaveformEnabled)` — no visualizer subsystem active → no theater to enter; `Available` is false when this page's release is not the one currently playing (Phase 20 Wave 2). Disabled until interactive (`!RendererInfo.IsInteractive`), same guard as Play and the lava-lamp trigger. On click: flips `WaveformVisualizerControlState.TheaterMode` and calls `NotifyChanged()`. Shows an on/off `aria-pressed` active state. Glyph: Material `Theaters`. `.dd-accent-icon` container gives the green-accent glyph in both themes with zero new CSS — same treatment as `WaveformVisualizerControlPopover`. Subscribes to `State.Changed` in `OnInitialized` and unsubscribes on `Dispose` to re-render when another observer (e.g. `CoerceTheaterMode()`) flips the state. `[Parameter] Size IconSize` (default `Large`) matches the adjacent lava-lamp trigger. `[Parameter] bool Available` (default `true`) — the page passes its `ShowTheaterToggle` predicate here so the toggle is scoped to the playing release; surfaces with no release-scoping pass the default `true`. Placed **immediately left** of the lava-lamp popover on all three detail pages inside a `.dd-detail-top-actions` cluster.
- `WaveformZoomMapping.cs`: Maps the `WaveformVisualizerControlState.Resolution` fraction to an integer zoom level for the WebGL renderer.
- `NowPlayingCard.razor`: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded `IStreamingPlayerService`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to `NowPlaying.razor`.
- `NowPlayingStats.razor`: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-`ReleaseType` Cut release breakdown), Mixes (`MixReleaseCount` labelled "Sets" + `hh:mm` total mix runtime via `RuntimeFormat`), and Plays (live `TotalPlays` odometer in `.hero-stat-odometer` + `UniqueListeners` "N listeners" secondary line via `.hero-stat-sub` — Phase 16 wave 16.5). All three cards read from the same `HomeStatsDto` round-trip; no extra fetch path. Fetches via `IStatsDataService` on init; bridges the prerender fetch across the WASM seam with `PersistentComponentState` (persists only on a successful load, matching the medium-browse bridge pattern). Implements `IDisposable` to release the `PersistingComponentStateSubscription`.
@@ -36,8 +38,9 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `QueueList.razor`: Shared presentational queue-list component (Phase 17 wave 17.1). Renders `Items` as an ordered list with the current track marked; `Editable` flag gates drag-reorder handles (drag handle icon + `MudDropContainer`/`MudDropZone` for reorder) and per-row remove controls. The remove (×) control is suppressed on the currently-playing row (`Editable && !isCurrent`) — the current track cannot be removed via the UI (wave 17.2; reorder of the current row is still permitted). When not editable, renders a plain `<div>` — the read-only state for the embed's fixed-order shared queue. Reorder, remove, and row-jump are surfaced to the parent as `EventCallback<(int FromIndex, int ToIndex)> OnReorder`, `EventCallback<int> OnRemove`, and `EventCallback<int> OnJump`; the component calls no `IQueueService` method itself (purely presentational, no data fetch, no player wiring). Both view modes (docked overlay 17.2, embedded panel 17.3) consume this single component differing only in hosting context and the `Editable` flag. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs).
- `QueueOverlay.razor`: Screen-centered tinted modal hosting the docked-player editable queue (Phase 17 wave 17.2). Borrows the `WaveformVisualizerControlPopover` `MudOverlay` idiom (`DarkBackground="true"`, `Modal="true"`): the panel stops click propagation; scrim-click closes the overlay; drag-safe (the panel's capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes when a removal empties the queue. Hosts `QueueList` in `Editable="true"` mode. Opened/closed by the Queue toggle button in `PlayerTransportZone` (shown only when `!Fixed && Items.Count > 0`; `QueueMusic` glyph, active state when open).
- `AddToQueueButton.razor`: Append-only Add-to-Queue button shared across detail-page play sites (Phase 17 wave 17.4). Two modes: track mode (calls `IQueueService.Enqueue` with a single `TrackDto`) and release mode (calls `IQueueService.EnqueueRange` with an ordered track list). Material `PlaylistAdd` glyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at: `CutDetail` header (release mode, `TrackNumber`-ordered list), `CutDetail` track rows (track mode), `SessionDetail` hero play (track mode), `MixDetail` hero play (track mode). Excluded from `StreamNowButton` (OQ9) and `ReleaseGallery` cards (OQ10, deferred).
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold.
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold. The scaffold's default masthead PLAY (`PlayTrack`) routes through `IQueueService.PlayTrack` (deque PLAY semantics — prepends the track to the queue front) when the queue cascade is present, falling back to `IStreamingPlayerService.SelectTrackStreaming` when absent; toggle-pause is handled directly via `IStreamingPlayerService.TogglePlayPause` when this track is already active.
- `SharePopover.razor`: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. **Track mode** (`EntryKey` set): copies the track's canonical URL and offers an iframe embed snippet pointing at `FramePlayer?TrackEntryKey=…`. **Release mode** (`ReleaseEntryKey` + `ReleaseMedium` set): copies the release's canonical detail URL (via `ReleaseRoutes.DetailHref`) and offers an iframe embed snippet pointing at `FramePlayer?ReleaseEntryKey=…`, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by `EmbedSnippetBuilder`. A transient "Copied!" confirmation resets after a short delay.
- `SeoHead.razor`: Purely presentational SEO head emitter (Phase 22). Renders a `<PageTitle>` + `<HeadContent>` block from a single `SeoModel` parameter — standard meta (description, robots), canonical, Open Graph, Twitter Card, and schema.org JSON-LD. Owns no data fetch; each page wires it in one line and supplies the model from its already-bridged ViewModel state. Wired on Home, About, Cut/Session/Mix detail (incl. not-found branches → `noindex`), browse views, and the 404 page.
- `Helpers/`: Utilities and mapper functions.
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
- `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure.
@@ -48,13 +51,13 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 1664 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions, **two subsystem on/off toggles** (Phase 15), and one **Theater-Mode flag** (Phase 20): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`), `TheaterMode` (bool, default `false``DefaultTheaterMode`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and `AudioPlayerBar`) subscribe to react to `TheaterMode`. **`CoerceTheaterMode()`**: enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` **before** `NotifyChanged()` so all observers see a consistent, coerced state in the same `Changed` cycle. `TheaterMode` is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. **Phase 20 Wave 2 — playing-release predicates** live in `ReleaseDetailBase` / `CutDetailBase` (not in this state holder): `IsThisReleasePlaying` (`PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). Both base classes also subscribe to `IStreamingPlayerService.StateChanged` (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1). Serializes the play classification and fires it via `BeaconInterop` to `api/event/play`. Synchronous (`EmitPlay` cannot await — it is called from the player close path and the page-unload handler). **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/telemetry/anonid.ts` (mints GUID on first visit, returns null without throwing when storage is unavailable).
- `IQueueService` / `QueueService`: Ordered playback orchestrator above the single-slot player. `PlayRelease(tracks, startIndex)` replaces the queue and starts streaming; `Next`/`Previous` advance or step back; `Enqueue`/`EnqueueRange` append without interrupting the current track; `Clear` empties the queue. **Armed-idle state** added to support prerender-safe release embeds: `Arm(tracks)` loads the track list at index 0 with no JS interop (safe during prerender); `IsArmed` signals the armed-but-not-streaming state; `Start()` begins streaming the current track and clears `IsArmed`, leaving the list and position intact so auto-advance carries on. `AudioPlayerBar` reads `IsArmed` to route the first play gesture through `Start()` instead of streaming the staged track alone. `QueueChanged` event fires on all list/position changes; cascaded via `AudioPlayerProvider`. **Wave 17.1 additions:** `Move(int fromIndex, int toIndex)` reorders `Items` in-place, adjusting `CurrentIndex` so the same track stays current across the move — never re-streams or interrupts playback; `RemoveAt(int index)` removes an item and adjusts `CurrentIndex` (removing the current track does not stop playback; removing the last remaining item leaves the queue empty and dormant). Both are interop-free state mutations that re-emit `QueueChanged`. **Dormant-`Enqueue` coherence (OQ8):** `Enqueue`/`EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) set `CurrentIndex` to 0 so a subsequent play/skip is correct — but do not auto-play. **Wave 17.2 additions:** `ClearUpcoming()` removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0` and re-emitting `QueueChanged` — touches no playback (OQ5: Clear does not stop or remove the current track). `PlayRelease` now always materializes a defensive copy of its input (`tracks.ToList()`) so it can never alias the service's own `Items` list — fixes a row-jump bug where `PlayRelease(Items, index)` could mutate the live list mid-operation.
- `IQueueService` / `QueueService`: **Two-level deque** orchestrator above the single-slot player. The deque has two entry ends. **PLAY (manual)** enters the FRONT: `PlayTrack(track)` and `PlayRelease(tracks, startIndex)` prepend the played track/release in order, **remove the previously-current track**, make the new front current, start streaming it, and leave whatever sat after the old current intact behind the prepend (a whole release prepends in order in one op). The detail pages (Cut header/row, Session/Mix hero) and `StreamNowButton` route their PLAY through these. **Add-to-queue** enters the BACK: `Enqueue`/`EnqueueRange` append to the end without interrupting the current track (`AddToQueueButton`). `Next`/`Previous` advance or step back, walking `CurrentIndex` and leaving played tracks behind so `Previous` can reach them; `JumpTo(index)` moves the pointer to a queued row and streams it once (the playlist panel's row-jump — it does NOT prepend or stream the intervening rows). **End-of-track:** auto-advance (`TrackEnded`) advances when there is a next track; when the **last** track ends naturally the queue **empties** and goes dormant (bug #2) rather than stranding the finished track. `Clear` empties the queue. **Bug #3 (dormant-seed):** the first `Enqueue`/`EnqueueRange` into a dormant queue while a track is already playing externally (via the attached player, not through the queue) seeds the head with that now-playing track and then appends — yielding `[now-playing, added]` (even when adding the same track). The queue learns the externally-playing track through the existing `Attach(player)` seam (`_player.CurrentTrack`) — no new dependency, no `IServiceProvider`. **Armed-idle state** (prerender-safe release embeds): `Arm(tracks)` replaces the queue at index 0 with no JS interop; `IsArmed` signals armed-but-not-streaming; `Start()` streams the current track and clears `IsArmed`. `AudioPlayerBar` reads `IsArmed` to route the embed's first play gesture through `Start()`. `QueueChanged` fires on all list/position changes; cascaded via `AudioPlayerProvider`. `Move`/`RemoveAt` are interop-free reorder/remove mutations that adjust `CurrentIndex` and never re-stream. `ClearUpcoming()` keeps the current track and drops the up-next. **Bug #4 (reactivity):** `AudioPlayerBar.QueueItems` caches `QueueService.Items` as a `_queueItemsCache` snapshot (the service exposes its backing list by reference); the cache is invalidated and set to `null` in `OnQueueChanged`, so every real mutation hands `QueueList` a new list reference while frequent progress-tick re-renders reuse the cached one without allocating. `QueueList.OnParametersSet` calls `_dropContainer?.Refresh()` so the `MudDropContainer` re-reads the new list and the open panel re-flows immediately. **Bug #1 (label):** the docked `QueueOverlay` panel header reads **"Playlist"** (the current track stays listed). `PlayRelease` materializes `tracks.ToList()` before mutating so it can never alias the service's own `Items` list.
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)``Stream` with optional Range header support for seek-beyond-buffer.
@@ -68,6 +71,11 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `Common/`: Shared utilities.
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
- `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B).
- `SeoModel.cs`: Typed per-page SEO input (Phase 22). Named factories: `ForRelease` (medium-dispatched — Cut → `MusicAlbum`, Session → `MusicAlbum`/`LiveAlbum`, Mix → `MusicRecording`), `ForHome`, `ForAbout`, `ForBrowse`, `ForNotFound`. Encodes the medium→schema.org mapping in one place. `SeoModel.Robots` overrides the environment-default (see `SeoEnvironment`).
- `SeoJsonLd.cs`: Typed schema.org JSON-LD builders (Phase 22). Types: `MusicGroup` (home/about, with `sameAs: ["https://instagram.com/deepdrft.music"]`), `MusicAlbum`/`LiveAlbum` (cuts/sessions, with ordered `MusicRecording` track list and per-release `byArtist`), `MusicRecording` (mixes, with ISO-8601 `duration`), `CollectionPage` (browse). All serialized output is inline-safe-escaped (`<`/`>`/`&``\uXXXX`) to prevent script-breakout from CMS-authored text.
- `SeoOptions.cs`: Site-wide SEO config (Phase 22). `BaseUrl` (`https://deepdrft.com`), title suffix (`Deep DRFT`, middot separator), default OG image seam (uses `ImageProxyController` route), IG handle in `sameAs`, no Twitter handle. Registered via the static `Startup` seam (both server and WASM `Program.cs`). `BaseUrl` is config, not `window.location` — no `window` at server prerender, and the origin cannot be derived reliably behind the nginx proxy.
- `SeoUrls.cs`: URL helpers for canonical and `og:image` construction from `SeoOptions.BaseUrl` (Phase 22).
- `SeoEnvironment.cs`: Scoped `[PersistentState]` bridge for the server environment flag (Phase 22). Seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — mirrors the `DarkModeSettings` bridge pattern. Default robots is `index,follow` only in Production; `noindex,nofollow` in every non-production environment so the beta/staging site stays uncrawled. Explicit per-page `SeoModel.Robots` overrides this default. Fail-safe default is `noindex`.
- `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`.
- `_Imports.razor`: Global using statements and component imports.
@@ -127,6 +135,8 @@ New modules in `DeepDrftPublic/Interop/audio/`:
The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie.
**`SeoEnvironment` follows the same `[PersistentState]` bridge pattern** (Phase 22). It is seeded server-side in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` and bridged to the WASM client. Consumers (`SeoHead`) read `SeoEnvironment.IsProduction` to gate the default robots directive (`index,follow` in Production, `noindex,nofollow` elsewhere). The pattern is identical to `DarkModeSettings` — one server-side seed, one `PersistentComponentState` round-trip, one scoped client read.
## MVVM convention
Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only.
@@ -148,6 +158,12 @@ Two reasons this is needed and why it's a class, not a palette colour: (1) no Mu
**Self-themed components are authoritative over `.dd-accent-icon`.** `PlayStateIcon` owns its glyph colour inside `.icon-container` and must beat a surrounding `.dd-accent-icon` in dark — its scoped CSS rule targets `.mud-icon-root` at (0,5,0) `!important` (after Blazor's scope attribute is applied), which outranks the consolidation rule's (0,3,0) `!important`. Do not wrap a `PlayStateIcon` in `.dd-accent-icon` expecting to recolor its play-chip glyph — the play chip always shows navy (`--deepdrft-play-glyph`) against the moss-green chip in dark.
**Layout-only cluster class: `.dd-detail-top-actions`.** When two or more icon affordances sit together in a top-action row (e.g. the Theater toggle + lava-lamp popover on the three detail pages), wrap them in `.dd-detail-top-actions` — a layout-only `display:flex; align-items:center; gap:0.25rem` class in `deepdrft-styles.css`. No colour; prevents the `SpaceBetween` row from spreading the icons apart. Each affordance inside still carries its own `.dd-accent-icon` wrapper independently.
**Full-screen detail body: `.dd-detail-fill`.** Phase 20 Wave 2. Applied to each detail page's foreground content container (the `<div>` or `<MudContainer>` that wraps the scaffold/hero); sets `min-height: calc(100vh - var(--deepdrft-nav-height, 88px))` so the ambient/full-bleed visualizer reads as genuinely full-screen and the site footer is pushed below the fold, independent of Theater Mode. Reuses `--deepdrft-nav-height` (88px desktop / 72px mobile) so the clearance tracks the nav bar height across breakpoints; no new layout token. Defined in `deepdrft-styles.css`.
**Eased Theater Mode collapse: `.dd-theater-collapsible` / `.dd-theater-collapsed`.** Phase 20 Wave 2. Used wherever Theater Mode should ease content in/out rather than pop via `@if`. The outer wrapper carries `.dd-theater-collapsible` (always present); its single direct child carries `.dd-theater-collapsible-inner`; adding `.dd-theater-collapsed` to the outer collapses the region. Technique: `grid-template-rows: 1fr → 0fr` (real-height interpolation), `opacity`, and `visibility: hidden` + `transition-behavior: allow-discrete` (visibility flip deferred to end of ease-out so collapsed content is removed from the tab order once the animation completes; immediately re-shown on expand). A `prefers-reduced-motion` block collapses instantly. Used on the release content regions in all three detail pages (`IsContentHidden` predicate) and on the player-bar `NowShowingPanel` band (collapsed when `!TheaterMode`). Defined in `deepdrft-styles.css`.
**Gas-lamp toggle is self-colored in its SVG.** `DDIcons.GasLampLit` (dark-mode icon) carries `fill="#2A5C4F"` directly on its frame path — no CSS colour override is needed. The former dark nav rule (`.deepdrft-theme-dark .dd-nav-actions .mud-icon-button`) has been removed as dead. `DDIcons.GasLamp` (light-mode icon) continues to use `currentColor` and inherits nav text colour in light (the unlit toggle is theme-divergent by design).
## Development commands
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Environment-gated robots bridge (Phase 22 remediation §4). The beta/staging site is web-hosted and must
/// not be crawled, so the <i>default</i> robots directive is environment-gated: <c>index,follow</c> only in
/// Production, <c>noindex,nofollow</c> everywhere else. A per-page <see cref="SeoModel.Robots"/> override
/// still wins — this only sets the default.
///
/// <para>
/// Crawlers read the server-prerendered HTML, so correctness lives in the server prerender pass — but the
/// value must be identical across the InteractiveAuto double render (AC6), so the WASM pass has to resolve
/// the same flag. The WASM assembly has no <c>IWebHostEnvironment</c> (config comes from the server). This
/// mirrors the DarkMode bridge exactly: a scoped service the server seeds during prerender (from
/// <c>IWebHostEnvironment.IsProduction()</c>) and <c>[PersistentState]</c> rounds to the client, so both
/// passes resolve the identical value. <c>SeoHead</c> injects this rather than an environment dependency,
/// honouring the no-environment-in-the-component constraint.
/// </para>
/// </summary>
public class SeoEnvironment
{
/// <summary>
/// True only in Production. Seeded server-side and persisted across the WASM boot. Defaults to
/// <c>false</c> so the fail-safe is "do not index" — a missing bridge never accidentally opens a
/// non-production site to crawlers.
/// </summary>
[PersistentState]
public bool IsProduction { get; set; }
/// <summary>The environment-gated default robots directive. Explicit page values override this.</summary>
public string DefaultRobots => IsProduction ? "index,follow" : "noindex,nofollow";
}
+127
View File
@@ -0,0 +1,127 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Typed schema.org JSON-LD nodes (Phase 22, OQ5 — the typed-builder option). Each record mirrors one
/// schema.org type; <see cref="SeoJsonLd.Serialize"/> renders a node to the <c>&lt;script type="application/ld+json"&gt;</c>
/// body. Keeping the shape in C# (not hand-written JSON in pages) is what makes the medium→type mapping
/// live in one place (DRY, §4.3) and the output unit-testable (AC5) rather than a manual validator pass.
///
/// <para>
/// All nodes share <see cref="JsonLdNode"/> so the <c>@context</c>/<c>@type</c> pair serialises first and
/// once. Null properties are omitted (the serializer ignores nulls) so partial data never emits an empty
/// or broken node (C6/AC4).
/// </para>
/// </summary>
public static class SeoJsonLd
{
private static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// schema.org keys are PascalCase ("@type", "byArtist", "datePublished"); JsonPropertyName drives
// each. Encoder relaxed so the JSON sits inline in HTML without over-escaping apostrophes etc.
// Note: the relaxed encoder leaves <, >, & raw — InlineSafe re-escapes exactly those before the
// body is injected into the <script> element. See Serialize.
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false,
};
/// <summary>
/// Renders a node to its compact JSON-LD script body. The host component wraps it in the script tag.
/// The body is run through <see cref="InlineSafe"/> so CMS-authored values containing
/// <c>&lt;/script&gt;</c> or <c>&lt;</c> cannot break out of the inline script element (XSS).
/// </summary>
public static string Serialize<TNode>(TNode node) where TNode : JsonLdNode =>
InlineSafe(JsonSerializer.Serialize(node, node.GetType(), Options));
/// <summary>
/// Escapes the three characters that can break out of an inline <c>&lt;script type="application/ld+json"&gt;</c>
/// element. Replacing <c>&lt;</c>/<c>&gt;</c>/<c>&amp;</c> with their <c>\uXXXX</c> JSON escapes keeps the
/// JSON byte-for-byte equivalent on parse (a JSON string treats <c><</c> and <c>&lt;</c> identically)
/// while making <c>&lt;/script&gt;</c> impossible to emit raw — the documented safe pattern for inline JSON-LD.
/// </summary>
internal static string InlineSafe(string json) => json
.Replace("<", "\\u003C")
.Replace(">", "\\u003E")
.Replace("&", "\\u0026");
}
/// <summary>Base for every schema.org node: emits <c>@context</c> and <c>@type</c> first.</summary>
public abstract record JsonLdNode
{
[JsonPropertyName("@context")]
[JsonPropertyOrder(-2)]
public string Context => "https://schema.org";
[JsonPropertyName("@type")]
[JsonPropertyOrder(-1)]
public abstract string Type { get; }
}
/// <summary>The Deep DRFT collective entity — the home/about node.</summary>
public sealed record MusicGroupNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "MusicGroup";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("url")] public string? Url { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("description")] public string? Description { get; init; }
[JsonPropertyName("logo")] public string? Logo { get; init; }
[JsonPropertyName("sameAs")] public IReadOnlyList<string>? SameAs { get; init; }
}
/// <summary>A studio cut or a live session release. <c>AlbumProductionType</c> distinguishes them.</summary>
public sealed record MusicAlbumNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "MusicAlbum";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
/// <summary>schema.org <c>MusicAlbumProductionType</c> URI, e.g. <c>StudioAlbum</c> or <c>LiveAlbum</c>.</summary>
[JsonPropertyName("albumProductionType")] public string? AlbumProductionType { get; init; }
[JsonPropertyName("datePublished")] public string? DatePublished { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("image")] public string? Image { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
/// <summary>Ordered list of the album's recordings (cut track list, in TrackNumber order).</summary>
[JsonPropertyName("track")] public IReadOnlyList<MusicRecordingNode>? Track { get; init; }
}
/// <summary>A single recording — a mix release, or one track inside an album's <c>track</c> list.</summary>
public sealed record MusicRecordingNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "MusicRecording";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
/// <summary>ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from <c>DurationSeconds</c>.</summary>
[JsonPropertyName("duration")] public string? Duration { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("image")] public string? Image { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
}
/// <summary>A browse/index surface listing releases (cuts/sessions/mixes/archive).</summary>
public sealed record CollectionPageNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "CollectionPage";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("description")] public string? Description { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
}
/// <summary>A nested <c>byArtist</c> reference — the collective as a MusicGroup, by name.</summary>
public sealed record ArtistRef
{
[JsonPropertyName("@type")] public string Type => "MusicGroup";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
}
+209
View File
@@ -0,0 +1,209 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// The OG <c>og:type</c> for a page. Releases map per medium (§3.4); everything else is a website.
/// </summary>
public enum SeoOgType
{
Website,
MusicAlbum,
MusicSong,
}
/// <summary>
/// The typed per-page SEO input (Phase 22). A page hands <c>SeoHead</c> one model instead of ~15 loose
/// parameters; the named factories below encode the per-page / per-medium mapping (title, description,
/// canonical path, og:type, JSON-LD node) in exactly one place each (DRY, §4.1/§4.2). The factories are
/// pure functions over DTOs the page already holds — unit-testable without rendering.
///
/// <para>
/// <see cref="CanonicalPath"/> is site-relative; <c>SeoHead</c> absolutises it against
/// <see cref="SeoOptions.BaseUrl"/>. Release pages pass <see cref="ReleaseRoutes.DetailHref"/> so the
/// canonical is the dedicated route regardless of alias/query routes (AC7). A null cover means the model
/// carries no <see cref="ImagePath"/> and <c>SeoHead</c> falls back to the default OG image (C6/AC4).
/// </para>
/// </summary>
public sealed record SeoModel
{
/// <summary>Bare page title, no site suffix. <c>SeoHead</c> composes <c>"{Title} · {suffix}"</c>.</summary>
public required string Title { get; init; }
/// <summary>Meta/OG description. Null falls back to <see cref="SeoOptions.DefaultDescription"/>.</summary>
public string? Description { get; init; }
/// <summary>Site-relative canonical path. Null defaults to the current path in <c>SeoHead</c>.</summary>
public string? CanonicalPath { get; init; }
/// <summary>Relative cover <c>ImagePath</c>. Null → the default OG image.</summary>
public string? ImagePath { get; init; }
public SeoOgType OgType { get; init; } = SeoOgType.Website;
/// <summary>Robots directive. Null falls back to <see cref="SeoOptions.DefaultRobots"/>.</summary>
public string? Robots { get; init; }
/// <summary>Pre-serialised JSON-LD script body, or null to emit no structured-data script.</summary>
public string? JsonLd { get; init; }
// --- Music-vertical OG, release pages only (null elsewhere → tags omitted) ---
public string? Artist { get; init; }
public DateOnly? ReleaseDate { get; init; }
public double? DurationSeconds { get; init; }
// ------------------------------------------------------------------ Factories
/// <summary>Home page: the collective entity (MusicGroup JSON-LD), site-level OG.</summary>
public static SeoModel ForHome(SeoOptions options) => new()
{
Title = "Electronic Music Collective",
Description = options.DefaultDescription,
CanonicalPath = "/",
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(MusicGroup(options)),
};
/// <summary>About page: the collective again, with the bio lede as description.</summary>
public static SeoModel ForAbout(SeoOptions options) => new()
{
Title = "The Collective",
Description =
"Two people, many hats. Deep DRFT brings the heart and soul of Midwest deep house to " +
"Charleston — informed by the founders of the style, and promising to push it forward.",
CanonicalPath = "/about",
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(MusicGroup(options) with
{
Description =
"Two people, many hats. Deep DRFT brings the heart and soul of Midwest deep house to " +
"Charleston — informed by the founders of the style, and promising to push it forward.",
}),
};
/// <summary>A browse surface: <c>CollectionPage</c> JSON-LD, website OG.</summary>
public static SeoModel ForBrowse(SeoOptions options, ReleaseMedium? medium, string path)
{
var (title, description) = BrowseCopy(medium);
return new SeoModel
{
Title = title,
Description = description,
CanonicalPath = path,
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(new CollectionPageNode
{
Name = title,
Description = description,
Url = SeoUrls.Absolute(options, path),
}),
};
}
/// <summary>The 404 page: no canonical, <c>noindex,follow</c>, no JSON-LD.</summary>
public static SeoModel ForNotFound(SeoOptions options) => new()
{
Title = "Not Found",
Description = options.DefaultDescription,
Robots = "noindex,follow",
OgType = SeoOgType.Website,
};
/// <summary>
/// A release detail page. The medium picks the schema (cut/session → MusicAlbum, mix → MusicRecording),
/// the og:type, and the music-vertical OG fields; the canonical is the dedicated route. The optional
/// <paramref name="tracks"/> seed the album's ordered <c>track</c> list (cut). <b>One call site, all tags.</b>
/// </summary>
public static SeoModel ForRelease(SeoOptions options, ReleaseDto release, IReadOnlyList<TrackDto>? tracks = null)
{
var canonicalPath = ReleaseRoutes.DetailHref(release.EntryKey, release.Medium);
var image = SeoUrls.CoverOrDefault(options, release.ImagePath);
// byArtist reflects the release's own artist, consistent with the music:musician OG tag (Daniel's
// call) — not the collective name. Album sub-recordings share it: the tracks are by this artist.
var artist = new ArtistRef { Name = release.Artist };
var description = string.IsNullOrWhiteSpace(release.Description) ? options.DefaultDescription : release.Description;
// A mix is a single recording; its duration comes from the (single) track when present.
var mixDurationSeconds = release.Medium == ReleaseMedium.Mix
? tracks?.FirstOrDefault()?.DurationSeconds
: null;
JsonLdNode node = release.Medium switch
{
ReleaseMedium.Mix => new MusicRecordingNode
{
Name = release.Title,
ByArtist = artist,
Duration = SeoUrls.IsoDuration(mixDurationSeconds),
Genre = release.Genre,
Image = image,
Url = SeoUrls.Absolute(options, canonicalPath),
},
// Cut and Session are both albums; the production type distinguishes a live session.
_ => new MusicAlbumNode
{
Name = release.Title,
ByArtist = artist,
AlbumProductionType = release.Medium == ReleaseMedium.Session
? "https://schema.org/LiveAlbum"
: "https://schema.org/StudioAlbum",
DatePublished = release.ReleaseDate?.ToString("yyyy-MM-dd"),
Genre = release.Genre,
Image = image,
Url = SeoUrls.Absolute(options, canonicalPath),
Track = AlbumTracks(options, artist, tracks),
},
};
return new SeoModel
{
Title = release.Title,
Description = description,
CanonicalPath = canonicalPath,
ImagePath = release.ImagePath,
OgType = release.Medium == ReleaseMedium.Mix ? SeoOgType.MusicSong : SeoOgType.MusicAlbum,
Artist = release.Artist,
ReleaseDate = release.ReleaseDate,
DurationSeconds = mixDurationSeconds,
JsonLd = SeoJsonLd.Serialize(node),
};
}
// The collective entity, built once from config — the home/about JSON-LD root.
private static MusicGroupNode MusicGroup(SeoOptions options) => new()
{
Name = options.SiteName,
Url = SeoUrls.Absolute(options, "/"),
Genre = options.Genre,
Description = options.DefaultDescription,
Logo = SeoUrls.Absolute(options, options.DefaultImageUrl),
SameAs = options.SameAs.Count > 0 ? options.SameAs : null,
};
// Ordered recording list for an album's `track` property. Null when there are no tracks so the
// property is omitted rather than emitting an empty array (C6).
private static IReadOnlyList<MusicRecordingNode>? AlbumTracks(
SeoOptions options, ArtistRef artist, IReadOnlyList<TrackDto>? tracks)
{
if (tracks is null || tracks.Count == 0) return null;
return tracks
.OrderBy(t => t.TrackNumber)
.Select(t => new MusicRecordingNode
{
Name = t.TrackName,
ByArtist = artist,
Duration = SeoUrls.IsoDuration(t.DurationSeconds),
})
.ToList();
}
private static (string Title, string Description) BrowseCopy(ReleaseMedium? medium) => medium switch
{
ReleaseMedium.Cut => ("Cuts", "Studio cuts from Deep DRFT — composed, layered, and finished."),
ReleaseMedium.Session => ("Sessions", "Live sessions from Deep DRFT — performances caught in the moment, unrepeatable and unedited."),
ReleaseMedium.Mix => ("Mixes", "DJ mixes from Deep DRFT — uninterrupted sets, one track bleeding into the next."),
_ => ("Archive", "The full Deep DRFT catalogue — cuts, sessions, and mixes, indexed and always expanding."),
};
}
@@ -0,0 +1,52 @@
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Site-wide SEO defaults (Phase 22). These are non-secret brand constants — a single canonical origin,
/// the site name/suffix, the fallback share image, the social links — sourced once and injected into
/// <c>SeoHead</c> so no page re-declares them. Registered as a singleton in
/// <see cref="Startup.ConfigureDomainServices"/>, which runs in <b>both</b> the server prerender and the
/// WASM passes, so both passes resolve identical values (the double-render-identity requirement, §5/AC6).
///
/// <para>
/// <see cref="BaseUrl"/> is the load-bearing field: absolute canonical / <c>og:url</c> / <c>og:image</c>
/// origins all come from here, never from a browser API — there is no <c>window.location</c> during
/// server prerender, and the request host is unreliable behind the nginx reverse proxy (§5, OQ1).
/// </para>
/// </summary>
public sealed record SeoOptions
{
/// <summary>Canonical production origin, no trailing slash. Absolute URLs are this + a resolved path (OQ1).</summary>
public string BaseUrl { get; init; } = "https://deepdrft.com";
/// <summary>The brand name used in <c>og:site_name</c>, <c>application-name</c>, and the JSON-LD MusicGroup.</summary>
public string SiteName { get; init; } = "Deep DRFT";
/// <summary>Appended to a page's bare title as <c>"{Title} · {TitleSuffix}"</c>. Resolves the prior suffix inconsistency (OQ4).</summary>
public string TitleSuffix { get; init; } = "Deep DRFT";
/// <summary>Fallback meta/OG description for pages that supply none.</summary>
public string DefaultDescription { get; init; } =
"Deep DRFT — an electronic music collective from Charleston, South Carolina. Studio cuts, live sessions, and DJ mixes.";
/// <summary>
/// Absolute or root-relative URL of the default 1200×630 share image used when a page has no cover (OQ2).
/// A placeholder path until the real asset is dropped in; swapping it is a one-value change.
/// </summary>
public string DefaultImageUrl { get; init; } = "/img/og-default.png";
/// <summary>OG locale. Optional surface tag.</summary>
public string Locale { get; init; } = "en_US";
/// <summary>The collective's primary genre, used in the MusicGroup JSON-LD node.</summary>
public string Genre { get; init; } = "Electronic";
// The default robots directive is NOT a static option — it is environment-gated (Production →
// index,follow; non-production → noindex,nofollow) via SeoEnvironment so the beta/staging site is
// never crawled. A page's explicit SeoModel.Robots still overrides that default.
/// <summary>
/// Public social profile URLs for the MusicGroup <c>sameAs</c> array (OQ3). Instagram only —
/// no Twitter/X account exists, so no <c>twitter:site</c>/<c>twitter:creator</c> handle is emitted.
/// </summary>
public IReadOnlyList<string> SameAs { get; init; } = ["https://instagram.com/deepdrft.music"];
}
+44
View File
@@ -0,0 +1,44 @@
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Absolute-URL composition for SEO tags (Phase 22). Canonical / <c>og:url</c> / <c>og:image</c> origins
/// all come from <see cref="SeoOptions.BaseUrl"/> (config), never from a browser API — there is no
/// <c>window.location</c> during server prerender and the request host is unreliable behind nginx
/// (§5, OQ1). Shared by the <c>SeoModel</c> factories (which absolutise JSON-LD <c>url</c>/<c>image</c>)
/// and <c>SeoHead</c> (which absolutises the meta/OG tags) so the rule lives in exactly one place.
/// </summary>
public static class SeoUrls
{
/// <summary>BaseUrl + a site-relative path. Both sides are trimmed so the join never doubles or drops the slash.</summary>
public static string Absolute(SeoOptions options, string path)
{
var origin = options.BaseUrl.TrimEnd('/');
if (string.IsNullOrEmpty(path)) return origin;
return $"{origin}/{path.TrimStart('/')}";
}
/// <summary>
/// Absolute URL of a release/track cover from its FileDatabase <c>ImagePath</c>, via the public image
/// route (<c>api/image/{escaped}</c>). Returns the configured default share image when no cover exists
/// (C6/AC4 — a default guarantees <c>og:image</c> presence).
/// </summary>
public static string CoverOrDefault(SeoOptions options, string? imagePath)
{
if (string.IsNullOrWhiteSpace(imagePath))
return Absolute(options, options.DefaultImageUrl);
return Absolute(options, $"api/image/{Uri.EscapeDataString(imagePath)}");
}
/// <summary>
/// ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from a seconds value, for JSON-LD <c>duration</c> and the
/// <c>music:duration</c> OG tag. Null / non-finite / non-positive input yields null (omit the tag).
/// </summary>
public static string? IsoDuration(double? seconds)
{
if (seconds is null || double.IsNaN(seconds.Value) || double.IsInfinity(seconds.Value) || seconds.Value <= 0)
return null;
return System.Xml.XmlConvert.ToString(TimeSpan.FromSeconds(seconds.Value));
}
}
@@ -3,10 +3,11 @@
@using DeepDrftPublic.Client.Services
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the
cascaded IQueueService's Enqueue/EnqueueRange (which append without disturbing current playback and
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select.
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@
cascaded IQueueService's Enqueue/EnqueueRange (which append to the END without disturbing current
playback; a first add into a dormant queue seeds the head from the externally-playing track when one
exists, then appends) — never PlayRelease/PlayTrack/Start/Select. Track mode (Track set) appends a
single track; release mode (ReleaseTracks set) appends the whole ordered list. Reads queue state from
the layout-level cascade (C1); owns no data fetch. *@
<MudTooltip Text="@Tooltip">
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
@@ -10,6 +10,22 @@ else
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
<MudPaper Elevation="8" Class="player-surface pa-3">
@* Theater Mode "now showing" band (Phase 20 §5/§7, Wave 2 §2). Keyed off the playing
track's Release, not off any detail page (the bar reaches into no page; §6). The release
page is hidden in Theater Mode, so the bar carries its identity: cover, linked title,
release share. The band stays mounted whenever a release is playing and eases in/out via
the shared .dd-theater-collapsible wrapper — collapsed (zero height, faded) unless
Theater is ON — so the bar grows/shrinks smoothly instead of popping. *@
@if (CurrentTrack?.Release is not null)
{
var nowShowing = VisualizerControlState.TheaterMode;
<div class="dd-theater-collapsible @(nowShowing ? null : "dd-theater-collapsed")">
<div class="dd-theater-collapsible-inner">
<NowShowingPanel Release="CurrentTrack.Release" />
</div>
</div>
}
<div class="player-layout">
<PlayerTransportZone IsLoaded="IsLoaded"
CanPlay="CanPlay"
@@ -45,7 +61,7 @@ else
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3).
Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked
overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started).
overlay, which calls JumpTo (moves the pointer and streams the row, clearing IsArmed).
Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
shrunken height to the host iframe. *@
@@ -16,12 +16,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
// Theater Mode (Phase 20). Property-injected (no constructor growth) so the bar can read
// TheaterMode to mount the "now showing" band and re-render when the flag flips. The toggle lives on
// the detail pages; the bar only observes — single source, multiple observers (§6).
[Inject] private WaveformVisualizerControlState VisualizerControlState { get; set; } = default!;
private bool _isMinimized = true;
private bool _isSeeking = false;
private double _seekPosition = 0;
private bool _queueOpen = false;
private IStreamingPlayerService? _subscribedService;
private IQueueService? _subscribedQueue;
private bool _subscribedToVisualizerState;
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
// spacer reserves its space. We mirror this element's live height into a CSS
@@ -85,7 +91,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
private bool ShowFixedPanel => Fixed && HasQueue;
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
// Cached snapshot of the queue list (bug #4 fix). QueueService.Items returns the service's
// backing list by reference, so passing it straight through means Blazor parameter diffing sees
// an unchanged reference after an in-place Clear/remove/reorder and the child (QueueList /
// MudDropContainer) keeps its stale snapshot until reopened. We snapshot on first access and
// rebuild in OnQueueChanged, so every real mutation hands the child a NEW reference while
// progress-tick re-renders (the frequent path) reuse the cached one without allocating.
private IReadOnlyList<TrackDto>? _queueItemsCache;
private IReadOnlyList<TrackDto> QueueItems =>
_queueItemsCache ??= QueueService is null ? [] : QueueService.Items.ToList();
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
@@ -135,12 +149,28 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
QueueService.QueueChanged += OnQueueChanged;
_subscribedQueue = QueueService;
}
// Theater Mode (Phase 20 §7): re-render the bar when TheaterMode flips so the "now showing" band
// appears/disappears. VisualizerControlState is injected (one stable scoped instance per session),
// so the subscribe is once-only — same idempotent subscribe-here / unsubscribe-on-dispose shape.
if (!_subscribedToVisualizerState)
{
VisualizerControlState.Changed += OnVisualizerStateChanged;
_subscribedToVisualizerState = true;
}
}
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private void OnQueueChanged()
{
// Invalidate the snapshot so QueueItems rebuilds a fresh list on the next render.
// This gives Blazor a new reference on every real mutation (bug #4 reactivity preserved)
// while progress-tick re-renders that don't go through here keep the cached reference.
_queueItemsCache = null;
// If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close
// the overlay so it cannot strand open over an empty queue. The button gate hides the overlay
// mount too, so this keeps state and view consistent.
@@ -189,12 +219,14 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private void ClearUpcoming() => QueueService?.ClearUpcoming();
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that
// touches playback — it streams the chosen track via the player.
// Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY,
// not an in-place seek), so a jump cannot route through it without duplicating the queue. JumpTo
// moves the pointer to the chosen row and streams it once — preserving deque order. This is the one
// queue action besides PLAY/skip that touches playback.
private async Task OnQueueJump(int index)
{
if (QueueService == null) return;
await QueueService.PlayRelease(QueueService.Items, index);
await QueueService.JumpTo(index);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -387,6 +419,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
_subscribedQueue = null;
}
if (_subscribedToVisualizerState)
{
VisualizerControlState.Changed -= OnVisualizerStateChanged;
_subscribedToVisualizerState = false;
}
if (_spacerModule is not null)
{
try
@@ -56,6 +56,54 @@
color: var(--mud-palette-primary);
}
/* Theater Mode "now showing" band (Phase 20 §5/§7). Sits above the transport layout inside the
player surface and lets the bar grow taller to carry the hidden release's identity. The band only
renders when Theater is ON, so this geometry is gated by render-inclusion, not a CSS flag — when
Theater is OFF the player bar is byte-for-byte its non-Theater self.
Colour/surface come from the bar's themed --deepdrft-page-* aliases; no new token, no dark override. */
::deep .now-showing {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--deepdrft-page-text-muted);
min-width: 0;
}
/* Fixed cover box — the reused .deepdrft-track-detail-cover-art / -placeholder idioms are height:100%,
so the band supplies the square frame they fill. */
::deep .now-showing-cover {
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 6px;
overflow: hidden;
}
::deep .now-showing-cover-art,
::deep .now-showing-cover-placeholder {
width: 100%;
}
::deep .now-showing-cover-placeholder .mud-icon-root {
font-size: 24px;
}
::deep .now-showing-title-link {
flex: 1 1 auto;
min-width: 0;
text-decoration: none;
}
::deep .now-showing-title {
color: var(--deepdrft-page-text);
}
::deep .now-showing-share {
flex: 0 0 auto;
}
/* Minimized floating dock — positioning + hover only; colour from MudFab */
.minimized-dock {
position: fixed;
@@ -0,0 +1,46 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
@using DeepDrftModels.DTOs
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Controls
@* "Now showing" block surfaced in the player bar when Theater Mode is ON (Phase 20 §5/§7). Theater
hides the release page, so the bar carries the release identity the page would have shown: cover art,
the release title linked to its detail page, and a release-mode share. Purely presentational — it owns
no player logic and no Theater state; AudioPlayerBar mounts it only when state.TheaterMode &&
CurrentTrack?.Release is not null, so Release is non-null here.
Theming is all reuse (§8, zero new CSS): the cover reuses the deepdrft-track-detail-cover-art /
-placeholder idiom; the share glyph goes green-accent in both themes via .dd-accent-icon; surface and
text come from the bar's own .player-surface and the .now-showing-* classes in the global sheet, which
bind the theme-aware --deepdrft-page-* aliases. *@
<div class="now-showing">
<div class="now-showing-cover">
@if (!string.IsNullOrEmpty(Release.ImagePath))
{
<div class="deepdrft-track-detail-cover-art now-showing-cover-art"
style="@($"background-image: url('api/image/{Uri.EscapeDataString(Release.ImagePath)}');")"></div>
}
else
{
<div class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary now-showing-cover-placeholder">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</div>
}
</div>
<a href="@ReleaseRoutes.DetailHref(Release)" class="now-showing-title-link">
<MudText Typo="Typo.subtitle2" Class="now-showing-title text-truncate">
@Release.Title
</MudText>
</a>
<div class="dd-accent-icon now-showing-share">
<SharePopover ReleaseEntryKey="@Release.EntryKey" ReleaseMedium="@Release.Medium" />
</div>
</div>
@code {
/// <summary>The current playing track's release. Non-null by the bar's mount gate.</summary>
[Parameter, EditorRequired] public ReleaseDto Release { get; set; } = default!;
}
@@ -71,6 +71,13 @@
private MudDropContainer<QueueRow>? _dropContainer;
// MudDropContainer snapshots its Items into internal drop zones and does not re-read them on a
// plain re-render — so a Clear/remove/reorder that changes the parent's Items list must be pushed
// into the container explicitly, or the panel shows the stale order until reopened (bug #4). The
// parent passes a fresh Items reference per mutation; refreshing here on every parameter set re-flows
// the container's snapshot to match. Cheap: Refresh only re-reads the bound list.
protected override void OnParametersSet() => _dropContainer?.Refresh();
// Index-tagged view rows. The index is the row's position in Items at render time and is the
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
private List<QueueRow> Rows =>
@@ -20,7 +20,7 @@
Class="deepdrft-queue-overlay">
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
<div class="deepdrft-queue-modal-header">
<span class="deepdrft-queue-modal-title">Up Next</span>
<span class="deepdrft-queue-modal-title">Playlist</span>
<MudButton Variant="Variant.Text"
Size="Size.Small"
Color="Color.Primary"
@@ -13,6 +13,7 @@ namespace DeepDrftPublic.Client.Controls;
public partial class ReleaseDetailScaffold : ComponentBase
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
[Parameter] public required string Title { get; set; }
[Parameter] public string? Artist { get; set; }
@@ -96,13 +97,19 @@ public partial class ReleaseDetailScaffold : ComponentBase
{
if (Track is null || PlayerService is null) return;
// Toggle if this track is already active (playing or paused); otherwise start a fresh
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
// Toggle if this track is already active (playing or paused); otherwise PLAY it —
// prepend to the queue's front (deque PLAY semantics) so it becomes current and
// the existing queue stays intact behind it. Falls back to a direct stream when
// the queue cascade is absent (prerender / non-interactive).
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(Track);
}
else
{
await PlayerService.SelectTrackStreaming(Track);
@@ -0,0 +1,116 @@
@using DeepDrftPublic.Client.Common
@inject SeoOptions Seo
@inject SeoEnvironment SeoEnv
@inject NavigationManager Nav
@*
The single reusable SEO head surface (Phase 22). Presentational and parameter-fed — owns no fetch and
no business logic (C4); it reads the injected SeoOptions for defaults and NavigationManager for the
current path. Renders <PageTitle> (the sole title source — pages drop their bare <PageTitle>) plus a
<HeadContent> block carrying the full standard/OG/Twitter/JSON-LD surface (§3), projected into the
<HeadOutlet> in App.razor so it is present in the prerendered HTML a crawler sees (C2/AC1).
Identical output across the InteractiveAuto double render (AC6): every value comes from the parameter
Model (built from the page's bridged PersistentComponentState) and config — never a browser API — so
the prerender and WASM passes render byte-identical tags.
Partial data (C6/AC4): a missing value falls back to config or omits its tag; og:image always resolves
(the default guarantees presence) so there is never a content="" attribute or a broken node.
*@
<PageTitle>@_fullTitle</PageTitle>
<HeadContent>
@* Standard / search *@
<meta name="description" content="@_description" />
<link rel="canonical" href="@_canonical" />
<meta name="robots" content="@_robots" />
<meta name="application-name" content="@Seo.SiteName" />
@* Open Graph *@
<meta property="og:title" content="@Model.Title" />
<meta property="og:description" content="@_description" />
<meta property="og:url" content="@_canonical" />
<meta property="og:type" content="@_ogType" />
<meta property="og:site_name" content="@Seo.SiteName" />
<meta property="og:locale" content="@Seo.Locale" />
<meta property="og:image" content="@_image" />
@if (_hasCover)
{
<meta property="og:image:alt" content="@($"{Model.Title} cover art")" />
}
@* Music-vertical OG (release pages only) *@
@if (!string.IsNullOrWhiteSpace(Model.Artist))
{
<meta property="music:musician" content="@Model.Artist" />
}
@if (Model.ReleaseDate is not null)
{
<meta property="music:release_date" content="@Model.ReleaseDate.Value.ToString("yyyy-MM-dd")" />
}
@if (_isoDuration is not null)
{
<meta property="music:duration" content="@_isoDuration" />
}
@* Twitter Card. No twitter:site / twitter:creator — no X account exists (OQ3). *@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@Model.Title" />
<meta name="twitter:description" content="@_description" />
<meta name="twitter:image" content="@_image" />
@* JSON-LD structured data *@
@if (!string.IsNullOrEmpty(Model.JsonLd))
{
<script type="application/ld+json">@((MarkupString)Model.JsonLd)</script>
}
</HeadContent>
@code {
/// <summary>The page's resolved SEO input, built via a <see cref="SeoModel"/> factory.</summary>
[Parameter, EditorRequired] public required SeoModel Model { get; set; }
private string _fullTitle = string.Empty;
private string _description = string.Empty;
private string _canonical = string.Empty;
private string _robots = string.Empty;
private string _ogType = "website";
private string _image = string.Empty;
private bool _hasCover;
private string? _isoDuration;
protected override void OnParametersSet()
{
_fullTitle = $"{Model.Title} · {Seo.TitleSuffix}";
_description = string.IsNullOrWhiteSpace(Model.Description) ? Seo.DefaultDescription : Model.Description;
// Default robots is environment-gated (non-production → noindex,nofollow) so beta/staging is never
// crawled; an explicit per-page Robots still wins (e.g. the 404's / soft-404's noindex,follow).
_robots = string.IsNullOrWhiteSpace(Model.Robots) ? SeoEnv.DefaultRobots : Model.Robots;
_ogType = OgTypeString(Model.OgType);
// Canonical: BaseUrl + the model's path, defaulting to the current relative path. The origin is
// always config (no browser API) so prerender and WASM agree (§5).
var path = Model.CanonicalPath ?? RelativePath();
_canonical = SeoUrls.Absolute(Seo, path);
_hasCover = !string.IsNullOrWhiteSpace(Model.ImagePath);
_image = SeoUrls.CoverOrDefault(Seo, Model.ImagePath);
_isoDuration = SeoUrls.IsoDuration(Model.DurationSeconds);
}
private string RelativePath()
{
var path = Nav.ToBaseRelativePath(Nav.Uri);
var query = path.IndexOf('?');
if (query >= 0) path = path[..query];
return "/" + path;
}
private static string OgTypeString(SeoOgType type) => type switch
{
SeoOgType.MusicAlbum => "music.album",
SeoOgType.MusicSong => "music.song",
_ => "website",
};
}
@@ -27,6 +27,7 @@
[Parameter] public string LoadingLabel { get; set; } = "Finding a track…";
[Parameter] public EventCallback OnStreamStarted { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
private bool _streamLoading;
@@ -79,7 +80,12 @@
_findingTrack = false;
StateHasChanged();
if (PlayerService is not null)
// PLAY semantics: prepend to the queue's front so a "stream now" track becomes current and
// any existing queue stays intact behind it. Falls back to a direct stream when the queue
// cascade is absent.
if (Queue is not null)
await Queue.PlayTrack(track);
else if (PlayerService is not null)
await PlayerService.SelectTrackStreaming(track);
}
catch (Exception)
@@ -0,0 +1,59 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@implements IDisposable
@inject WaveformVisualizerControlState State
@* Theater-Mode toggle (Phase 20 §3). The single affordance placed identically on all three release
detail pages — immediately to the LEFT of the lava-lamp WaveformVisualizerControlPopover trigger.
It is purely a mutation surface: tapping it flips State.TheaterMode and raises Changed; the detail
pages observe that to gate their content @if, and the player bar observes it to grow. This component
reaches into no page and no bar — single source, multiple observers (§6).
Visible only when the lava OR waveform subsystem is on — there is nothing to go to theater FOR if both
are off (§3.2) — AND when <see cref="Available"/> is true. The page supplies Available so the toggle
only appears when this page's release is the one playing (Phase 20 Wave 2 §3): the toggle owns the
subsystem gate; the page owns the release-playing predicate. Disabled until interactive (§3.4), the
same prerender guard the lava/Play buttons use. Active visual state when Theater is ON. .dd-accent-icon
gives the green-accent glyph in both themes with zero new CSS (§8) — same as the lava-lamp trigger. *@
@if (Available && (State.LavaEnabled || State.WaveformEnabled))
{
<div class="dd-accent-icon">
<MudTooltip Text="@(State.TheaterMode ? "Exit theater mode" : "Theater mode")">
<MudIconButton Icon="@Icons.Material.Filled.Theaters"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Theater mode"
aria-pressed="@State.TheaterMode" />
</MudTooltip>
</div>
}
@code {
/// <summary>Trigger-icon size. Defaults Large to match the lava-lamp popover trigger it sits beside.</summary>
[Parameter] public Size IconSize { get; set; } = Size.Large;
/// <summary>
/// Whether the toggle is available on this surface (Phase 20 Wave 2 §3). The page passes the
/// "this release is the one playing" predicate here; Theater Mode only applies to the playing
/// release, so a detail page whose release is not playing passes <c>false</c> and shows no toggle.
/// Defaults <c>true</c> so surfaces with no release-scoping (none today) keep the subsystem-only gate.
/// </summary>
[Parameter] public bool Available { get; set; } = true;
protected override void OnInitialized() => State.Changed += OnStateChanged;
// The toggle's own visibility and active state both key off State, which another observer (or this
// button) may mutate, so re-render on every Changed — same idempotent posture the visualizer bridge uses.
private void OnStateChanged() => InvokeAsync(StateHasChanged);
private void Toggle()
{
State.TheaterMode = !State.TheaterMode;
State.NotifyChanged();
}
public void Dispose() => State.Changed -= OnStateChanged;
}
@@ -34,147 +34,164 @@
@if (Visible)
{
@* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudGrid>
@* ── Row 1 — MODE (always visible). ── *@
<MudItem xs="2" Class="d-flex align-center">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
<span class="wvc-section-label">MODE:</span>
</MudStack>
</MudItem>
<MudTooltip Text="Show the sound, or hide the ribbon.">
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
Color="Color.Primary"
OnClick="@ToggleWaveform"
aria-label="Waveform ribbon on or off"
aria-pressed="@ControlState.WaveformEnabled"/>
</div>
</MudTooltip>
<MudItem xs="10">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="Show the sound, or hide the ribbon.">
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
Color="Color.Primary"
OnClick="@ToggleWaveform"
aria-label="Waveform ribbon on or off"
aria-pressed="@ControlState.WaveformEnabled"/>
</div>
</MudTooltip>
@* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one
@* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one
present, so visible only when BOTH are on (§3 truth table). *@
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
{
<MudTooltip Text="How hard the blobs body-check the beat.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
}
@* </div> *@
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
{
<MudTooltip Text="How hard the blobs body-check the beat.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Compress"
Size="Size.Small"
Color="Color.Primary"
Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
}
@* Color applies to the whole field regardless of which subsystems are on, so it is pinned
far-right of row 1 and never reflows when collisions hides (§3). *@
<MudTooltip Text="How fast the lamp drifts through its colors.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
{
<MudTooltip Text="How fast the lamp drifts through its colors.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
}
<MudTooltip Text="Light the lamp — or let it go cold.">
<div class="wvc-toggle @(ControlState.LavaEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Lava field on or off">
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleLava"
aria-label="Lava field on or off"
aria-pressed="@ControlState.LavaEnabled"/>
</div>
</MudTooltip>
</MudStack>
@* ── Row 2 — WAVE section (only when waveform on). Both controls are RadialKnobs (scroll reverted
from MudSlider per Phase 15 polish); width pinned far-right via wvc-row-wave space-between. ── *@
@if (ControlState.WaveformEnabled)
{
<div class="wvc-row wvc-row-section wvc-row-wave">
<span class="wvc-section-label">WAVE:</span>
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How fast the sound rolls by.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
<RadialKnob Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Color="Color.Surface" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
<MudTooltip Text="Light the lamp — or let it go cold.">
<div class="wvc-toggle @(ControlState.LavaEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Lava field on or off">
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleLava"
aria-label="Lava field on or off"
aria-pressed="@ControlState.LavaEnabled"/>
</div>
</MudTooltip>
</MudStack>
</div>
}
@* ── Row 3 — LAVA section (only when lava on). ── *@
@if (ControlState.LavaEnabled)
{
<div class="wvc-row wvc-row-section">
<span class="wvc-section-label">LAVA:</span>
</MudItem>
@* ── Row 2 — WAVE section (only when waveform on). ── *@
@if (ControlState.WaveformEnabled)
{
<MudItem xs="2" Class="d-flex align-center">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
<span class="wvc-section-label">WAVE:</span>
</MudStack>
</MudItem>
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How heavy the wax feels — float, or sink.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudItem xs="10">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How fast the sound rolls by.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
<RadialKnob Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Color="Color.Surface" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
</MudStack>
</MudItem>
}
@if (ControlState.LavaEnabled)
{
<MudItem xs="2" Class="d-flex align-center">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
<span class="wvc-section-label">LAVA:</span>
</MudStack>
</MudItem>
<MudTooltip Text="How much goo is in the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudItem xs="10" Class="d-flex align-center">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How heavy the wax feels — float, or sink.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
<MudTooltip Text="Runny and gooey, or tight little globes.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</MudStack>
</div>
}
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
<MudTooltip Text="How much wax is in the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
<MudTooltip Text="Runny and gooey, or tight little globes.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
</MudStack>
</MudItem>
}
</MudGrid>
}
</div>
@@ -209,12 +226,14 @@
private void ToggleLava()
{
ControlState.LavaEnabled = !ControlState.LavaEnabled;
ControlState.CoerceTheaterMode();
ControlState.NotifyChanged();
}
private void ToggleWaveform()
{
ControlState.WaveformEnabled = !ControlState.WaveformEnabled;
ControlState.CoerceTheaterMode();
ControlState.NotifyChanged();
}
@@ -38,3 +38,8 @@
color: var(--mud-palette-primary);
opacity: 0.78;
}
.wvc-row {
display: flex;
width: 100%;
}
+5 -9
View File
@@ -2,8 +2,9 @@
@using DeepDrftPublic.Client.Controls
@implements IAsyncDisposable
@inject IJSRuntime JsRuntime
@inject SeoOptions Seo
<PageTitle>The Collective - Deep DRFT</PageTitle>
<SeoHead Model="@SeoModel.ForAbout(Seo)" />
@* ──────────────────────────────────────────────────────────────────────────────
THE LINER NOTES — a numbered three-movement editorial essay.
@@ -343,12 +344,7 @@
}
return sb.ToString();
}
// Member bios. Khabran's body is an intentional empty slot — the card composes
// without it (graceful degrade). Daniel's copy is verbatim per spec COPY C,
// including the two typos he chose to keep ("embarked in", "metalhead at from").
// PortraitImage* are null until final portrait files land — the card renders a
// placeholder treatment in their absence.
private record Member(
string Name,
string Role,
@@ -361,13 +357,13 @@
new(
Name: "Khabran Peters",
Role: "Production · Sound Design · Live",
Bio: "Raised on the Chicago underground, this artist cut their teeth on DJ Assault and DJ Funk. They started DJing young, learning to read a room long before they opened a DAW. After fifteen years as a visual artist, they moved into music production.\n\nNow based in Charleston, their sound carries the city's late-night feel but keeps the kinetic edge of its Midwest roots—deep one minute, fast the next. As much indie sensibility as booty-house grit.\n\nThe work is hardware-first, with software kept to remixes and edits. Onstage they stay out of the way and let the tracks do the talking. Polished without being precious—built by someone who cares more about the craft than the spotlight.",
Bio: "Raised on the Chicago underground, this artist cut his teeth on DJ Assault and DJ Funk. He started DJing young, learning to read a room long before he opened a DAW. After fifteen years as a visual artist, he moved into music production.\n\nNow based in Charleston, his sound carries the city's late-night feel but keeps the kinetic edge of its Midwest roots—deep one minute, fast the next. As much indie sensibility as booty-house grit.\n\nThe work is hardware-first, with software kept to remixes and edits. Onstage he stays out of the way and lets the tracks do the talking. Polished without being precious—built by someone who cares more about the craft than the spotlight.",
PortraitImage1: "img/dd-khabran-bw.jpeg",
PortraitImage2: "img/dd-khabran.jpeg"),
new(
Name: "Daniel Harvey",
Role: "Production · Sound Design · Live",
Bio: "Daniel started on drums at ten and embarked in electronic music at seventeen — synthesizers first. A metalhead at from a young age, he spent ten years as an engineer living near Detroit filling the nights with synthesized tones and rhythms, shaped most of all by the thriving local underground techno scene.\n\nNow back home in the lowcountry, Daniel carries the varied sounds of his past into a new future, inspired by the wandering cypress swamps and soulful sunsets over the Ashley River.\n\nArt & engineering cannot be separated: custom plugins, hardware recording & performance rigs; the tools behind the tracks are just as important as the finished sound. To him the science and the math matter as much as the beauty — tension and release, built deliberately.",
Bio: "Daniel started on drums at ten and embarked in electronic music at seventeen — synthesizers first. A metalhead from a young age, he spent ten years as an engineer living near Detroit filling the nights with synthesized tones and rhythms, shaped most of all by the thriving local underground techno scene.\n\nNow back home in the lowcountry, Daniel carries the varied sounds of his past into a new future, inspired by the wandering cypress swamps and soulful sunsets over the Ashley River.\n\nArt & engineering cannot be separated: custom plugins, hardware recording & performance rigs; the tools behind the tracks are just as important as the finished sound. To him, the science and the math matter as much as the beauty — tension and release, built deliberately.",
PortraitImage1: "img/dd-daniel-bw.jpeg",
PortraitImage2: "img/dd-daniel.jpeg"),
];
+3 -1
View File
@@ -1,7 +1,9 @@
@page "/cuts"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inject SeoOptions Seo
<PageTitle>DeepDrft Cuts</PageTitle>
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Cut, "/cuts")" />
@* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table.
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
@@ -1,8 +1,9 @@
@page "/archive"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inject SeoOptions Seo
<PageTitle>DeepDrft Archive</PageTitle>
<SeoHead Model="@SeoModel.ForBrowse(Seo, null, "/archive")" />
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
@@ -9,7 +9,7 @@
flex-wrap: wrap;
align-items: center;
gap: 24px;
padding: 36px 0 20px 0;
padding: 12px 0 20px 0;
}
.archive-controls-search {
+37 -6
View File
@@ -3,8 +3,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits CutDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -17,6 +16,9 @@
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
(mirrors the dedicated /404 NotFound page). *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
@@ -37,6 +39,14 @@ else
var hasYear = release.ReleaseDate is not null;
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
@* SEO head — fed from the same bridged release + ordered tracks, so the prerender and WASM passes
render identical tags (AC6). MusicAlbum/StudioAlbum with the ordered track list (§3.4). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release, ViewModel.Tracks)" />
@* Full-screen content body (Phase 20 Wave 2 §1): the scaffold has no Class param, so a thin wrapper
carries the min-height. dd-detail-fill keeps the body >= viewport height (below the nav) so the
ambient visualizer reads full-screen and the site footer is pushed below the fold. *@
<div class="dd-detail-fill">
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@firstTrack"
@@ -54,11 +64,24 @@ else
TrackEntryKey="@firstTrack?.EntryKey" />
</Ambient>
<TopRightAction>
@* Lava-lamp icon → popover panel (full parity, §3d-revised). Sits top-right across from the
back link, clear of the header's own Play/Share affordances below. *@
<WaveformVisualizerControlPopover />
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both are
controls over the experience, not release content, so both stay in Theater Mode (§4/OQ4).
Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
<div class="dd-detail-top-actions">
@* Theater toggle only appears when this Cut is the currently-playing release (Phase 20
Wave 2 §3). ShowTheaterToggle folds in the subsystem gate + the release-playing check. *@
<TheaterModeToggle Available="ShowTheaterToggle" />
@* Lava-lamp icon → popover panel (full parity, §3d-revised). Sits top-right across from the
back link, clear of the header's own Play/Share affordances below. *@
<WaveformVisualizerControlPopover />
</div>
</TopRightAction>
<Header>
@* Theater Mode (Phase 20 §4, Wave 2 §2): the release content stays mounted and eases out via
a collapsing wrapper so it does not pop — IsContentHidden collapses it to zero height when
Theater is on AND this Cut is the playing release. OFF eases it back to its normal layout. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
<div class="cut-detail-header">
<div class="cut-detail-meta">
@@ -117,8 +140,13 @@ else
}
</div>
</div>
</div>
</div>
</Header>
<BodyContent>
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Header region. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Blurb sits between the header and the track-list divider. *@
<ReleaseDescription Description="@release.Description" />
<MudDivider Class="cut-detail-divider" />
@@ -149,12 +177,15 @@ else
}
</div>
}
</div>
</div>
</BodyContent>
</ReleaseDetailScaffold>
</div>
}
@code {
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// PlayerService is cascaded by CutDetailBase (used there for the Theater release-playing predicate).
[CascadingParameter] public IQueueService? Queue { get; set; }
// Header Play: load the full album into the queue starting at track 0.
+65 -2
View File
@@ -1,4 +1,5 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
@@ -19,7 +20,41 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
[Inject] public required CutDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
// Theater Mode (Phase 20). The page owns the content gate, so it must re-render when the flag flips
// on the toggle. Property-injected; no constructor growth.
[Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; }
// Theater Mode is scoped to the currently-playing release (Phase 20 Wave 2 §3). The page observes
// player state so the toggle availability and content gate re-evaluate live when playback starts,
// stops, or moves to a different release. Cascaded by AudioPlayerProvider; no constructor growth.
// The cascade is IsFixed, so the provider's own re-render does not reach this page — the page must
// subscribe to StateChanged to re-render itself (same posture as AudioPlayerBar).
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
private IStreamingPlayerService? _subscribedPlayer;
/// <summary>
/// True when the currently-playing track belongs to this page's release. Theater Mode only applies
/// to the playing release: a detail page whose release is not playing ignores the global flag and
/// shows no toggle. Identity is the release <c>EntryKey</c> — the canonical public key the routes
/// and <see cref="DeepDrftPublic.Client.Common.ReleaseRoutes"/> use.
/// </summary>
protected bool IsThisReleasePlaying =>
PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey;
/// <summary>
/// True when this page's release content should be hidden for Theater Mode — only when Theater is on
/// AND this release is the one playing. Drives the eased collapse of the header/track-list regions.
/// </summary>
protected bool IsContentHidden => VisualizerControlState.TheaterMode && IsThisReleasePlaying;
/// <summary>
/// True when the Theater toggle should be offered on this page: a visualizer subsystem is on AND
/// this page's release is the one playing.
/// </summary>
protected bool ShowTheaterToggle =>
(VisualizerControlState.LavaEnabled || VisualizerControlState.WaveformEnabled) && IsThisReleasePlaying;
// The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g.
// /cuts/{a} -> /cuts/{b}) which reuse this component instance and fire OnParametersSet without
@@ -28,10 +63,29 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
private bool _loaded;
protected override void OnInitialized()
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
VisualizerControlState.Changed += OnVisualizerStateChanged;
}
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
protected override async Task OnParametersSetAsync()
{
// The player cascade is IsFixed, so the provider's re-render does not reach this page; subscribe
// to the StateChanged side-channel to re-render when playback moves between releases. Idempotent
// (reference-guarded) and unsubscribed on dispose — same posture as AudioPlayerBar.
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedPlayer))
{
if (_subscribedPlayer is not null)
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedPlayer = PlayerService;
}
if (_loaded && _loadedKey == EntryKey) return;
// Capture the key synchronously before any await so a re-entrant call (rapid navigation or a
@@ -61,7 +115,16 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
public void Dispose()
{
_persistingSubscription.Dispose();
VisualizerControlState.Changed -= OnVisualizerStateChanged;
if (_subscribedPlayer is not null)
{
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
_subscribedPlayer = null;
}
}
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
+2 -1
View File
@@ -1,8 +1,9 @@
@page "/"
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inject SeoOptions Seo
<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>
<SeoHead Model="@SeoModel.ForHome(Seo)" />
@* Hero - split 50/50 *@
<section class="hero">
+46 -12
View File
@@ -2,8 +2,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -16,6 +15,9 @@
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
(mirrors the dedicated /404 NotFound page). *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
@@ -32,6 +34,11 @@ else if (ViewModel.NotFound || ViewModel.Release is null)
else
{
var release = ViewModel.Release;
var mixTracks = ViewModel.Track is not null ? new[] { ViewModel.Track } : null;
@* SEO head — fed from the same bridged release + single track, so prerender and WASM render identical
tags (AC6). MusicRecording with ISO-8601 duration from the track (§3.4). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release, mixTracks)" />
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
@@ -41,7 +48,7 @@ else
TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" />
<div class="mix-detail-foreground">
<div class="mix-detail-foreground dd-detail-fill">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
@* Mix keeps the scaffold solely for the Phase 10 top row (back link | controls | lava-lamp).
Title/artist/genre/date/share/play all move into the overlaid hero, so the scaffold's
@@ -56,13 +63,26 @@ else
ShowMeta="false"
ShowShareRow="false">
<TopRightAction>
@* Lava-lamp icon → popover panel, top-right across from the back link (Phase 12
§3d-revised). Replaces the former inline TopContent knob-bar: the icon IS the toggle
and the popover IS the panel. Mix takes the cleanest anchor case (§8e) — the popover's
default bottom-right anchor opens down over the full-bleed field. *@
<WaveformVisualizerControlPopover />
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both stay
visible in Theater Mode — controls over the experience, not release content (§4/OQ4).
Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
<div class="dd-detail-top-actions">
@* Theater toggle only appears when this Mix is the currently-playing release
(Phase 20 Wave 2 §3). ShowTheaterToggle folds in the subsystem + release-playing gate. *@
<TheaterModeToggle Available="ShowTheaterToggle" />
@* Lava-lamp icon → popover panel, top-right across from the back link (Phase 12
§3d-revised). Replaces the former inline TopContent knob-bar: the icon IS the toggle
and the popover IS the panel. Mix takes the cleanest anchor case (§8e) — the popover's
default bottom-right anchor opens down over the full-bleed field. *@
<WaveformVisualizerControlPopover />
</div>
</TopRightAction>
<Hero>
@* Theater Mode (Phase 20 §4, Wave 2 §2): the hero overlay stays mounted and eases out via
a collapsing wrapper so it does not pop — collapsed to zero height when Theater is on AND
this Mix is the playing release. OFF eases the full-bleed visualizer back behind the hero. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The
cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
to null). Share and play ride in as slots, matching Sessions. *@
@@ -86,10 +106,17 @@ else
}
</PlayContent>
</ReleaseHeroOverlay>
</div>
</div>
</Hero>
<BodyContent>
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
<ReleaseDescription Description="@release.Description" />
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Hero region. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
<ReleaseDescription Description="@release.Description" />
</div>
</div>
</BodyContent>
</ReleaseDetailScaffold>
</MudContainer>
@@ -99,11 +126,14 @@ else
@code {
protected override string PersistKey => "mix-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// PlayerService is cascaded by ReleaseDetailBase (used there for the Theater release-playing predicate).
[CascadingParameter] public IQueueService? Queue { get; set; }
// The hero now carries the play affordance (the scaffold's header is suppressed), so the
// play-toggle is wired here directly — mirroring SessionDetail. Toggle if this track is already
// active, otherwise start a fresh stream.
// active, otherwise PLAY it: prepend to the queue's front (deque PLAY semantics) so it becomes
// current and the existing queue stays intact behind it. Falls back to a direct stream when the
// queue cascade is absent (prerender / non-interactive).
private async Task PlayTrack()
{
var track = ViewModel.Track;
@@ -114,6 +144,10 @@ else
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(track);
}
else
{
await PlayerService.SelectTrackStreaming(track);
+3 -1
View File
@@ -1,8 +1,10 @@
@page "/mixes"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
@inject SeoOptions Seo
<PageTitle>DeepDrft Mixes</PageTitle>
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Mix, "/mixes")" />
<ReleaseGallery Releases="@Releases"
Loading="@Loading"
@@ -1,4 +1,9 @@
@page "/404"
@using DeepDrftPublic.Client.Controls
@inject SeoOptions Seo
@* The 404 must not be indexed (AC8): noindex,follow — no canonical, no JSON-LD. *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<MudText Typo="Typo.h3">
Not Found
@@ -1,4 +1,5 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
@@ -17,7 +18,41 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
// Theater Mode (Phase 20). The page owns the content gate, so it must re-render when the flag flips
// on the toggle. Property-injected; no constructor growth.
[Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; }
// Theater Mode is scoped to the currently-playing release (Phase 20 Wave 2 §3). The page observes
// player state so the toggle availability and content gate re-evaluate live when playback starts,
// stops, or moves to a different release. Cascaded by AudioPlayerProvider; no constructor growth.
// The cascade is IsFixed, so the provider's own re-render does not reach this page — the page must
// subscribe to StateChanged to re-render itself (same posture as AudioPlayerBar).
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
private IStreamingPlayerService? _subscribedPlayer;
/// <summary>
/// True when the currently-playing track belongs to this page's release. Theater Mode only applies
/// to the playing release: a detail page whose release is not playing ignores the global flag and
/// shows no toggle. Identity is the release <c>EntryKey</c> — the canonical public key the routes
/// and <see cref="DeepDrftPublic.Client.Common.ReleaseRoutes"/> use.
/// </summary>
protected bool IsThisReleasePlaying =>
PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey;
/// <summary>
/// True when this page's release content should be hidden for Theater Mode — only when Theater is on
/// AND this release is the one playing. Drives the eased collapse of the hero/blurb regions.
/// </summary>
protected bool IsContentHidden => VisualizerControlState.TheaterMode && IsThisReleasePlaying;
/// <summary>
/// True when the Theater toggle should be offered on this page: a visualizer subsystem is on AND
/// this page's release is the one playing.
/// </summary>
protected bool ShowTheaterToggle =>
(VisualizerControlState.LavaEnabled || VisualizerControlState.WaveformEnabled) && IsThisReleasePlaying;
// The release EntryKey the ViewModel currently holds. Tracks param-only navigations (e.g.
// /mixes/{a} -> /mixes/{b}) which reuse this component instance and fire OnParametersSet
@@ -30,10 +65,29 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
protected abstract string PersistKey { get; }
protected override void OnInitialized()
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
VisualizerControlState.Changed += OnVisualizerStateChanged;
}
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
protected override async Task OnParametersSetAsync()
{
// The player cascade is IsFixed, so the provider's re-render does not reach this page; subscribe
// to the StateChanged side-channel to re-render when playback moves between releases. Idempotent
// (reference-guarded) and unsubscribed on dispose — same posture as AudioPlayerBar.
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedPlayer))
{
if (_subscribedPlayer is not null)
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedPlayer = PlayerService;
}
// Re-run whenever the route key changes. Component instances are reused across
// same-template navigations, so the load decision must live here, not in
// OnInitialized (which fires once per instance).
@@ -69,7 +123,16 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
public void Dispose()
{
_persistingSubscription.Dispose();
VisualizerControlState.Changed -= OnVisualizerStateChanged;
if (_subscribedPlayer is not null)
{
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
_subscribedPlayer = null;
}
}
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
protected sealed record BridgedDetail(ReleaseDto Release, TrackDto? Track);
@@ -3,8 +3,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -20,6 +19,9 @@
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
(mirrors the dedicated /404 NotFound page). *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Session not found.</MudText>
@@ -40,6 +42,10 @@ else
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
@* SEO head — fed from the same bridged release, so prerender and WASM render identical tags (AC6).
MusicAlbum/LiveAlbum (a session is a live release, §3.4/OQ6). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release)" />
@* Ambient living waveform behind the hero overlay (Phase 12 §3e option b / §3f mode B). Session does
NOT compose ReleaseDetailScaffold, so it mounts the shared engine directly with its own thin
full-bleed wrapper — the engine is single-source either way, only the mount differs (§3b). The
@@ -49,18 +55,30 @@ else
TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" />
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page session-detail-foreground">
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page session-detail-foreground dd-detail-fill">
<div class="session-detail-top-row">
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; All sessions
</MudLink>
@* Lava-lamp icon → popover panel (full parity, §3e/§3d-revised). Anchored top-right, clear of
the hero overlay and the share/play affordances overlaid on the hero below. *@
<WaveformVisualizerControlPopover />
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). The whole top
row (back + theater + lava) stays in Theater Mode — controls, not release content (§4/OQ4). *@
<div class="dd-detail-top-actions">
@* Theater toggle only appears when this Session is the currently-playing release
(Phase 20 Wave 2 §3). ShowTheaterToggle folds in the subsystem + release-playing gate. *@
<TheaterModeToggle Available="ShowTheaterToggle" />
@* Lava-lamp icon → popover panel (full parity, §3e/§3d-revised). Anchored top-right, clear of
the hero overlay and the share/play affordances overlaid on the hero below. *@
<WaveformVisualizerControlPopover />
</div>
</div>
@* Theater Mode (Phase 20 §4, Wave 2 §2): the hero overlay + blurb stay mounted and ease out via a
collapsing wrapper so they do not pop — collapsed to zero height when Theater is on AND this
Session is the playing release. The top row above stays. OFF eases this region back in. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* The overlay shows the cover thumbnail only when it differs from the resolved hero image —
when there is no dedicated hero, heroImage already falls back to release.ImagePath, so the
thumb would duplicate the background. That logic lives in ReleaseHeroOverlay. *@
@@ -86,6 +104,8 @@ else
</ReleaseHeroOverlay>
<ReleaseDescription Description="@release.Description" />
</div>
</div>
</MudContainer>
}
@@ -93,11 +113,14 @@ else
@code {
protected override string PersistKey => "session-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// PlayerService is cascaded by ReleaseDetailBase (used there for the Theater release-playing predicate).
[CascadingParameter] public IQueueService? Queue { get; set; }
// Mirrors the play-toggle wiring the shared scaffold owns. Session detail composes the player
// affordance directly (it diverges from ReleaseDetailScaffold for the overlay layout), so the
// toggle logic lives here: toggle if this track is already active, otherwise start a fresh stream.
// toggle logic lives here: toggle if this track is already active, otherwise PLAY it — prepend to
// the queue's front (deque PLAY semantics) so it becomes current and the existing queue stays
// intact behind it. Falls back to a direct stream when the queue cascade is absent.
private async Task PlayTrack()
{
var track = ViewModel.Track;
@@ -108,6 +131,10 @@ else
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(track);
}
else
{
await PlayerService.SelectTrackStreaming(track);
@@ -1,8 +1,10 @@
@page "/sessions"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
@inject SeoOptions Seo
<PageTitle>DeepDrft Sessions</PageTitle>
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Session, "/sessions")" />
<ReleaseGallery Releases="@Releases"
Loading="@Loading"
+75 -28
View File
@@ -10,18 +10,35 @@ namespace DeepDrftPublic.Client.Services;
/// — it adds no new playback semantics.
///
/// <para>
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/>
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> +
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only
/// adding new ones — so consumers written against today's surface keep working.
/// <b>Two-level deque model (the load-bearing invariant).</b> The queue is a deque whose
/// <see cref="Current"/> track (the item at <see cref="CurrentIndex"/>) is the live "front of play".
/// Two families of mutation enter the deque from opposite ends:
/// <list type="bullet">
/// <item><b>PLAY (manual)</b> — <see cref="PlayTrack"/> / <see cref="PlayRelease"/> prepend to the
/// <em>front</em>. The previously-current track is removed, the prepended track(s) become the head
/// in order, the new head becomes current and starts streaming, and whatever sat after the old
/// current stays intact behind the prepend. A whole release prepends in order in one operation.</item>
/// <item><b>Add-to-queue</b> — <see cref="Enqueue"/> / <see cref="EnqueueRange"/> append to the
/// <em>end</em>. They never interrupt the current track and never start playback.</item>
/// </list>
/// </para>
///
/// <para>
/// <b>Advance and end-of-track.</b> <see cref="Next"/> and auto-advance (the player's
/// <see cref="IPlayerService.TrackEnded"/>) walk <see cref="CurrentIndex"/> forward, leaving the just-
/// played track in the list behind the pointer so <see cref="Previous"/> can step back to it. The one
/// exception is the <em>last</em> track: when the current track ends naturally and there is nothing
/// after it, the queue <b>empties</b> and goes dormant (<see cref="CurrentIndex"/> == -1) rather than
/// stranding the finished track as current.
/// </para>
///
/// <para>
/// With an empty queue (<see cref="CurrentIndex"/> == -1) the queue is dormant: it drives nothing and
/// auto-advances nothing, so direct single-track play through the player behaves exactly as it did
/// before the queue existed.
/// before the queue existed. The <b>first</b> <see cref="Enqueue"/>/<see cref="EnqueueRange"/> into a
/// dormant queue while a track is already playing externally seeds the head from the player's current
/// track (learned through the attached player, no extra dependency) and then appends the added item, so
/// the resulting deque is <c>[now-playing, added…]</c> rather than a phantom single entry.
/// </para>
/// </summary>
public interface IQueueService
@@ -41,9 +58,10 @@ public interface IQueueService
/// <summary>
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet —
/// the embed's pre-gesture state. Set by <see cref="Arm"/>; cleared the moment playback actually
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="Next"/>/<see cref="Previous"/>)
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone.
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="PlayTrack"/>/<see cref="Next"/>/
/// <see cref="Previous"/>) or on <see cref="Clear"/>. The player bar reads this to route the first
/// play gesture through <see cref="Start"/> (which begins the armed release) rather than streaming
/// the staged track alone.
/// </summary>
bool IsArmed { get; }
@@ -55,26 +73,40 @@ public interface IQueueService
/// <summary>
/// Raised whenever the queue's contents or current position change. The player bar subscribes
/// to re-render its skip-forward/back affordances. Fires on enqueue, advance, step-back, and clear.
/// to re-render its skip-forward/back affordances. Fires on enqueue, prepend, advance, step-back,
/// and clear.
/// </summary>
event Action? QueueChanged;
/// <summary>
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to
/// the end from there. No-op when <paramref name="tracks"/> is empty.
/// Manual PLAY of a single track: prepends <paramref name="track"/> to the <em>front</em> of the
/// deque, removes the previously-current track, makes <paramref name="track"/> the new head/current,
/// and starts streaming it. The rest of the queue (everything that sat after the old current) stays
/// intact behind the new head. Into a dormant queue this simply becomes the sole head and plays.
/// This is the deque-front counterpart to the append-only <see cref="Enqueue"/>.
/// </summary>
Task PlayTrack(TrackDto track);
/// <summary>
/// Manual PLAY of a release: prepends <paramref name="tracks"/> (in the order given) to the
/// <em>front</em> of the deque, removes the previously-current track, and starts streaming the
/// prepended track at <paramref name="startIndex"/> — which becomes current. Tracks prepended
/// before <paramref name="startIndex"/> sit behind the pointer (reachable via <see cref="Previous"/>);
/// tracks after it are up-next; whatever sat after the old current stays intact behind the whole
/// prepend. This is the "play album" entry point the detail pages consume: a header Play uses
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index. No-op when
/// <paramref name="tracks"/> is empty.
/// </summary>
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
/// <summary>
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT
/// streaming anything — the queue is "armed". This is the embed's prerender-safe entry point: it
/// performs no JS interop, so it runs identically during prerender and after WASM boot. The first
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which
/// keeps the loaded release queued so it advances through its tracks. No-op when
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed).
/// performs no JS interop, so it runs identically during prerender and after WASM boot. It replaces
/// the queue (an armed embed is a fresh staged release, not a prepend). The first play gesture (see
/// <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which keeps the loaded
/// release queued so it advances through its tracks. No-op when <paramref name="tracks"/> is empty
/// (the queue stays empty and disarmed).
/// </summary>
void Arm(IEnumerable<TrackDto> tracks);
@@ -88,18 +120,24 @@ public interface IQueueService
Task Start();
/// <summary>
/// Appends a track to the end of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
/// Appends a track to the <em>end</em> of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) while a track is already playing
/// externally (through the attached player but not via the queue), the append first seeds the head
/// with that now-playing track, then appends <paramref name="track"/> — yielding
/// <c>[now-playing, track]</c> so the queue reflects what the listener actually hears. Into a fully
/// dormant queue with nothing playing, the single appended track becomes the head at
/// <see cref="CurrentIndex"/> == 0. Either way it does NOT begin playback (add is not play).
/// Interop-free; safe during prerender.
/// </summary>
void Enqueue(TrackDto track);
/// <summary>
/// Appends tracks to the end of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
/// Appends tracks to the <em>end</em> of the queue without changing what is currently playing.
/// Into a dormant queue while a track is already playing externally, the append first seeds the head
/// with that now-playing track (see <see cref="Enqueue"/>), then appends the range. Into a fully
/// dormant queue with nothing playing, the first appended track becomes the head at
/// <see cref="CurrentIndex"/> == 0. It does NOT begin playback (add is not play). Interop-free; safe
/// during prerender.
/// </summary>
void EnqueueRange(IEnumerable<TrackDto> tracks);
@@ -136,6 +174,15 @@ public interface IQueueService
/// </summary>
Task Previous();
/// <summary>
/// Moves the current pointer to <paramref name="index"/> and streams that track once. This is the
/// row-jump primitive the open playlist panel uses: unlike <see cref="PlayRelease"/> it does not
/// prepend (the track is already in the deque), and unlike repeated <see cref="Next"/> it does not
/// stream the intervening rows. No-op when <paramref name="index"/> is out of range or already
/// current.
/// </summary>
Task JumpTo(int index);
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
void Clear();
+100 -31
View File
@@ -3,10 +3,12 @@ using DeepDrftModels.DTOs;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Default <see cref="IQueueService"/>: a single-slot orchestrator over an
/// Default <see cref="IQueueService"/>: a two-level deque orchestrator over an
/// <see cref="IStreamingPlayerService"/>. Holds the ordered list and current index as pure state,
/// drives playback through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>,
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal. PLAY mutations enter
/// the front (prepend); add-to-queue mutations enter the back (append) — see <see cref="IQueueService"/>
/// for the full invariant.
///
/// <para>
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
@@ -14,7 +16,8 @@ namespace DeepDrftPublic.Client.Services;
/// creates the player) rather than constructor injection. This keeps the player single-slot, avoids a
/// construction cycle between provider/player/queue, and needs no <c>IServiceProvider</c>. The queue's
/// own constructor stays parameterless, so the queue logic is unit-testable against a fake player with
/// no container.
/// no container. The attached player is also the seam by which the queue learns the externally-playing
/// track when a dormant <see cref="Enqueue"/> needs to seed the head.
/// </para>
/// </summary>
public sealed class QueueService : IQueueService, IDisposable
@@ -54,23 +57,42 @@ public sealed class QueueService : IQueueService, IDisposable
_player.TrackEnded += OnTrackEnded;
}
public async Task PlayTrack(TrackDto track)
{
PrependForPlay(new[] { track }, prependIndex: 0);
QueueChanged?.Invoke();
await PlayCurrent();
}
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
{
var list = tracks.ToList();
if (list.Count == 0) return;
var start = Math.Clamp(startIndex, 0, list.Count - 1);
_items.Clear();
_items.AddRange(list);
CurrentIndex = start;
// Playback is now starting for real, so the queue is no longer merely armed.
IsArmed = false;
PrependForPlay(list, start);
QueueChanged?.Invoke();
await PlayCurrent();
}
// The shared PLAY-prepend mutation (bug #5). Removes the previously-current track, inserts the
// played track(s) at the front in order, and points CurrentIndex at the prepended item the caller
// chose to start on. Whatever sat AFTER the old current stays intact behind the prepend; the old
// back-history (items before the old current) is discarded because a fresh PLAY defines a new
// front. Pure state — callers invoke QueueChanged + PlayCurrent. IsArmed clears: playback is real now.
private void PrependForPlay(IReadOnlyList<TrackDto> played, int prependIndex)
{
// Drop the previously-current track only (its tail — the up-next after it — is preserved).
// Anything before the old current is back-history that a new PLAY supersedes.
if (CurrentIndex >= 0 && CurrentIndex < _items.Count)
_items.RemoveRange(0, CurrentIndex + 1);
_items.InsertRange(0, played);
CurrentIndex = prependIndex;
IsArmed = false;
}
public void Arm(IEnumerable<TrackDto> tracks)
{
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
@@ -94,27 +116,47 @@ public sealed class QueueService : IQueueService, IDisposable
public void Enqueue(TrackDto track)
{
SeedHeadFromPlayerIfDormant();
_items.Add(track);
// OQ8: appending into a dormant (empty) queue leaves a coherent CurrentIndex so the next
// play/skip is correct — but does NOT auto-play (add is not play). PlayCurrent is never
// called here, so this stays interop-free and prerender-safe.
if (CurrentIndex == -1)
CurrentIndex = 0;
EnsureCoherentDormantIndex();
QueueChanged?.Invoke();
}
public void EnqueueRange(IEnumerable<TrackDto> tracks)
{
var before = _items.Count;
_items.AddRange(tracks);
if (_items.Count == before) return;
// OQ8: see Enqueue — first append into a dormant queue stages a coherent CurrentIndex
// without playing. The first newly-appended track becomes current.
if (CurrentIndex == -1)
CurrentIndex = 0;
var toAdd = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
if (toAdd.Count == 0) return;
SeedHeadFromPlayerIfDormant();
_items.AddRange(toAdd);
EnsureCoherentDormantIndex();
QueueChanged?.Invoke();
}
// Bug #3: the first add into a dormant queue while a track is already playing externally (through
// the attached player but not via the queue) must seed the head with that now-playing track, so the
// append yields [now-playing, added] instead of a phantom single entry. We read the player's
// CurrentTrack — the same seam OnTrackEnded uses — so no extra dependency is introduced. Only seeds
// when truly dormant (empty list) AND a player track exists; a non-dormant queue is untouched.
private void SeedHeadFromPlayerIfDormant()
{
if (_items.Count != 0) return;
var playing = _player?.CurrentTrack;
if (playing is null) return;
_items.Add(playing);
CurrentIndex = 0;
}
// After an append, a dormant queue (CurrentIndex == -1, e.g. nothing was playing to seed from)
// needs a coherent head so a subsequent play/skip is correct — but add is not play, so we never
// stream here. A queue that already has a current index is left untouched.
private void EnsureCoherentDormantIndex()
{
if (CurrentIndex == -1 && _items.Count > 0)
CurrentIndex = 0;
}
public void Move(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return;
@@ -192,6 +234,16 @@ public sealed class QueueService : IQueueService, IDisposable
await PlayCurrent();
}
public async Task JumpTo(int index)
{
if (index < 0 || index >= _items.Count) return;
if (index == CurrentIndex) return;
CurrentIndex = index;
IsArmed = false;
QueueChanged?.Invoke();
await PlayCurrent();
}
public void Clear()
{
if (_items.Count == 0 && CurrentIndex == -1) return;
@@ -217,23 +269,40 @@ public sealed class QueueService : IQueueService, IDisposable
// Advance on organic end-of-stream only. TrackEnded is not raised by stop/unload/track-switch,
// so a manual stop or a fresh single-track selection elsewhere never spuriously advances the
// queue. When the queue is past its last track, end-of-stream simply stops — nothing to advance.
// queue.
//
// Guard: only advance when the track that just ended is the queue's own current item. Call sites
// that stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
// Guard: only act when the track that just ended is the queue's own current item. Call sites that
// stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
// overwrite the player's CurrentTrack without touching the queue. If their track reaches natural
// end, the player fires TrackEnded — but the queue's Current no longer matches the player's
// CurrentTrack, so we must not advance. Id-based equality is used rather than ReferenceEquals
// CurrentTrack, so we must not touch the queue. Id-based equality is used rather than ReferenceEquals
// because DTO copies through serialisation are not reference-equal.
//
// When the ended track IS the queue's current: advance if there is a next track, otherwise the queue
// has reached its end — empty it (bug #2), so the finished last track is not stranded as current and
// the queue goes dormant (panel/button gone per HasQueue gating).
private void OnTrackEnded()
{
if (!HasNext) return;
if (_player?.CurrentTrack?.Id != Current?.Id) return;
// Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's
// end-of-playback callback continuation; we must not block it. Advancing kicks off the next
// stream, whose own failures surface through the player's ErrorMessage/state — the queue does
// not own playback error handling.
_ = Next();
if (Current is null) return;
if (HasNext)
{
// Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's
// end-of-playback callback continuation; we must not block it. Advancing kicks off the next
// stream, whose own failures surface through the player's ErrorMessage/state — the queue does
// not own playback error handling.
_ = Next();
}
else
{
// Last track ended naturally → empty the deque. The player is left alone (its stream has
// already ended on its own); we only reset queue state.
_items.Clear();
CurrentIndex = -1;
IsArmed = false;
QueueChanged?.Invoke();
}
}
private async Task PlayCurrent()
@@ -97,6 +97,13 @@ public sealed class WaveformVisualizerControlState
/// </summary>
public const bool DefaultWaveformEnabled = true;
/// <summary>
/// Default Theater-mode state. <c>false</c> so a fresh page load opens with the full release page,
/// not the bare visualizer (Phase 20 §4/OQ5). Has no TS-side anchor: Theater Mode is a page-chrome
/// presentation flag, not a visualizer dial — the bridge never reads it.
/// </summary>
public const bool DefaultTheaterMode = false;
/// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
/// time-span via <see cref="WaveformZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
@@ -137,12 +144,35 @@ public sealed class WaveformVisualizerControlState
/// </summary>
public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled;
/// <summary>
/// Whether Theater Mode is on (Phase 20). When <c>true</c> the three release-detail pages remove
/// their release content via <c>@if</c> so the visualizer fills the surface, and the player bar
/// grows to carry the playing release's identity. Distinct from the visualizer dials: the bridge
/// ignores it — the pages and the player bar observe it through the same <see cref="Changed"/> seam.
/// Gated for visibility on <see cref="LavaEnabled"/> || <see cref="WaveformEnabled"/> at the toggle.
/// </summary>
public bool TheaterMode { get; set; } = DefaultTheaterMode;
/// <summary>
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
/// affected dial(s). Mutators set the property then raise this; subscribers re-read the values.
/// affected dial(s); the Theater-Mode observers (detail pages, player bar) subscribe to react to
/// <see cref="TheaterMode"/>. Mutators set the property then raise this; subscribers re-read the values.
/// </summary>
public event Action? Changed;
/// <summary>
/// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer
/// subsystems are off (there is nothing to go to theater FOR). Call this after mutating
/// <see cref="LavaEnabled"/> or <see cref="WaveformEnabled"/> and before
/// <see cref="NotifyChanged"/> so all observers see a consistent, coerced state in the same
/// <see cref="Changed"/> cycle.
/// </summary>
public void CoerceTheaterMode()
{
if (TheaterMode && !LavaEnabled && !WaveformEnabled)
TheaterMode = false;
}
/// <summary>Raise <see cref="Changed"/>. Called by the controls component after mutating a value.</summary>
public void NotifyChanged() => Changed?.Invoke();
}
+10
View File
@@ -45,6 +45,16 @@ public static class Startup
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
services.AddScoped<ShareTracker>();
// Phase 22 SEO defaults — non-secret brand constants (canonical origin, site name, default share
// image, social links). Singleton: stateless config, identical in the server-prerender and WASM
// passes (this method runs in both), which is what makes SeoHead's double-render output identical.
services.AddSingleton(new SeoOptions());
// Environment-gated robots bridge. Scoped + [PersistentState] like DarkModeSettings: the server
// seeds IsProduction during prerender and it rounds to the WASM pass, so SeoHead resolves the same
// default robots in both render passes (non-production → noindex,nofollow, keeping beta uncrawled).
services.AddScoped<SeoEnvironment>();
}
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
+8 -3
View File
@@ -6,12 +6,15 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## One-line purpose
The Blazor Web App host. Owns a browser-facing proxy controller for `api/track/*` (metadata and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop.
The Blazor Web App host. Owns a browser-facing proxy controller for `api/track/*` (metadata and audio streaming), crawl-directive endpoints (`/robots.txt` + `/sitemap.xml`), MudBlazor theme prerender, and TypeScript→JS audio interop.
## What lives here now (only)
- `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, port binding.
- `Controllers/TrackProxyController.cs`: Thin proxy controller at `[Route("api/track")]`. Two actions: `GET api/track/page` (proxies paged track metadata) and `GET api/track/{trackId}` (proxies audio streaming without buffering, forwards `offset` query param for seek-beyond-buffer). Uses `RegisterForDispose` for clean connection cleanup.
- `Controllers/CrawlDirectiveController.cs`: Second controller; serves `GET /robots.txt` and `GET /sitemap.xml`. Reads `IWebHostEnvironment.IsProduction()` **directly** (server-side only — no PersistentState bridge). Production robots.txt: `Allow: /` + `Disallow: /FramePlayer` + `Disallow: /api/` + `Sitemap:` pointer. Non-production robots.txt: `Disallow: /`. Production sitemap.xml: walks `GET api/release` via the `"DeepDrft.API"` named client, emits six static roots + one `<url>` per release (loc = `SeoOptions.BaseUrl` + `ReleaseRoutes.DetailHref`, lastmod from `ReleaseDate`); resilient (partial read → well-formed roots-only doc, never 500). Non-production: sitemap returns 404. Routes automatically via `MapControllers()`.
- `Seo/RobotsTxt.cs`: Pure builder for the robots.txt body (no HTTP, no DI — composition only).
- `Seo/SitemapXml.cs`: Pure builder for the sitemap XML body (no HTTP, no DI — composition only).
- `Services/DarkModeService.cs`: Server-side dark-mode prerender (reads `darkMode` cookie, seeds `DarkModeSettings.IsDarkMode` via `IHttpContextAccessor`, carries to WASM via `PersistentComponentState`).
- `Components/App.razor`: Root component with `@rendermode="InteractiveAuto"`. Calls `DarkModeService.InitializeAsync()` in `OnInitialized`.
- `Components/Pages/Error.razor`: Error fallback.
@@ -84,9 +87,11 @@ The middleware pipeline in `Program.cs` is ordered as follows:
8. Development-only `UseStaticFiles()` — serves raw TypeScript from `/Interop/` for source-map debugging.
9. `MapControllers()` and `MapRazorComponents()` — route controller and component requests.
## The proxy controller
## Controllers
`TrackProxyController` in `Controllers/` is the only HTTP controller. It is a thin proxy only — no domain logic, no data layer. The WASM client points both named HttpClients (`"DeepDrft.API"` and `"DeepDrft.Content"`) at the Blazor host's base address, so all browser requests route through this controller to DeepDrftAPI. Server-side SSR calls DeepDrftAPI directly (server-to-server) via the same named clients — no proxy hop on the server side.
`Controllers/` now holds two controllers. Both are thin boundaries — no domain logic, no data layer.
`TrackProxyController` is the audio/metadata proxy. The WASM client points both named HttpClients (`"DeepDrft.API"` and `"DeepDrft.Content"`) at the Blazor host's base address, so all browser requests route through this controller to DeepDrftAPI. Server-side SSR calls DeepDrftAPI directly (server-to-server) via the same named clients — no proxy hop on the server side.
The proxy forwards public, unauthenticated routes:
- `GET api/track/page` — paged metadata listing
+6
View File
@@ -1,4 +1,5 @@
@using DeepDrftPublic.Client
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Services
@using DeepDrftShared.Client.Components
<!DOCTYPE html>
@@ -34,11 +35,16 @@
@code {
[Inject] public required DarkModeService DarkModeService { get; set; }
[Inject] public required SeoEnvironment SeoEnvironment { get; set; }
[Inject] public required IWebHostEnvironment HostEnvironment { get; set; }
protected override void OnInitialized()
{
base.OnInitialized();
DarkModeService.CheckDarkMode();
// Seed the environment-gated robots bridge during prerender; [PersistentState] rounds it to WASM
// so both render passes resolve the same default robots (Production → index, else noindex).
SeoEnvironment.IsProduction = HostEnvironment.IsProduction();
}
}
@@ -0,0 +1,111 @@
using System.Net.Http.Json;
using System.Text.Json;
using DeepDrftModels.DTOs;
using Models.Common;
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Seo;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftPublic.Controllers;
/// <summary>
/// Serves the public crawl-directive surfaces (Phase 23): <c>GET /robots.txt</c> and
/// <c>GET /sitemap.xml</c>. Both are environment-gated server-side via
/// <see cref="IWebHostEnvironment.IsProduction"/> read directly here — not the WASM-only
/// <c>SeoEnvironment</c> bridge — and fail safe closed (non-production is uncrawlable, Invariant E1).
///
/// <para>
/// This is a thin host boundary: it owns the gate and the release walk, and delegates all body composition
/// to the pure <see cref="RobotsTxt"/> / <see cref="SitemapXml"/> builders. The sitemap walk reuses the
/// existing <c>"DeepDrft.API"</c> named client server-to-server (the same client SSR prerender uses) — it
/// <b>enumerates and transforms</b> releases into XML rather than relaying verbatim like the proxy controllers.
/// No new API endpoint, no schema change (Phase 22 C5 holds).
/// </para>
/// </summary>
[ApiController]
public class CrawlDirectiveController : ControllerBase
{
// 100 is the server-side PageSize cap, so this is the largest page the walk can actually get.
private const int WalkPageSize = 100;
// The release walk deserializes a bare PagedResult<ReleaseDto> (no ApiResultDto envelope), matching TrackClient.
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IWebHostEnvironment _environment;
private readonly SeoOptions _seoOptions;
private readonly HttpClient _upstream;
private readonly ILogger<CrawlDirectiveController> _logger;
public CrawlDirectiveController(
IWebHostEnvironment environment,
SeoOptions seoOptions,
IHttpClientFactory httpClientFactory,
ILogger<CrawlDirectiveController> logger)
{
_environment = environment;
_seoOptions = seoOptions;
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
_logger = logger;
}
/// <summary>
/// <c>GET /robots.txt</c>. Production: allow + FramePlayer/api disallows + sitemap pointer. Any
/// non-production environment: <c>Disallow: /</c> with no sitemap pointer (E1). Always <c>text/plain</c>.
/// </summary>
[HttpGet("/robots.txt")]
public ContentResult GetRobots()
{
var body = RobotsTxt.Build(_environment.IsProduction(), _seoOptions.BaseUrl);
return Content(body, "text/plain");
}
/// <summary>
/// <c>GET /sitemap.xml</c>. Non-production: 404 (the non-prod robots carries no sitemap pointer, so
/// nothing references it). Production: the static roots plus one entry per release. Resilient — a
/// partial/empty/failed release read yields a well-formed (possibly roots-only) document, never a 500.
/// </summary>
[HttpGet("/sitemap.xml")]
public async Task<ActionResult> GetSitemap(CancellationToken ct = default)
{
if (!_environment.IsProduction())
return NotFound();
var releases = await GatherReleasesAsync(ct);
var xml = SitemapXml.Build(_seoOptions.BaseUrl, releases);
return Content(xml, "application/xml");
}
// Walks GET api/release page by page until every release is read. On any upstream failure, returns the
// releases gathered so far (possibly none) so the sitemap degrades to a well-formed roots-only document
// rather than 500ing — a sitemap that errors trains crawlers to stop fetching it (AC-S5).
private async Task<IReadOnlyList<ReleaseDto>> GatherReleasesAsync(CancellationToken ct)
{
var gathered = new List<ReleaseDto>();
var page = 1;
try
{
while (true)
{
var result = await _upstream.GetFromJsonAsync<PagedResult<ReleaseDto>>(
$"api/release?page={page}&pageSize={WalkPageSize}", JsonOptions, ct);
if (result?.Items is null)
break;
gathered.AddRange(result.Items);
if (gathered.Count >= result.TotalCount || !result.Items.Any())
break;
page++;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Sitemap release walk failed after gathering {Count} release(s); serving a partial sitemap", gathered.Count);
}
return gathered;
}
}
+85 -10
View File
@@ -10,14 +10,81 @@
* read it. One observer at a time, re-pointed on each `observe` call; the var
* resets to 0 on `unobserve` (player minimized / disposed) so the spacer
* collapses.
*
* COALESCING (Phase 20 theater-flash fix). `--player-height` has two consumers:
* the layout spacer div AND the ambient WaveformVisualizer backdrop, whose
* `bottom` inset is this var (WaveformVisualizer.razor.css `.mix-waveform-bg`).
* Moving that inset changes the visualizer canvas's CSS box, which fires the
* renderer's own canvas ResizeObserver — and a GL resize CLEARS the backing
* store. That is correct and cheap for a discrete bar-height change (breakpoint
* reflow, minimize/expand, error banner). But Theater Mode eases the player bar's
* "now showing" band open/closed over ~0.45s via a CSS grid-rows transition, so
* the bar height changes EVERY FRAME of the ease. Mirroring each intermediate
* frame here would re-clear the GL backing store ~27×, reading as a flash.
*
* The fix coalesces the publish with a LEADING + TRAILING edge: the first change
* after a quiet period is written immediately (so a discrete jump — the common
* case — has zero added latency and the clip never lags), then a rapid STREAM of
* further changes (an animated transition) is debounced and only its SETTLED
* end-state is written. So a Theater ease resizes the visualizer at most twice
* (leading 1px move + final settle) instead of once per frame. The settled value
* is always the last write, so at-rest sizing/clip stays exact; and this remains
* the SOLE writer of `--player-height`, so the renderer's ResizeObserver stays the
* sole canvas size writer (its invariant is untouched).
*/
const HEIGHT_VAR = '--player-height';
let observer: ResizeObserver | null = null;
function writeHeight(px: number): void {
/**
* Quiet window (ms) after which a pending settled height is flushed. One change
* then silence (a discrete reflow) flushes after this delay but was ALSO written
* on the leading edge, so the trailing flush is a no-op — discrete jumps pay no
* latency. A continuous transition keeps resetting this timer until it ends, then
* flushes the final height once. ~80ms comfortably exceeds a frame interval (so a
* mid-ease frame never trips an early flush) yet settles promptly after the ease.
*/
const SETTLE_MS = 80;
let observer: ResizeObserver | null = null;
let lastWritten = -1;
let pendingHeight = -1;
let settleTimer: number | null = null;
function setVar(px: number): void {
// Round up so sub-pixel heights never leave a hairline of overlap.
document.documentElement.style.setProperty(HEIGHT_VAR, `${Math.ceil(px)}px`);
const rounded = Math.ceil(px);
if (rounded === lastWritten) return;
lastWritten = rounded;
document.documentElement.style.setProperty(HEIGHT_VAR, `${rounded}px`);
}
/**
* Publish a measured height with leading + trailing coalescing. Leading: if no
* settle is pending, this is the first change after a quiet period — write it now.
* Trailing: (re)arm the settle timer so the final value of a rapid stream lands
* once the stream stops.
*/
function publishHeight(px: number): void {
pendingHeight = px;
if (settleTimer === null) {
// Leading edge — discrete jumps land immediately; the first frame of a
// transition lands too (one resize), then the rest is debounced below.
setVar(px);
}
if (settleTimer !== null) {
clearTimeout(settleTimer);
}
settleTimer = window.setTimeout(() => {
settleTimer = null;
setVar(pendingHeight);
}, SETTLE_MS);
}
function measure(entry: ResizeObserverEntry): number {
// Prefer the border-box measurement; fall back to contentRect on the
// (older) engines that don't populate borderBoxSize.
const box = entry.borderBoxSize?.[0];
return box ? box.blockSize : entry.contentRect.height;
}
export function observe(element: Element): void {
@@ -27,20 +94,28 @@ export function observe(element: Element): void {
observer = new ResizeObserver(entries => {
const entry = entries[0];
if (!entry) return;
// Prefer the border-box measurement; fall back to contentRect on the
// (older) engines that don't populate borderBoxSize.
const box = entry.borderBoxSize?.[0];
writeHeight(box ? box.blockSize : entry.contentRect.height);
publishHeight(measure(entry));
});
observer.observe(element);
// Seed synchronously so the spacer is correct on this frame, before the
// first ResizeObserver callback fires.
writeHeight(element.getBoundingClientRect().height);
// first ResizeObserver callback fires. A fresh observe target is a discrete
// change, so write it straight through (bypassing the debounce) — re-pointing
// the observer (e.g. expanded <-> minimized) must not lag behind a settle.
if (settleTimer !== null) {
clearTimeout(settleTimer);
settleTimer = null;
}
setVar(element.getBoundingClientRect().height);
}
export function unobserve(): void {
observer?.disconnect();
observer = null;
writeHeight(0);
if (settleTimer !== null) {
clearTimeout(settleTimer);
settleTimer = null;
}
pendingHeight = -1;
setVar(0);
}
+34
View File
@@ -0,0 +1,34 @@
namespace DeepDrftPublic.Seo;
/// <summary>
/// Pure composition of the <c>robots.txt</c> body (Phase 23 wave 23.1). The environment gate is the
/// caller's: the endpoint reads <see cref="Microsoft.AspNetCore.Hosting.IWebHostEnvironment.IsProduction"/>
/// server-side and passes the boolean here, so the production-vs-beta branch lives in one testable place.
/// Fail-safe is closed — anything that is not Production yields <c>Disallow: /</c> (Invariant E1).
/// </summary>
public static class RobotsTxt
{
/// <summary>
/// Builds the directive body. In Production: allow everything except the embed shell and the proxy API
/// paths, plus a <c>Sitemap:</c> pointer (OQ-R2). In any non-production environment: a closed door
/// (<c>Disallow: /</c>) with no sitemap pointer, so a crawl of beta sees nothing and the sitemap is
/// never advertised.
/// </summary>
/// <param name="isProduction">The server-side <c>IsProduction()</c> result — the single gate.</param>
/// <param name="baseUrl">Canonical origin (no trailing slash) for the <c>Sitemap:</c> line; Production only.</param>
public static string Build(bool isProduction, string baseUrl)
{
if (!isProduction)
{
return "User-agent: *\n" +
"Disallow: /\n";
}
var origin = baseUrl.TrimEnd('/');
return "User-agent: *\n" +
"Allow: /\n" +
"Disallow: /FramePlayer\n" +
"Disallow: /api/\n" +
$"Sitemap: {origin}/sitemap.xml\n";
}
}
+68
View File
@@ -0,0 +1,68 @@
using System.Text;
using System.Xml;
using System.Xml.Linq;
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Common;
namespace DeepDrftPublic.Seo;
/// <summary>
/// Pure composition of the sitemaps.org <c>urlset</c> document (Phase 23 wave 23.2). Enumerates the fixed
/// indexable roots plus one entry per release, every <c>&lt;loc&gt;</c> absolutized against
/// <see cref="SeoOptions.BaseUrl"/> and per-release paths resolved through
/// <see cref="ReleaseRoutes.DetailHref(string, DeepDrftModels.Enums.ReleaseMedium)"/> — so each sitemap URL
/// equals the page's <c>SeoHead</c> canonical by construction. No fetch, no env logic: the endpoint owns the
/// gate and the release walk; this turns the gathered DTOs into XML and never throws on partial input.
/// </summary>
public static class SitemapXml
{
private static readonly XNamespace Ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
/// <summary>
/// The indexable static roots (OQ-S3). An explicit list, deliberately NOT derived from the nav index:
/// the indexable set is not the nav set (e.g. <c>/FramePlayer</c> is nav-absent and must stay out, and a
/// new nav entry is not automatically sitemap-worthy). Revisit here if the indexable-roots set grows.
/// </summary>
public static readonly IReadOnlyList<string> StaticRoots = ["/", "/about", "/cuts", "/sessions", "/mixes", "/archive"];
/// <summary>
/// Builds the full <c>urlset</c>: the static roots (no <c>lastmod</c>) followed by one <c>&lt;url&gt;</c>
/// per release. A release carries a <c>&lt;lastmod&gt;</c> sourced from <see cref="ReleaseDto.ReleaseDate"/>
/// in W3C <c>YYYY-MM-DD</c> form when present (OQ-S2 — the release date, accepted as a plausible crawl hint).
/// A null/empty release set yields a well-formed roots-only document.
/// </summary>
/// <param name="baseUrl">Canonical origin (no trailing slash) every <c>&lt;loc&gt;</c> is built from.</param>
/// <param name="releases">The gathered releases; may be empty or partial after an upstream failure.</param>
public static string Build(string baseUrl, IEnumerable<ReleaseDto> releases)
{
var origin = baseUrl.TrimEnd('/');
var roots = StaticRoots.Select(path => UrlElement(origin + path, lastmod: null));
var releaseUrls = releases.Select(release => UrlElement(
origin + ReleaseRoutes.DetailHref(release.EntryKey, release.Medium),
release.ReleaseDate?.ToString("yyyy-MM-dd")));
var urlset = new XElement(Ns + "urlset", roots.Concat(releaseUrls));
var document = new XDocument(new XDeclaration("1.0", "UTF-8", null), urlset);
// Save through a byte-based UTF-8 stream so the XML declaration reads encoding="utf-8". An
// XmlWriter over a StringBuilder/StringWriter is character-based (UTF-16) and would stamp the
// declaration utf-16, which is wrong for a body served as application/xml.
using var stream = new MemoryStream();
var settings = new XmlWriterSettings { Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), Indent = true };
using (var xmlWriter = XmlWriter.Create(stream, settings))
{
document.Save(xmlWriter);
}
return Encoding.UTF8.GetString(stream.ToArray());
}
private static XElement UrlElement(string loc, string? lastmod)
{
var element = new XElement(Ns + "url", new XElement(Ns + "loc", loc));
if (lastmod is not null)
element.Add(new XElement(Ns + "lastmod", lastmod));
return element;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

@@ -351,6 +351,75 @@ h2, h3, h4, h5, h6,
gap: 0.5rem;
}
/* Theater toggle + lava-lamp popover cluster on the detail-page top action row (Phase 20 §3). Keeps
the two icon affordances adjacent on the right edge rather than letting the SpaceBetween row spread
them apart. Shared by Cut/Mix (scaffold TopRightAction) and Session (its own top row). */
.dd-detail-top-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Full-screen detail body (Phase 20 Wave 2 §1). The content body always fills the viewport below the
fixed nav so the ambient/full-bleed visualizer reads as genuinely full-screen and the footer is pushed
below the fold (scroll to reach it) — independent of Theater Mode. Reuses the shared
--deepdrft-nav-height token (88px desktop / 72px mobile) so the clearance tracks the bar across
breakpoints; no new layout token. Applied to each detail page's foreground content container. */
.dd-detail-fill {
min-height: calc(100vh - var(--deepdrft-nav-height, 88px));
}
/* Eased content collapse for Theater Mode (Phase 20 Wave 2 §2). The detail content stays mounted and
collapses smoothly when .dd-theater-collapsed is applied, so toggling Theater eases both directions
instead of popping — when collapsed the content is fully out of the way and the visualizer is
unobstructed. The same pattern drives the player-bar "now showing" band so the bar grows/shrinks
smoothly too.
Technique: grid-template-rows 1fr → 0fr interpolates the REAL content height (no 400vh ceiling
artifact / delayed-start that the old max-height approach had). The direct child receives
overflow:hidden + min-height:0 so it actually clips during the transition (the grid child is the
collapsing unit). visibility:hidden removes all descendants from the tab order and from pointer/
keyboard interaction once collapsed — this fixes the Major accessibility defect where Tab could
reach hidden controls. transition-behavior:allow-discrete makes visibility flip discretely: it
flips to hidden AFTER the ease-out finishes (so the animation plays fully), and flips back to
visible BEFORE the ease-in starts (so content is immediately interactive on the way back in).
The visibility transition duration matches the height ease (0.45s) so allow-discrete has a real
interval to defer against: on collapse the flip to hidden is held until t=0.45s; on reopen it
fires at t=0 (immediately interactive). A 0s duration would fire the flip at t≈0 on collapse,
defeating the deferral and hiding content before the ease-out finishes. */
.dd-theater-collapsible {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
transition: grid-template-rows 0.45s ease, opacity 0.3s ease, visibility 0.45s;
transition-behavior: allow-discrete;
}
/* The single direct child clips itself during the grid-row collapse. min-height:0 overrides the
implicit min-height:auto that would prevent the row from shrinking past the content's intrinsic
height. overflow:hidden clips painted content when the row is partially collapsed. */
.dd-theater-collapsible > * {
overflow: hidden;
min-height: 0;
}
.dd-theater-collapsed {
grid-template-rows: 0fr;
opacity: 0;
/* visibility flips to hidden at the END of the 0.45s ease-out (deferred by allow-discrete);
on reopen it flips back to visible at t=0 so content is immediately interactive. */
visibility: hidden;
}
/* Honor reduced-motion: collapse still happens (it is layout, not decoration) but instantly, matching
the parallax precedent (transition-duration: 0). */
@media (prefers-reduced-motion: reduce) {
.dd-theater-collapsible {
transition-duration: 0ms;
}
}
.deepdrft-track-detail-meta {
display: flex;
flex-direction: row;
@@ -440,7 +509,7 @@ h2, h3, h4, h5, h6,
flex-direction: column;
gap: 0.75rem;
min-height: 0;
max-width: 420px;
max-width: 480px;
/* Pin the MudBlazor palette vars the portaled RadialKnob + slider consume. */
--mud-palette-primary: var(--deepdrft-green-accent); /* knob arc/pointer + slider track/thumb (interactive) */
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */
@@ -0,0 +1,15 @@
/**
* theme - body-class helpers for dark-mode theme toggling.
*
* Single Responsibility: apply or remove the deepdrft-theme-dark class on
* document.body so that portaled MudBlazor elements (popovers, menus, selects)
* inherit --deepdrft-popover-surface from body.deepdrft-theme-dark rather than
* from :root only. Popovers portal outside the ThemeWrapperClass div, so only
* a body-level class can reach them.
*/
/** Toggle the deepdrft-theme-dark class on document.body.
* @param isDark true to add the class, false to remove it. */
export function setBodyThemeClass(isDark) {
document.body.classList.toggle('deepdrft-theme-dark', isDark);
}
//# sourceMappingURL=/js/theme/theme.js.map
@@ -0,0 +1 @@
{"version":3,"file":"theme.js","sourceRoot":"/Interop/","sources":["theme/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;gEACgE;AAChE,MAAM,UAAU,iBAAiB,CAAC,MAAe;IAC7C,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;AAClE,CAAC"}
+3
View File
@@ -40,6 +40,9 @@
The queue is pure domain logic, unit-testable against a fake IStreamingPlayerService
with no browser/JS. -->
<ProjectReference Include="..\DeepDrftPublic.Client\DeepDrftPublic.Client.csproj" />
<!-- Referenced for the Phase 23 crawl-directive builders (RobotsTxt / SitemapXml) — pure
string/XML composition over the env flag and release DTOs, unit-testable without HTTP. -->
<ProjectReference Include="..\DeepDrftPublic\DeepDrftPublic.csproj" />
</ItemGroup>
</Project>
+285 -27
View File
@@ -5,11 +5,12 @@ using Microsoft.AspNetCore.Components;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the play-queue orchestrator (<see cref="QueueService"/>). The queue is pure
/// domain logic over the single-slot player, so it is exercised here against a recording fake
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage:
/// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and
/// auto-advance on the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// Unit tests for the two-level deque play-queue orchestrator (<see cref="QueueService"/>). The queue
/// is pure domain logic over the single-slot player, so it is exercised here against a recording fake
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage: PLAY-
/// prepend (single + release), add-to-queue append, dormant-seed-from-player, ordered advance,
/// next/previous bounds, jump, clear, current-index integrity, and auto-advance / last-track-empty on
/// the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// </summary>
[TestFixture]
public class QueueServiceTests
@@ -125,8 +126,12 @@ public class QueueServiceTests
}
[Test]
public async Task PlayRelease_ReplacesAnExistingQueue()
public async Task PlayRelease_PrependsToFront_RemovesPreviousCurrent_KeepsRemainderIntact()
{
// Deque PLAY (bug #5): PlayRelease into a non-empty queue prepends the release at the front,
// removes the previously-current track, and leaves the up-next that sat after it intact behind
// the prepend. Current was track-1 (index 0) → after prepend, the old current is dropped and
// its tail (track-2, track-3) stays behind [x-1, x-2].
await _queue.PlayRelease(Tracks(3));
var second = new List<TrackDto>
{
@@ -138,39 +143,133 @@ public class QueueServiceTests
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Has.Count.EqualTo(2));
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" }));
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "x-1", "x-2", "track-2", "track-3" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("x-1"));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("x-1"));
});
}
[Test]
public async Task PlayRelease_ViaLiveQueueItems_PreservesTracksAndJumpsToIndex()
public async Task PlayRelease_FromMidQueueCurrent_DropsOnlyTheCurrentTrack_NotItsTail()
{
// Regression guard for the aliasing bug: OnQueueJump calls PlayRelease(QueueService.Items, index).
// Items returns the backing list directly; without a defensive copy, the cast
// "tracks as IReadOnlyList<TrackDto>" aliases _items, so _items.Clear() also clears list,
// and _items.AddRange(list) adds nothing — wiping the queue and playing nothing.
await _queue.PlayRelease(Tracks(4)); // populate the live queue
// Current advanced to track-2 (index 1) with track-3, track-4 after it. PLAY of a new release
// drops only track-2 (the current) and keeps track-3, track-4 behind the prepend. The old
// back-history (track-1, before the current) is discarded — a fresh PLAY defines a new front.
await _queue.PlayRelease(Tracks(4));
await _queue.Next(); // current = track-2 at index 1
// Jump to index 2 via the live Items reference, exactly as OnQueueJump does.
await _queue.PlayRelease(new List<TrackDto> { new() { EntryKey = "p-1", TrackName = "P1" } });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "p-1", "track-3", "track-4" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("p-1"));
});
}
[Test]
public async Task PlayRelease_WithStartIndex_PrependsWholeReleaseInOrder_CurrentAtStartIndex()
{
// A mid-album row play prepends the whole release in order; the chosen startIndex becomes
// current. Tracks before it sit behind the pointer (Previous reaches them); tracks after are
// up-next. The previous current is dropped.
await _queue.PlayRelease(Tracks(2)); // existing queue: [track-1*, track-2]
var release = new List<TrackDto>
{
new() { EntryKey = "r-1", TrackName = "R1" },
new() { EntryKey = "r-2", TrackName = "R2" },
new() { EntryKey = "r-3", TrackName = "R3" },
};
await _queue.PlayRelease(release, startIndex: 1);
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "r-1", "r-2", "r-3", "track-2" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("r-2"));
Assert.That(_queue.HasPrevious, Is.True); // r-1 is behind the pointer
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("r-2"));
});
}
[Test]
public async Task PlayRelease_ViaLiveQueueItems_DoesNotCorruptListUnderPrepend()
{
// Aliasing guard retained under the deque model: a caller that passes the live Items reference
// into PlayRelease must not corrupt the list. PlayRelease materializes tracks.ToList() before
// the RemoveRange/InsertRange prepend, so the defensive copy survives the mutation.
await _queue.PlayRelease(Tracks(4));
// Pass the live Items reference (current = track-1). Prepend drops the current and re-inserts
// the copy at the front, with the tail (track-2..4) preserved behind it.
await _queue.PlayRelease(_queue.Items, 2);
Assert.Multiple(() =>
{
// The queue must survive — all four tracks still present, in order.
Assert.That(_queue.Items, Has.Count.EqualTo(4));
// The defensive copy is intact: all four original tracks were re-prepended in order, and the
// old current's tail follows. CurrentIndex is the chosen start.
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
// CurrentIndex must be the jumped-to slot.
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4", "track-2", "track-3", "track-4" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
// Current must be the right track.
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
// The player must have streamed the jumped-to track.
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
});
}
// --- PlayTrack: deque PLAY of a single track (prepend to front) — bug #5 ---
[Test]
public async Task PlayTrack_IntoDormantQueue_BecomesSoleHeadAndStreams()
{
await _queue.PlayTrack(new TrackDto { EntryKey = "solo", TrackName = "Solo" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "solo" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("solo"));
});
}
[Test]
public async Task PlayTrack_FromNonEmptyQueue_PrependsDropsPreviousCurrent_KeepsRemainder()
{
// bug #5: PLAY of a single track from a non-empty queue prepends it as the new head, drops the
// previously-current track, and leaves the remainder intact behind the new head.
await _queue.PlayRelease(Tracks(3)); // [track-1*, track-2, track-3]
await _queue.PlayTrack(new TrackDto { EntryKey = "jump-in", TrackName = "Jump In" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "jump-in", "track-2", "track-3" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("jump-in"));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("jump-in"));
});
}
[Test]
public async Task PlayTrack_DisarmsAnArmedQueue()
{
_queue.Arm(Tracks(3));
await _queue.PlayTrack(new TrackDto { EntryKey = "override", TrackName = "Override" });
Assert.Multiple(() =>
{
Assert.That(_queue.IsArmed, Is.False);
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("override"));
});
}
// --- Arm: prerender-safe load without streaming (release embed) ---
[Test]
@@ -671,6 +770,81 @@ public class QueueServiceTests
});
}
[Test]
public void Enqueue_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
{
// Bug #3: a single track is playing NOT through the queue (the player's CurrentTrack is set, the
// queue is dormant). The first Add-to-queue must seed the head with that now-playing track and
// then append the added one → [now-playing, added], even if they are the same track.
var nowPlaying = new TrackDto { Id = 7, EntryKey = "now-playing", TrackName = "Now Playing" };
_player.SimulateDirectPlay(nowPlaying);
_queue.Enqueue(new TrackDto { EntryKey = "added", TrackName = "Added" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "now-playing", "added" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "the now-playing track is the head/current");
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("now-playing"));
Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed");
});
// A second add appends a third item — no ghost/duplicate seeding.
_queue.Enqueue(new TrackDto { EntryKey = "added-2", TrackName = "Added 2" });
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "now-playing", "added", "added-2" }));
}
[Test]
public void Enqueue_OfTheSameExternallyPlayingTrack_SeedsHeadThenAppendsTheDuplicate()
{
// Bug #3 exact repro: add the very track that is playing externally. Result must be a 2-item
// queue [now-playing(current), same-track-appended] — not a single ghost entry.
var nowPlaying = new TrackDto { Id = 7, EntryKey = "the-track", TrackName = "The Track" };
_player.SimulateDirectPlay(nowPlaying);
_queue.Enqueue(nowPlaying);
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Has.Count.EqualTo(2));
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "the-track", "the-track" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
});
}
[Test]
public void EnqueueRange_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
{
var nowPlaying = new TrackDto { Id = 9, EntryKey = "live", TrackName = "Live" };
_player.SimulateDirectPlay(nowPlaying);
_queue.EnqueueRange(Tracks(2));
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "live", "track-1", "track-2" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_player.SelectedTracks, Is.Empty);
});
}
[Test]
public void Enqueue_IntoDormantQueue_WithNothingPlaying_DoesNotSeedAPhantomHead()
{
// No external track playing → nothing to seed. The single added track is the head (OQ8 coherent
// index), and there is no phantom duplicate.
_queue.Enqueue(new TrackDto { EntryKey = "only", TrackName = "Only" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "only" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
});
}
[Test]
public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex()
{
@@ -686,6 +860,67 @@ public class QueueServiceTests
});
}
// --- JumpTo: row-jump within the deque (move pointer + stream once) ---
[Test]
public async Task JumpTo_MovesPointerForwardAndStreamsTheTargetOnce()
{
await _queue.PlayRelease(Tracks(4)); // current = track-1
var streamedBefore = _player.SelectedTracks.Count;
await _queue.JumpTo(2);
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
// Exactly one new stream — the intervening track-2 must NOT have been streamed.
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore + 1));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
});
}
[Test]
public async Task JumpTo_MovesPointerBackwardAndStreamsTheTarget()
{
await _queue.PlayRelease(Tracks(4), startIndex: 3); // current = track-4
await _queue.JumpTo(1);
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2"));
});
}
[Test]
public async Task JumpTo_DoesNotDuplicateTheQueue()
{
// Regression guard: JumpTo must NOT prepend (it is not a PLAY) — the deque length is unchanged.
await _queue.PlayRelease(Tracks(4));
await _queue.JumpTo(2);
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
}
[Test]
public async Task JumpTo_SameIndexOrOutOfRange_IsNoOp()
{
await _queue.PlayRelease(Tracks(3)); // current = track-1
var streamedBefore = _player.SelectedTracks.Count;
await _queue.JumpTo(0); // already current
await _queue.JumpTo(-1);
await _queue.JumpTo(3);
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
"no-op jumps must not re-stream");
}
// --- Clear ---
[Test]
@@ -820,16 +1055,38 @@ public class QueueServiceTests
}
[Test]
public async Task TrackEnded_OnLastTrack_DoesNotAdvanceOrReplay()
public async Task TrackEnded_OnLastTrack_EmptiesTheQueueAndGoesDormant()
{
// Bug #2: when the current track ends naturally and there is nothing after it, the queue empties
// (CurrentIndex == -1, dormant) rather than stranding the finished track as current. No replay.
await _queue.PlayRelease(Tracks(2), startIndex: 1);
var raised = false;
_queue.QueueChanged += () => raised = true;
_player.RaiseTrackEnded();
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
Assert.That(_queue.Current, Is.Null);
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "no replay on end");
Assert.That(raised, Is.True, "emptying the queue raises QueueChanged");
});
}
[Test]
public async Task TrackEnded_OnSingleTrackQueue_EmptiesTheQueue()
{
// Bug #2, single-track variant: a one-item queue playing to its end empties (dormant).
await _queue.PlayRelease(Tracks(1));
_player.RaiseTrackEnded();
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
});
}
@@ -877,17 +1134,18 @@ public class QueueServiceTests
}
[Test]
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd()
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd_ThenEmptiesOnLastEnd()
{
await _queue.PlayRelease(Tracks(3));
_player.RaiseTrackEnded(); // → track-2
_player.RaiseTrackEnded(); // → track-3
_player.RaiseTrackEnded(); // last track: no advance
_player.RaiseTrackEnded(); // last track ends → queue empties (bug #2)
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
});
+62
View File
@@ -0,0 +1,62 @@
using DeepDrftPublic.Seo;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for <see cref="RobotsTxt"/> — the pure environment-branch composition of the robots.txt body
/// (Phase 23 wave 23.1). The gate (Production vs. anything-else) is the load-bearing branch: Production
/// allows + points at the sitemap and disallows the non-page routes; every non-production environment is a
/// closed door with no sitemap pointer (Invariant E1).
/// </summary>
[TestFixture]
public class RobotsTxtTests
{
private const string BaseUrl = "https://deepdrft.com";
[Test]
public void Build_Production_AllowsAndPointsAtSitemap()
{
var body = RobotsTxt.Build(isProduction: true, BaseUrl);
Assert.Multiple(() =>
{
Assert.That(body, Does.Contain("User-agent: *"));
Assert.That(body, Does.Contain("Allow: /"));
Assert.That(body, Does.Contain("Sitemap: https://deepdrft.com/sitemap.xml"));
});
}
[Test]
public void Build_Production_DisallowsFramePlayerAndApi()
{
var body = RobotsTxt.Build(isProduction: true, BaseUrl);
Assert.Multiple(() =>
{
Assert.That(body, Does.Contain("Disallow: /FramePlayer"));
Assert.That(body, Does.Contain("Disallow: /api/"));
});
}
[Test]
public void Build_NonProduction_DisallowsEverythingWithNoSitemapPointer()
{
var body = RobotsTxt.Build(isProduction: false, BaseUrl);
Assert.Multiple(() =>
{
Assert.That(body, Does.Contain("User-agent: *"));
Assert.That(body, Does.Contain("Disallow: /"));
Assert.That(body, Does.Not.Contain("Allow:"));
Assert.That(body, Does.Not.Contain("Sitemap:"));
});
}
[Test]
public void Build_Production_TrimsTrailingSlashOnBaseUrl()
{
var body = RobotsTxt.Build(isProduction: true, "https://deepdrft.com/");
Assert.That(body, Does.Contain("Sitemap: https://deepdrft.com/sitemap.xml"));
}
}
+423
View File
@@ -0,0 +1,423 @@
using System.Text.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Phase 22 SEO typed builders (<see cref="SeoModel"/> factories + <see cref="SeoJsonLd"/>
/// nodes + <see cref="SeoUrls"/>). These are pure functions over the DTOs a page already holds — the
/// medium→schema mapping (AC3), graceful partial data (AC4), JSON-LD validity (AC5), and canonical
/// correctness (AC7) are all testable here without rendering. The JSON-LD is parsed back to a document so
/// each assertion checks real structure, not a substring.
/// </summary>
[TestFixture]
public class SeoModelTests
{
private static readonly SeoOptions Options = new()
{
BaseUrl = "https://deepdrft.com",
SiteName = "Deep DRFT",
TitleSuffix = "Deep DRFT",
DefaultDescription = "default description",
DefaultImageUrl = "/img/og-default.png",
Genre = "Electronic",
SameAs = ["https://instagram.com/deepdrft.music"],
};
private static ReleaseDto Release(
ReleaseMedium medium,
string? image = "cover.jpg",
string? description = "desc",
string title = "Test Release",
string artist = "Aphex Twin") => new()
{
EntryKey = "abc-key",
Title = title,
Artist = artist,
Genre = "House",
Description = description,
ImagePath = image,
ReleaseDate = new DateOnly(2026, 3, 14),
Medium = medium,
};
private static JsonElement Parse(string? json)
{
Assert.That(json, Is.Not.Null.And.Not.Empty, "expected a JSON-LD body");
return JsonDocument.Parse(json!).RootElement;
}
// --- AC3: per-medium schema -------------------------------------------------
[Test]
public void ForRelease_Cut_IsMusicAlbum_StudioAlbum_WithOrderedTrackList()
{
var tracks = new List<TrackDto>
{
new() { TrackName = "Second", TrackNumber = 2, DurationSeconds = 60 },
new() { TrackName = "First", TrackNumber = 1, DurationSeconds = 30 },
};
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/StudioAlbum"));
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));
var trackArray = node.GetProperty("track");
Assert.That(trackArray.GetArrayLength(), Is.EqualTo(2));
// Ordered by TrackNumber, not input order.
Assert.That(trackArray[0].GetProperty("name").GetString(), Is.EqualTo("First"));
Assert.That(trackArray[1].GetProperty("name").GetString(), Is.EqualTo("Second"));
Assert.That(trackArray[0].GetProperty("duration").GetString(), Is.EqualTo("PT30S"));
});
}
[Test]
public void ForRelease_Session_IsMusicAlbum_LiveAlbum()
{
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Session));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/LiveAlbum"));
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));
});
}
[Test]
public void ForRelease_Mix_IsMusicRecording_WithIsoDuration()
{
var tracks = new List<TrackDto> { new() { TrackName = "The Mix", DurationSeconds = 3723 } }; // 1h 2m 3s
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"));
Assert.That(node.GetProperty("duration").GetString(), Is.EqualTo("PT1H2M3S"));
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicSong));
Assert.That(model.DurationSeconds, Is.EqualTo(3723));
// A mix is one recording, not an album — it carries no track list.
Assert.That(node.TryGetProperty("track", out _), Is.False);
});
}
[Test]
public void ForRelease_AllNodes_DeclareSchemaOrgContext()
{
foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
var node = Parse(SeoModel.ForRelease(Options, Release(medium)).JsonLd);
Assert.That(node.GetProperty("@context").GetString(), Is.EqualTo("https://schema.org"),
$"{medium} node must declare the schema.org context");
}
}
// --- AC4: graceful partial data ---------------------------------------------
[Test]
public void ForRelease_NoDescription_FallsBackToDefault()
{
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, description: null));
Assert.That(model.Description, Is.EqualTo(Options.DefaultDescription));
}
[Test]
public void ForRelease_NoCover_OmitsImagePath_SoHeadFallsBackToDefault()
{
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, image: null));
// The model carries no relative ImagePath; the JSON-LD image is absolutised to the default.
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(model.ImagePath, Is.Null);
Assert.That(node.GetProperty("image").GetString(), Is.EqualTo("https://deepdrft.com/img/og-default.png"));
});
}
[Test]
public void ForRelease_NoGenre_OmitsGenreProperty_NoEmptyValue()
{
var release = Release(ReleaseMedium.Cut);
release.Genre = null;
var node = Parse(SeoModel.ForRelease(Options, release).JsonLd);
Assert.That(node.TryGetProperty("genre", out _), Is.False, "a null genre must be omitted, not emitted empty");
}
[Test]
public void ForRelease_Mix_NoTrack_OmitsDuration()
{
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks: null).JsonLd);
Assert.That(node.TryGetProperty("duration", out _), Is.False, "a mix with no track must omit duration");
}
// --- AC7: canonical correctness ---------------------------------------------
[TestCase(ReleaseMedium.Cut, "https://deepdrft.com/cuts/abc-key")]
[TestCase(ReleaseMedium.Session, "https://deepdrft.com/sessions/abc-key")]
[TestCase(ReleaseMedium.Mix, "https://deepdrft.com/mixes/abc-key")]
public void ForRelease_CanonicalPath_IsDedicatedRoute_AndJsonLdUrlAgrees(ReleaseMedium medium, string expectedAbsolute)
{
var model = SeoModel.ForRelease(Options, Release(medium));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(SeoUrls.Absolute(Options, model.CanonicalPath!), Is.EqualTo(expectedAbsolute));
// The JSON-LD url must be the same absolute canonical (AC7: canonical == og:url == node url).
Assert.That(node.GetProperty("url").GetString(), Is.EqualTo(expectedAbsolute));
});
}
// --- Home / About / Browse / NotFound ---------------------------------------
[Test]
public void ForHome_IsMusicGroup_WithSameAs()
{
var model = SeoModel.ForHome(Options);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Deep DRFT"));
Assert.That(node.GetProperty("sameAs")[0].GetString(), Is.EqualTo("https://instagram.com/deepdrft.music"));
Assert.That(model.CanonicalPath, Is.EqualTo("/"));
});
}
[Test]
public void ForAbout_IsMusicGroup()
{
var node = Parse(SeoModel.ForAbout(Options).JsonLd);
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
}
[TestCase(ReleaseMedium.Cut, "/cuts")]
[TestCase(ReleaseMedium.Session, "/sessions")]
[TestCase(ReleaseMedium.Mix, "/mixes")]
public void ForBrowse_IsCollectionPage_WithAbsoluteUrl(ReleaseMedium medium, string path)
{
var model = SeoModel.ForBrowse(Options, medium, path);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("CollectionPage"));
Assert.That(node.GetProperty("url").GetString(), Is.EqualTo($"https://deepdrft.com{path}"));
Assert.That(model.CanonicalPath, Is.EqualTo(path));
});
}
[Test]
public void ForBrowse_NullMedium_IsArchive()
{
var model = SeoModel.ForBrowse(Options, null, "/archive");
Assert.That(model.Title, Is.EqualTo("Archive"));
}
[Test]
public void ForNotFound_IsNoindex_NoCanonical_NoJsonLd()
{
var model = SeoModel.ForNotFound(Options);
Assert.Multiple(() =>
{
Assert.That(model.Robots, Is.EqualTo("noindex,follow"));
Assert.That(model.CanonicalPath, Is.Null);
Assert.That(model.JsonLd, Is.Null);
});
}
// --- byArtist consistency: per-release artist, matching music:musician -------
[TestCase(ReleaseMedium.Cut)]
[TestCase(ReleaseMedium.Session)]
[TestCase(ReleaseMedium.Mix)]
public void ForRelease_ByArtist_IsPerReleaseArtist_NotCollectiveName(ReleaseMedium medium)
{
var model = SeoModel.ForRelease(Options, Release(medium, artist: "Aphex Twin"));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
// byArtist mirrors the OG music:musician value (the release's own artist), not the SiteName.
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.EqualTo("Aphex Twin"));
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.Not.EqualTo(Options.SiteName));
Assert.That(model.Artist, Is.EqualTo("Aphex Twin"));
});
}
[Test]
public void ForRelease_Cut_AlbumTracks_ByArtist_IsPerReleaseArtist()
{
var tracks = new List<TrackDto> { new() { TrackName = "Track", TrackNumber = 1, DurationSeconds = 30 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, artist: "Aphex Twin"), tracks).JsonLd);
Assert.That(node.GetProperty("track")[0].GetProperty("byArtist").GetProperty("name").GetString(),
Is.EqualTo("Aphex Twin"));
}
// --- AC5 regression: no stray CLR `Type` property emitted alongside `@type` ---
// System.Text.Json previously emitted both `@type` (from the base [JsonPropertyName]) and `Type`
// (the raw CLR override name) on concrete derived nodes, failing the schema.org validator.
// The fix: repeat [JsonPropertyName("@type")] directly on each concrete override.
[Test]
public void MusicAlbumNode_SerializesAtType_OnlyOnce_NoBareTypeProperty()
{
var tracks = new List<TrackDto> { new() { TrackName = "T", TrackNumber = 1, DurationSeconds = 30 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks).JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"),
"MusicAlbumNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"MusicAlbumNode must NOT emit a bare Type property");
});
}
[Test]
public void MusicRecordingNode_TopLevel_SerializesAtType_NoBareTypeProperty()
{
var tracks = new List<TrackDto> { new() { TrackName = "The Mix", DurationSeconds = 3600 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks).JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"),
"MusicRecordingNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"MusicRecordingNode must NOT emit a bare Type property");
});
}
[Test]
public void MusicRecordingNode_NestedTrack_SerializesAtType_NoBareTypeProperty()
{
// The nested track[] MusicRecordingNode is a different code path from the top-level mix node.
var tracks = new List<TrackDto> { new() { TrackName = "T", TrackNumber = 1, DurationSeconds = 30 } };
var albumNode = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks).JsonLd);
var trackNode = albumNode.GetProperty("track")[0];
Assert.Multiple(() =>
{
Assert.That(trackNode.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"),
"nested MusicRecordingNode must emit @type");
Assert.That(trackNode.TryGetProperty("Type", out _), Is.False,
"nested MusicRecordingNode must NOT emit a bare Type property");
});
}
[Test]
public void MusicGroupNode_SerializesAtType_NoBareTypeProperty()
{
var node = Parse(SeoModel.ForHome(Options).JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"),
"MusicGroupNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"MusicGroupNode must NOT emit a bare Type property");
});
}
[Test]
public void CollectionPageNode_SerializesAtType_NoBareTypeProperty()
{
var node = Parse(SeoModel.ForBrowse(Options, ReleaseMedium.Cut, "/cuts").JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("CollectionPage"),
"CollectionPageNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"CollectionPageNode must NOT emit a bare Type property");
});
}
[Test]
public void ArtistRef_NestedByArtist_SerializesAtType_NoBareTypeProperty()
{
// ArtistRef (byArtist) was already clean — this asserts it stays clean after the fix.
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut)).JsonLd);
var byArtist = node.GetProperty("byArtist");
Assert.Multiple(() =>
{
Assert.That(byArtist.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"),
"ArtistRef must emit @type");
Assert.That(byArtist.TryGetProperty("Type", out _), Is.False,
"ArtistRef must NOT emit a bare Type property");
});
}
[Test]
public void AllNodes_ContextIsPresent_AndSchemaOrg()
{
// Belt-and-suspenders: @context must not regress alongside the @type fix.
var nodes = new[]
{
Parse(SeoModel.ForHome(Options).JsonLd),
Parse(SeoModel.ForAbout(Options).JsonLd),
Parse(SeoModel.ForBrowse(Options, ReleaseMedium.Cut, "/cuts").JsonLd),
Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut)).JsonLd),
Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Session)).JsonLd),
Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix)).JsonLd),
};
foreach (var node in nodes)
Assert.That(node.GetProperty("@context").GetString(), Is.EqualTo("https://schema.org"),
"@context must remain present after the @type fix");
}
// --- Critical: inline JSON-LD script-breakout escaping (XSS) -----------------
[Test]
public void ForRelease_TitleWithScriptClose_DoesNotEmitRawAngleBracket()
{
// A CMS-authored title containing </script> / < must not survive raw in the inline script body —
// that is a breakout/XSS vector. The escaped < keeps the JSON parseable while neutralising it.
var release = Release(ReleaseMedium.Cut, title: "Evil</script><script>alert(1)</script>");
var body = SeoModel.ForRelease(Options, release).JsonLd;
Assert.That(body, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(body, Does.Not.Contain("<"), "no raw < may appear in the inline JSON-LD body");
Assert.That(body, Does.Not.Contain(">"), "no raw > may appear in the inline JSON-LD body");
Assert.That(body, Does.Contain("\\u003C"), "< must be emitted as its \\u003C JSON escape");
// The escaped body still parses as JSON and round-trips the original value.
var node = Parse(body);
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Evil</script><script>alert(1)</script>"));
});
}
[Test]
public void ForRelease_TitleWithAmpersand_EscapesAmpersand_StillParses()
{
// The title is serialized into the JSON-LD `name`, so an ampersand there exercises the & escape.
var release = Release(ReleaseMedium.Mix, title: "Sound & Vision");
var body = SeoModel.ForRelease(Options, release).JsonLd;
Assert.That(body, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(body, Does.Not.Contain("&"), "no raw & may appear in the inline JSON-LD body");
Assert.That(body, Does.Contain("\\u0026"), "& must be emitted as its \\u0026 JSON escape");
Assert.That(Parse(body).GetProperty("name").GetString(), Is.EqualTo("Sound & Vision"));
});
}
}
+68
View File
@@ -0,0 +1,68 @@
using DeepDrftPublic.Client.Common;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for <see cref="SeoUrls"/> — the absolute-URL composition shared by the SeoModel factories
/// and SeoHead (Phase 22). Origin always comes from config (never a browser API), so these pin the
/// slash-join, the cover-vs-default fallback (C6/AC4), and the ISO-8601 duration edge cases.
/// </summary>
[TestFixture]
public class SeoUrlsTests
{
private static readonly SeoOptions Options = new()
{
BaseUrl = "https://deepdrft.com",
DefaultImageUrl = "/img/og-default.png",
};
[TestCase("/cuts/key", "https://deepdrft.com/cuts/key")]
[TestCase("cuts/key", "https://deepdrft.com/cuts/key")]
[TestCase("/", "https://deepdrft.com/")]
[TestCase("", "https://deepdrft.com")]
public void Absolute_JoinsOriginAndPath_WithoutDoublingOrDroppingSlash(string path, string expected)
{
Assert.That(SeoUrls.Absolute(Options, path), Is.EqualTo(expected));
}
[Test]
public void Absolute_TrimsTrailingSlashOnBaseUrl()
{
var withSlash = Options with { BaseUrl = "https://deepdrft.com/" };
Assert.That(SeoUrls.Absolute(withSlash, "/cuts"), Is.EqualTo("https://deepdrft.com/cuts"));
}
[Test]
public void CoverOrDefault_WithCover_BuildsEscapedImageRoute()
{
Assert.That(SeoUrls.CoverOrDefault(Options, "my cover.jpg"),
Is.EqualTo("https://deepdrft.com/api/image/my%20cover.jpg"));
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void CoverOrDefault_WithoutCover_FallsBackToDefaultImage(string? image)
{
Assert.That(SeoUrls.CoverOrDefault(Options, image),
Is.EqualTo("https://deepdrft.com/img/og-default.png"));
}
[TestCase(30.0, "PT30S")]
[TestCase(90.0, "PT1M30S")]
[TestCase(3723.0, "PT1H2M3S")]
public void IsoDuration_PositiveSeconds_RendersIso8601(double seconds, string expected)
{
Assert.That(SeoUrls.IsoDuration(seconds), Is.EqualTo(expected));
}
[TestCase(null)]
[TestCase(0.0)]
[TestCase(-5.0)]
[TestCase(double.NaN)]
[TestCase(double.PositiveInfinity)]
public void IsoDuration_NonPositiveOrNonFinite_ReturnsNull(double? seconds)
{
Assert.That(SeoUrls.IsoDuration(seconds), Is.Null);
}
}
+154
View File
@@ -0,0 +1,154 @@
using System.Xml.Linq;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Seo;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for <see cref="SitemapXml"/> — the pure sitemaps.org urlset composition (Phase 23 wave 23.2).
/// The document is parsed back to an <see cref="XDocument"/> so each assertion checks real structure, not a
/// substring: that every <c>&lt;loc&gt;</c> is absolute and built through <see cref="ReleaseRoutes"/> (so it
/// equals the page canonical), that <c>&lt;lastmod&gt;</c> tracks the release date, that the static roots are
/// present and FramePlayer is absent, and that empty input still yields a well-formed roots-only document.
/// </summary>
[TestFixture]
public class SitemapXmlTests
{
private const string BaseUrl = "https://deepdrft.com";
private static readonly XNamespace Ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
private static ReleaseDto Release(string entryKey, ReleaseMedium medium, DateOnly? releaseDate = null) => new()
{
EntryKey = entryKey,
Title = "Title",
Artist = "Artist",
Medium = medium,
ReleaseDate = releaseDate,
};
private static List<string> Locs(string xml)
{
var doc = XDocument.Parse(xml);
return doc.Root!.Elements(Ns + "url")
.Select(u => u.Element(Ns + "loc")!.Value)
.ToList();
}
[Test]
public void Build_EmptyReleases_YieldsWellFormedRootsOnlyDocument()
{
var xml = SitemapXml.Build(BaseUrl, []);
var locs = Locs(xml);
Assert.Multiple(() =>
{
Assert.That(locs, Has.Count.EqualTo(SitemapXml.StaticRoots.Count));
Assert.That(locs, Does.Contain("https://deepdrft.com/"));
Assert.That(locs, Does.Contain("https://deepdrft.com/about"));
Assert.That(locs, Does.Contain("https://deepdrft.com/cuts"));
Assert.That(locs, Does.Contain("https://deepdrft.com/sessions"));
Assert.That(locs, Does.Contain("https://deepdrft.com/mixes"));
Assert.That(locs, Does.Contain("https://deepdrft.com/archive"));
});
}
[Test]
public void Build_IsWellFormedUrlsetWithSitemapsOrgNamespace()
{
var xml = SitemapXml.Build(BaseUrl, []);
var doc = XDocument.Parse(xml);
Assert.Multiple(() =>
{
Assert.That(doc.Root!.Name, Is.EqualTo(Ns + "urlset"));
Assert.That(xml, Does.Contain("utf-8").IgnoreCase);
});
}
[Test]
public void Build_FramePlayerIsNeverAStaticRoot()
{
var xml = SitemapXml.Build(BaseUrl, []);
Assert.That(Locs(xml), Has.None.Contains("FramePlayer"));
}
[TestCase(ReleaseMedium.Cut, "https://deepdrft.com/cuts/key-1")]
[TestCase(ReleaseMedium.Session, "https://deepdrft.com/sessions/key-1")]
[TestCase(ReleaseMedium.Mix, "https://deepdrft.com/mixes/key-1")]
public void Build_ReleaseLoc_IsAbsoluteAndResolvedThroughReleaseRoutes(ReleaseMedium medium, string expectedLoc)
{
var xml = SitemapXml.Build(BaseUrl, [Release("key-1", medium)]);
// The loc must equal BaseUrl + ReleaseRoutes.DetailHref — i.e. the page's SeoHead canonical, by construction.
var expected = BaseUrl + ReleaseRoutes.DetailHref("key-1", medium);
Assert.Multiple(() =>
{
Assert.That(expected, Is.EqualTo(expectedLoc));
Assert.That(Locs(xml), Does.Contain(expectedLoc));
});
}
[Test]
public void Build_AllReleasesEnumerated_AppendedAfterStaticRoots()
{
var releases = new[]
{
Release("a", ReleaseMedium.Cut),
Release("b", ReleaseMedium.Mix),
Release("c", ReleaseMedium.Session),
};
var xml = SitemapXml.Build(BaseUrl, releases);
Assert.That(Locs(xml), Has.Count.EqualTo(SitemapXml.StaticRoots.Count + releases.Length));
}
[Test]
public void Build_ReleaseWithDate_EmitsW3CLastmod()
{
var xml = SitemapXml.Build(BaseUrl, [Release("key-1", ReleaseMedium.Cut, new DateOnly(2026, 5, 12))]);
var doc = XDocument.Parse(xml);
var releaseUrl = doc.Root!.Elements(Ns + "url")
.Single(u => u.Element(Ns + "loc")!.Value.EndsWith("/cuts/key-1"));
Assert.That(releaseUrl.Element(Ns + "lastmod")!.Value, Is.EqualTo("2026-05-12"));
}
[Test]
public void Build_ReleaseWithoutDate_OmitsLastmod()
{
var xml = SitemapXml.Build(BaseUrl, [Release("key-1", ReleaseMedium.Cut)]);
var doc = XDocument.Parse(xml);
var releaseUrl = doc.Root!.Elements(Ns + "url")
.Single(u => u.Element(Ns + "loc")!.Value.EndsWith("/cuts/key-1"));
Assert.That(releaseUrl.Element(Ns + "lastmod"), Is.Null);
}
[Test]
public void Build_StaticRoots_NeverCarryLastmod()
{
var xml = SitemapXml.Build(BaseUrl, []);
var doc = XDocument.Parse(xml);
Assert.That(doc.Root!.Elements(Ns + "url").All(u => u.Element(Ns + "lastmod") is null), Is.True);
}
[Test]
public void Build_TrimsTrailingSlashOnBaseUrl()
{
var xml = SitemapXml.Build("https://deepdrft.com/", [Release("key-1", ReleaseMedium.Cut)]);
Assert.Multiple(() =>
{
// No doubled slash on the root or the release URL.
Assert.That(Locs(xml), Does.Contain("https://deepdrft.com/"));
Assert.That(Locs(xml), Does.Contain("https://deepdrft.com/cuts/key-1"));
});
}
}
@@ -0,0 +1,91 @@
using DeepDrftPublic.Client.Services;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Theater-Mode auto-exit invariant on <see cref="WaveformVisualizerControlState"/>
/// (Phase 20 bug fix): when both subsystems are disabled, <see cref="WaveformVisualizerControlState.CoerceTheaterMode"/>
/// must force <c>TheaterMode = false</c> so observers never see a stranded-theater state.
/// </summary>
[TestFixture]
public class WaveformVisualizerControlStateTests
{
private WaveformVisualizerControlState _state = null!;
[SetUp]
public void SetUp() => _state = new WaveformVisualizerControlState();
// ── CoerceTheaterMode guard ──
// Both off + Theater on → coerce exits theater.
[Test]
public void CoerceTheaterMode_BothOff_TheaterBecomesFalse()
{
_state.TheaterMode = true;
_state.LavaEnabled = false;
_state.WaveformEnabled = false;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.False);
}
// Lava still on → theater is left alone even if waveform is off.
[Test]
public void CoerceTheaterMode_LavaOnWaveformOff_TheaterPreserved()
{
_state.TheaterMode = true;
_state.LavaEnabled = true;
_state.WaveformEnabled = false;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.True);
}
// Waveform still on → theater is left alone even if lava is off.
[Test]
public void CoerceTheaterMode_WaveformOnLavaOff_TheaterPreserved()
{
_state.TheaterMode = true;
_state.LavaEnabled = false;
_state.WaveformEnabled = true;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.True);
}
// Theater already false + both off → no change (no false-positive write).
[Test]
public void CoerceTheaterMode_TheaterAlreadyFalse_NoChange()
{
_state.TheaterMode = false;
_state.LavaEnabled = false;
_state.WaveformEnabled = false;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.False);
}
// ── Changed event fires once with coerced state visible ──
// Verify that after coercion, the Changed notification carries the already-corrected TheaterMode
// value — all observers see a consistent state in the single Changed cycle.
[Test]
public void NotifyChanged_AfterCoerce_ObserverSeesTheaterFalse()
{
_state.TheaterMode = true;
_state.LavaEnabled = false;
_state.WaveformEnabled = false;
bool? observedTheaterMode = null;
_state.Changed += () => observedTheaterMode = _state.TheaterMode;
_state.CoerceTheaterMode();
_state.NotifyChanged();
Assert.That(observedTheaterMode, Is.False);
}
}
+212
View File
@@ -443,6 +443,218 @@ not the same work; this phase does not satisfy or depend on that one.
---
## Phase 18 — Opus Low-Data Streaming (dual-format lossless + Opus delivery)
The concrete realization of the long-deferred **"Non-WAV formats"** intent (`CONTEXT.md §5`). Daniel's
direction (2026-06-23): **two delivery formats per track — the existing lossless WAV path, and a new
low-data Ogg Opus (fullband, 320 kbps) path — so the listener gets a choice, with Opus the
bandwidth-friendly default-candidate.** Lossless streaming becomes *optional*, not the only path. The
bespoke Web Audio decode→schedule graph is **retained by deliberate choice** — Opus feeds the same
`IFormatDecoder` seam, not an HTML `<media>` element or MSE (the decision shared with Phase 21 OQ5).
**Sequenced BEFORE Phase 21** — windowing must work across both formats. Surfaces: ingest/preprocessing
in `DeepDrftContent` (`AudioProcessor`/router/`WaveformProfileService`) + `DeepDrftAPI`
(`UnifiedTrackService.UploadAsync`, replace-audio); delivery/decode in `DeepDrftAPI` (stream endpoint +
`Range`) + `DeepDrftPublic` proxy + `DeepDrftPublic.Client` player stack + `DeepDrftPublic/Interop/audio`
TS decoders. Full design, the three directions with SOLID/road-not-taken rationale, the storage and
delivery options, the Opus decoder + seek math, acceptance criteria, open questions, and wave
decomposition: `product-notes/phase-18-opus-low-data-streaming.md`.
**Much further along than the backlog line implies (verified 2026-06-23).** The multi-format *substrate*
already exists on both sides: the producer-side `AudioProcessorRouter` routes `.wav`/`.mp3`/`.flac` and
`TrackContentService.AddTrackAsync` is format-agnostic (it **stores originals**, no transcode); the
decoder-side `AudioPlayer.createFormatDecoder` is a **wired** strategy registry dispatching on
`Content-Type` (WAV/MP3/FLAC decoders all present — correcting the Phase 21 spec's stale
"implemented-not-wired" note). **The actual gap is Daniel's specific ask:** (1) a **transcode-at-ingest**
step that *derives* an Opus 320 artifact per track (nothing derives Opus today), and (2) a **per-format
delivery selection** so one track serves as either WAV or Opus on request.
**Open questions RESOLVED (Daniel, 2026-06-23).** OQ1 selection UX → **global, via a new public-site
Settings menu** (not a bare app-bar control); OQ2 default → **Opus by default, capability-gated** (defer
network-awareness); OQ3 remembered → **persisted via the dark-mode seam** (cookie → prerender-read →
`PersistentComponentState` → client cookie service); OQ4 → **always-on Opus + Backfill-Opus**; OQ5 →
**Ogg Opus**; OQ6 transcode model → **background job after the file is available, with a visible
Post-Processing phase on the CMS upload meter.** OQ7 (seek-index granularity) → **0.5 s (half-second)
buckets** (~115 KB index for a 1-hour mix).
**Architectural spine — a derived artifact set + a delivery param + one new decoder + a precomputed
accurate seek index; leaf implementations only, zero changes to existing format code (the strong OCP
signal).** Transcode is a new processor sibling in `DeepDrftContent`, invoked post-store alongside
`WaveformProfileService` **as a background job** (a 1 GB WAV transcode must not block the upload; the source
is stored and the track plays lossless *first*, then Opus is derived) — mirroring the landed waveform-datum
pattern (derive at ingest, regenerate via a CMS bulk action + ApiKey endpoint). The Opus bytes are a
**derived artifact** stored like the high-res waveform datum (recommend a dedicated `track-opus` vault, the
`track-waveforms` precedent; final call staff-engineer's). Delivery adds a **`?format=opus|lossless` param**
(mirroring the existing `offset` param threading through `TrackProxyController`) resolved server-side to the
right artifact + content-type, with a **lossless fallback** when no Opus artifact exists (additive, never
404/silence). The player gains one `OpusFormatDecoder` (`IFormatDecoder`): Ogg-page-aligned segmenting
(`OggS` scan — the FLAC frame-sync analogue) and `OpusHead`/`OpusTags` setup-bytes carry (the FLAC
`streamInfoBytes` analogue). **Browser constraint flagged:** Ogg-Opus `decodeAudioData` is Safari-18.4+ only
(Chrome/FF long-standing), so the Opus default is **capability-gated** — fall back to the universal lossless
path on browsers that can't decode it.
**VBR-safe ACCURATE seeking (Daniel, 2026-06-23 — supersedes the earlier "approximate" hand-wave).** Raw
byte-offset seek and rough page interpolation are inadequate for VBR Opus — there is no linear time↔byte
relationship. The fix is an **accurate transfer function built at transcode time** (the one moment the
whole encoded stream is walked): a precomputed **seek index** mapping Ogg-page `granulepos` (48 kHz sample
counts → time) → exact byte offset (**0.5 s buckets** snapped to page starts — OQ7; ~7,200 entries ×
16 bytes ≈ ~115 KB for a 1-hour mix). The decode **setup header** (`OpusHead`/`OpusTags`, needed to decode any mid-stream slice) is made
available too. Recommended concrete design: **one sidecar artifact per track = `[setup header][seek
index]`, built at transcode, stored beside the Opus bytes, fetched once on track load**, parsed into
`OpusSeekData`. Client seek flow: `calculateByteOffset(t)` binary-searches the index for the exact page
offset → `Range: bytes=X-` fetch (landed Phase 4 primitive, unchanged) → prepend the cached setup header →
decode → fine re-sync to `t` within the bucket. **The listener lands at the correct time, not
approximately** (AC9), **without** the full PCM in memory — so it composes with Phase 21 windowed refill,
which calls the **same** index resolver. The earlier "approximate page-interpolation" language is rejected.
**Constraints/invariants:** keep the bespoke graph (no MSE); preprocessing is **additive** (WAV path
untouched, byte-for-byte; a track with no Opus artifact still plays losslessly); reuse the landed
`Range`/offset seek path; no format branches leak outside the new decoder + one selection arm + the
transcode/delivery seam; transcode failure must not block ingest; format selection is a delivery-time
decision resolving one `EntryKey` to one of two artifacts (one source, two views — **not** a second
`TrackEntity` row, which would fracture share/queue/play-count/release identity).
Sequenced as six waves. `18.1 → 18.2 → {18.3, 18.4} → 18.5`, with `18.6` (Settings menu) able to run in
parallel (it needs only 18.3's format mechanism before its toggle is live). **18.1 (ingest transcode +
seek-index + setup-header derived artifacts) is the cold-start prerequisite** — nothing downstream has
bytes to serve, decode, or seek against until those artifacts exist.
- **18.1 — Ingest transcode + seek-index + setup-header (cold-start; load-bearing).** New
`OpusTranscodeService`/processor in `DeepDrftContent`, invoked post-store from
`UnifiedTrackService.UploadAsync` alongside `WaveformProfileService` **as a background job** (OQ6);
produces Ogg Opus fullband 320; **walks the encoded stream once to build the granule→byte seek index and
extract the `OpusHead`/`OpusTags` setup header**; stores the Opus bytes **and** the combined seek/setup
**sidecar** as derived artifacts (recommend a `track-opus` vault). Failure-tolerant. **Independent of the
delivery/decoder waves — can begin immediately.**
- **18.2 — Storage + lookup contract.** The derived-artifact key/vault convention (Opus bytes + sidecar) +
server-side "given `EntryKey` + format, return the right `AudioBinary` + content-type (+ the sidecar),"
including the lossless fallback. **Depends on 18.1.**
- **18.3 — Delivery: `?format=opus|lossless` param + sidecar serving + proxy threading.** On the
`DeepDrftAPI` stream endpoint (resolves via 18.2), forwarded through `TrackProxyController` (mirror
`offset`), `Range` serving the chosen artifact; **plus serving the seek/setup sidecar**; player sends the
format param via `TrackMediaClient`. **Depends on 18.2; parallel-ok with 18.4.**
- **18.4 — `OpusFormatDecoder` + index-based seek resolver in the player stack.** New `IFormatDecoder`
(Ogg-page segmenting via `OggS` scan, `OpusHead`/`OpusTags` setup carry from the cached sidecar,
**`calculateByteOffset` that binary-searches the precomputed seek index** — NOT interpolation — with an
`OpusSeekData` accelerator holding the parsed index + setup bytes, and the one-time sidecar fetch+parse on
track load) + one arm in `createFormatDecoder` on `audio/ogg`/`audio/opus`; capability detection for the
lossless fallback. **Depends on 18.2; parallel-ok with 18.3.**
- **18.5 — Backfill + replace-audio + end-to-end validation (incl. seek accuracy).** "Backfill Opus" CMS
bulk action (third sibling to Generate-Profiles / Backfill-High-res), rebuilding Opus bytes + sidecar for
existing tracks; replace-audio Opus + sidecar regeneration; the AC1AC10 acceptance pass **including AC9
(an Opus seek lands at the correct time, not approximately)** and the Phase-21 handshake (Opus windowable
via the index resolver + sidecar setup header). **Depends on 18.118.4.**
- **18.6 — Public Settings menu + quality toggle (the listener selection UX).** New public-site
Settings-menu shell (app-bar trigger + MudBlazor menu + a settings-item abstraction + a
`PublicSiteSettings`/`ListenerSettings` object + the dark-mode-pattern persistence seam: `streamQuality`
cookie, a `DeepDrftPublic` prerender-read service, `PersistentComponentState` bridge, client cookie
service); the **quality toggle is its first occupant** (Low-data/Lossless, Opus default, capability-gated)
+ the CMS upload meter's **Post-Processing phase** (OQ6). Built design-for-adaptability so dark mode can
plug in later without restructuring (not migrated now). **Depends on 18.3** for the toggle; the menu shell
can be built ahead. *Splittable* (shell, then toggle) if Daniel wants the shell proven first.
**Dependency shape:** `18.1 → 18.2 → {18.3 ∥ 18.4} → 18.5`; `18.6 ∥` (needs 18.3 for the live toggle);
18.1 is the only cold-start wave. **Phase-level: 18 precedes Phase 21** (windowed refill consumes the Phase
18 seek-index resolver). **OQ1OQ7 RESOLVED (above); OQ7 (seek-index granularity) = 0.5 s buckets.** None
block 18.1.
---
## Phase 21 — Windowed Streaming Buffer (bounded client memory for long streams)
Bound the **client memory** a playing track consumes to a small, configurable forward window —
**independent of total stream length** — so a 1 GB+ DJ MIX (Phase 9 `Mix` medium: a single long track)
plays without the whole decoded PCM accumulating in the browser. **Public listener site only**
(`DeepDrftPublic.Client` player stack + `DeepDrftPublic` TypeScript audio interop); no CMS, no API
endpoint, no schema change.
**Sequenced AFTER Phase 18 (Opus Low-Data Streaming) — Daniel, 2026-06-23.** Format support (the
derived Ogg Opus 320 low-data path, Phase 18) is a prerequisite that comes first; windowing must work
across **both** delivery formats. Phase 21's C5 invariant already anticipated this ("must not foreclose
MP3/FLAC"); **Opus is now the concrete VBR/paged driver** — windowing an Opus stream uses the decoder's
**accurate index-based** byte↔time mapping (`OpusFormatDecoder.calculateByteOffset`, a binary search in the
Phase 18 precomputed seek index — *not* the exact CBR-WAV `byteRate` math, and *not* approximate page
interpolation: VBR-safe and exact, per the Phase 18 seek-model resolution 2026-06-23). The windowed refill
controller calls the **same** index resolver an explicit seek does, and a window opening away from byte 0
still decodes via the Phase 18 sidecar setup header. Build the window machinery format-agnostically so it
inherits Opus for free.
The network path already streams in adaptive 1664 KB chunks. The accumulation is on the **decode
side**: `PlaybackScheduler` holds an `AudioBuffer[]` it **never evicts** ("Supports pause/resume/seek by
retaining all buffers" — its own doc comment). Decoded PCM is larger than the source (Web Audio is
32-bit float per sample/channel — a 16-bit stereo WAV roughly doubles once decoded), so a 1 GB WAV
becomes ~2 GB of retained float data. That is the OOM. The fix: hold only a sliding forward window plus a
small back-retain, discard already-played buffers, and refill on demand.
**Architectural spine — a sliding window keyed on playback position, built as a generalization of the
landed seek-beyond-buffer path.** The Phase 4 HTTP `Range: bytes=X-` → 206 primitive already does every
plumbing primitive the window needs (discard-buffers-keep-offset via `clearForSeek`/`setPlaybackOffset`;
fetch-from-offset via `TrackMediaClient`; decode-header-less-body via
`StreamDecoder.reinitializeForRangeContinuation`; time→byte via `IFormatDecoder.calculateByteOffset`),
just triggered manually and one-shot. The only genuinely new mechanisms are **partial eviction** on the
scheduler and **back-pressure** on the forward read loop (stop calling `ReadAsync` above a high-water
mark, resume below low-water). Recommended **Direction A** (sliding window on the existing single forward
stream); **Direction B** (discrete Range-fetched segments — the HLS/DASH/MSE-eviction analogue) held as
the documented fallback; **Direction C** (adopt MSE and let the browser manage the buffer) **rejected
(OQ5 = NO, Daniel 2026-06-23)** — the bespoke Web Audio graph is a deliberate long-term commitment, and
the compressed-delivery move that would have justified MSE is met instead by **Phase 18 (Opus) feeding
the same bespoke graph** through the `IFormatDecoder` seam. Direction A is therefore the permanent
destination, not a stopgap MSE would retire.
**Invariants that must hold (the §3.5 seam contract).** Reuse the Range path, don't fork it; playback-
start latency at parity; the `IFormatDecoder` abstraction untouched (windowing is format-agnostic, so
wiring MP3/FLAC later inherits it free); read-only playback (no new control); the single-instance JS
decoder stays single-writer (every refill routes through the existing cancellation/drain discipline). The
**Mix visualizer is provably unaffected** — it renders from the preprocessed per-track high-res datum
(Phase 10/12), never from live decoded PCM, so evicting played buffers cannot starve it. The 1 GB mix is
both the canonical case *and* the proof the eviction is safe.
**Interaction with deferred Phase 1 features (same seam):** windowing should land **before** preload
(1.3) — it makes preload of long tracks memory-safe by construction (a staged next-track decoder inherits
the bounded scheduler); it makes crossfade (1.4) between two long mixes affordable (the overlap doubles
the *window*, not the track); it adds a minor "don't evict the final window before the gapless boundary"
care point for 1.5. It **enlarges the error surface** (1.6): windowed refill issues mid-stream fetches
the listener didn't initiate, one of which can fail deep into a 1 GB mix — so the *cheap* half of 1.6
(clean refill-failure handling, no wedged player) is folded into this phase's acceptance criteria, not
left fully to 1.6.
Full design, the three directions with SOLID/road-not-taken rationale, use cases, acceptance criteria,
the open-question set, and the wave decomposition: `product-notes/phase-21-windowed-streaming-buffer.md`.
Sequenced as four waves. `21.1 → 21.2 → 21.3`, with `21.4` validating the whole. **21.1 is the cold-start
prerequisite and the load-bearing change** — independent of the open questions (window *sizes* are
parameters fed in later).
- **21.1 — Partial eviction in `PlaybackScheduler` (cold-start; load-bearing).** Drop already-played
buffers while keeping the position/index/time-anchor bookkeeping exact against a buffer array that no
longer begins at absolute time 0 (today `getCurrentPosition`/`playFromPosition`/the schedule loop all
assume `buffers[0]` is the track start). The hardest correctness work in the phase. No refill yet.
**Independent of the open questions — can begin immediately.**
- **21.2 — Back-pressure on the forward read loop.** Stop `ReadAsync` above the high-water mark, resume
below low-water; together with 21.1 this bounds *both* the played and unplayed regions (the AC1
guarantee). Routes resume/pause through the existing single-loop cancellation discipline. **Depends on
21.1.**
- **21.3 — Seek-back-past-window refill.** When a backward seek lands earlier than the retained tail,
refetch via the existing seek-beyond-buffer Range path pointed at the earlier offset; plus the minimal
clean refill-failure handling (the 1.6 adjacency). Mostly reuse of the landed seek path. **Depends on
21.1 + 21.2.**
- **21.4 — Validation against the 1 GB target (acceptance).** Memory profiling (bounded under 1 GB is the
headline), latency parity, edge-to-edge playback, the seek matrix, induced refill failure, visualizer-
running, rapid-seek concurrency. Largely measurement; breaks are tuning fixes in 21.1's anchor math or
21.2's water-marks. **Depends on 21.121.3.**
**Dependency shape:** `21.1 → 21.2 → 21.3 → 21.4`; 21.1 is the only cold-start wave. **Phase-level
prerequisite: Phase 18 (Opus) lands first** so windowing is built against both formats. **Open questions
for Daniel (spec §6):** window-size policy axis (time-based window + memory guard — recommended); seek-
back-past-window re-buffer acceptable (recommend yes, symmetric to forward); a hard total in-flight
memory cap as a guard rail (recommend yes); window everything vs. only long tracks (recommend everything
— one path, short tracks never hit a refill). **OQ5 (adopt MSE) — RESOLVED NO (Daniel 2026-06-23): the
bespoke graph stays by deliberate choice; recorded considered-and-declined, kept visible per file
convention.** None block 21.1.
---
## Working with this file
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing.
+127
View File
@@ -0,0 +1,127 @@
# DeepDrftHome — Production Installation Checklist
Fresh-box checklist for deploying the DeepDrftHome solution (DeepDrftPublic, DeepDrftManager, DeepDrftAPI) to a new production host. Every package, directory, and service is treated as absent. Gated steps — those requiring a decision, a secret, or a network action from the operator — are marked **[GATE]**.
> This document is a reference you run from. It can drift from the actual `deploy/` scripts (`bootstrap.sh`, `install.sh`, `setup-step10-creds.sh`, the systemd units, and the nginx templates) — when those change, update this checklist to match.
## Phase 0 — Prerequisites (build/admin machine)
- [ ] Confirm DNS A/AAAA records for `deepdrft.com` and `app.deepdrft.com` point at the new host's IP — certbot's HTTP-01 challenge fails if DNS hasn't propagated. **[GATE]**
- [ ] Generate a CI deploy ed25519 key on your local machine (not the host): `ssh-keygen -t ed25519 -C "gitea-ci-deepdrft-prod" -f ~/.ssh/gitea_deepdrft_prod`. Public key → installer prompt; private key → Gitea secret `DEEPDRFT_PROD_SSH_DEPLOY`. **[GATE]**
- [ ] Download the latest `deepdrft-install.tar.gz` release asset (built by `package-install.yml` on a `deploy/` push to `master`). If none exists, push a no-op change to `deploy/` on `master` and wait for the artifact. **[GATE]**
- [ ] `scp deepdrft-install.tar.gz root@<host>:/tmp/`
## Phase 1 — Bootstrap / installer (run as root)
- [ ] Run: `INSTALL_PKG_PATH=/tmp/deepdrft-install.tar.gz bash bootstrap.sh` (installs OS prereqs, hands off to `install.sh`).
- [ ] The installer is interactive — have ready: app user (`deepdrft` / `/deepdrft`), PG role (`deepdrft`), DB names (`deepdrft-meta`, `deepdrft-auth`), public domain, app subdomain (default `app.<public>`), ports (5000/5001/5002), certbot email, PG password (twice), CI deploy public key. **[GATE]**
Automated Steps 010: apt preflight (postgresql, nginx, rsync, openssl, jq, wget) → create user → `enable-linger` → directory layout → deploy scripts to `/opt/deepdrft/bin/` → systemd units enabled (not started) → credentials (Step 6, prompts) → PostgreSQL role + DBs → `authorized_keys` forced-command → nginx vhosts → summary.
## Phase 2 — Credentials (Step 6, interactive) **[GATE]**
Writes 6 files to `/deepdrft/.config/credentials/` (mode 600): `filedatabase.json` (no prompt; vault path hardcoded), `apikey.json` (auto-generate or paste), `connections.json` (PG password), `authblocks.json` (JWT secret, issuer/audience, SMTP host+token+From, admin user/email/password, support email), `api-public.json` + `api-manager.json` (auto-built). JWT issuer/audience must match `appsettings.json` `AuthBlocks:Jwt`.
Verify: `sudo -u deepdrft ls -la /deepdrft/.config/credentials/` → 6 × mode 600, owned `deepdrft:deepdrft`.
## Phase 3 — CorsSettings **[GATE]**
`DeepDrftAPI/appsettings.json` `CorsSettings.AllowedOrigins` must include the Manager origin `https://app.deepdrft.com`. The API throws on startup if origins are empty; a missing Manager origin causes silent 401s on CMS auth. (Confirm this is present before building.)
## Phase 4 — Gitea secrets **[GATE]**
Add `DEEPDRFT_PROD_SSH_DEPLOY` = full private key contents. (The `dev`/beta host uses `DEEPDRFT_DCH7_SSH_DEPLOY`.)
## Phase 5 — TLS (after DNS propagates) **[GATE]**
```
host deepdrft.com
host app.deepdrft.com
snap install --classic certbot
ln -sf /snap/bin/certbot /usr/bin/certbot
certbot --nginx --email <email> --agree-tos --no-eff-email -d deepdrft.com -d app.deepdrft.com
nginx -t && systemctl reload nginx
certbot renew --dry-run
```
The installer's vhosts are HTTP-only (`listen 80`); certbot rewrites them in place to add the 443 blocks.
## Phase 6 — Verify SSH forced-command chain (before first deploy) **[GATE]**
- Layer 1: `ssh -i key deepdrft@host deploy-public` → prints the `[deploy-public]` prefix then a missing-archive error (non-zero exit expected).
- Layer 2: `ssh -i key deepdrft@host id``ssh-wrapper: unknown command: id` (no shell).
- Layer 3: rsync a smoke file → lands in `/deepdrft/staging/`.
- Layer 4: `deploy-public`, `deploy-manager`, `deploy-api` each print their prefix.
Do not proceed until all four pass.
## Phase 7 — First deploy (push `master`)
Three workflows trigger by path filter:
- `deploy-api.yml`: `DeepDrftAPI/`, `DeepDrftData/`, `DeepDrftContent/`, `DeepDrftModels/`
- `deploy-public.yml`: `DeepDrftPublic/`, `DeepDrftPublic.Client/`, `DeepDrftShared.Client/`, `DeepDrftModels/`
- `deploy-manager.yml`: `DeepDrftManager/`, `DeepDrftShared.Client/`, `DeepDrftModels/`
Root-file-only changes trigger none. `deploy-api` builds + publishes self-contained linux-x64, runs `ef migrations bundle`, rsyncs, then `deploy-api.sh` applies the EF bundle to `deepdrft-meta`, swaps `bin/`, restarts the unit. `deploy-public` also installs the `wasm-tools` workload. Watch the three parallel Gitea jobs. **[GATE]**
## Phase 8 — EF migration verification **[GATE]**
The EF bundle runs before the binary swap (metadata DB). The AuthBlocks `deepdrft-auth` schema self-migrates on first boot and seeds the admin. Verify `\dt` on both DBs plus `__EFMigrationsHistory`. On failure: `journalctl --user -u deepdrftapi`.
## Phase 9 — Service health
Check `deepdrftapi` / `deepdrftpublic` / `deepdrftmanager` via `systemctl --user status` and `journalctl`. Common failures: missing/wrong credential key; unreadable creds (must be 600 `deepdrft:deepdrft`); PostgreSQL not on peer auth; wrong vault path; OOM on droplets < 2 GB (add swap).
## Phase 10 — Smoke-test per host
**API (port 5002, internal):**
- `curl localhost:5002/api/track/page` → empty list on fresh install.
- `curl localhost:5002/api/stats/home` → zeros.
- `POST /api/auth/login` with admin creds → a JWT.
**Public site:**
- `curl https://deepdrft.com/` renders.
- `curl https://deepdrft.com/robots.txt``Allow: /` (NOT `Disallow: /` — that means the env isn't Production).
- `curl https://deepdrft.com/sitemap.xml` → XML (static roots present even with 0 releases).
- Confirm the unit carries `Environment=ASPNETCORE_ENVIRONMENT=Production`.
**Manager (CMS):**
- `curl https://app.deepdrft.com/` renders.
- `curl https://app.deepdrft.com/robots.txt``Disallow: /` (always uncrawlable).
- `curl -o /dev/null -w "%{http_code}" https://app.deepdrft.com/` → 200.
**Blazor WebSocket:**
- `curl -H "Upgrade: websocket" -H "Connection: Upgrade" https://deepdrft.com/_blazor` → 101 (not 502/504 from nginx).
## Phase 11 — Hardening
- [ ] Change the admin password immediately — the seed credentials are stored plaintext on disk. **[GATE]**
- [ ] Firewall (UFW): allow 22/80/443, deny the rest. Port 5002 (API) is internal-only.
- [ ] `apt-get install -y unattended-upgrades && dpkg-reconfigure -plow unattended-upgrades`
- [ ] Review `pg_hba.conf` — no `host all all 0.0.0.0/0 md5` line; the `deepdrft` role connects via Unix socket (peer auth).
- [ ] Back up `~/api/deepdrft/vaults` — it is not in any deploy artifact or EF bundle; a server wipe loses all audio permanently. **[GATE]**
## Phase 12 — Iteration notes
- EF migrations auto-apply before the binary swap on every deploy — no manual `dotnet ef database update`.
- AuthBlocks self-migrates on each API start.
- The FileDatabase vault is never touched by deploys; new vault types are created by the app on first access.
- Credential rotation: rerun `setup-step10-creds.sh --force` on the host as `deepdrft`, then restart the affected services.
- Each deploy script moves the current `bin/` to `bin.prev/` — manual rollback: `mv ~/public/bin.prev ~/public/bin && systemctl --user restart deepdrftpublic.service` (substitute `manager` / `api/deepdrft`).
- Two distinct staging dirs: `~/staging/` (CI rsync jail) vs `~/api/deepdrft/vaults/staging/` (large-audio upload staging). Do not conflate them.
## Host path reference
| Path | What |
|---|---|
| `/deepdrft/.config/credentials/` | 6 × JSON credential files (600) |
| `/deepdrft/.config/systemd/user/` | 3 × `.service` unit files |
| `/deepdrft/public/bin/` | DeepDrftPublic publish output |
| `/deepdrft/manager/bin/` | DeepDrftManager publish output |
| `/deepdrft/api/deepdrft/bin/` | DeepDrftAPI publish output |
| `/deepdrft/api/deepdrft/vaults/` | FileDatabase vault — never delete, never in deploy |
| `/deepdrft/staging/` | rsync jail root (CI artifact drop zone) |
| `/opt/deepdrft/bin/ssh-wrapper` | Forced-command dispatcher |
| `/opt/deepdrft/bin/deploy-*.sh` | Per-service deploy scripts |
| `/etc/nginx/sites-available/deepdrft.com.conf` | Public nginx vhost |
| `/etc/nginx/sites-available/app.deepdrft.com.conf` | Manager nginx vhost |
+3 -2
View File
@@ -181,6 +181,7 @@ if need_cred "authblocks"; then
read -rp " Email host (SMTP server or API host): " EMAIL_HOST
read -rsp " Email token (API key / SMTP password): " EMAIL_TOKEN
echo
read -rp " Sender email address (From:, e.g. noreply@${DOMAIN_PUBLIC}): " EMAIL_FROM
# Admin account
echo
@@ -201,10 +202,10 @@ if need_cred "authblocks"; then
read -rp " Support email address: " SUPPORT_EMAIL
write_cred "authblocks" "$(cat <<JSON
{"AuthBlocks":{"Jwt":{"Secret":"$(json_escape "${JWT_SECRET}")","Issuer":"$(json_escape "${JWT_ISSUER}")","Audience":"$(json_escape "${JWT_AUDIENCE}")"},"Email":{"Host":"$(json_escape "${EMAIL_HOST}")","Token":"$(json_escape "${EMAIL_TOKEN}")"},"Admin":{"UserName":"$(json_escape "${ADMIN_USERNAME}")","Email":"$(json_escape "${ADMIN_EMAIL}")","Password":"$(json_escape "${ADMIN_PASSWORD}")"},"SupportEmail":"$(json_escape "${SUPPORT_EMAIL}")"}}
{"AuthBlocks":{"Jwt":{"Secret":"$(json_escape "${JWT_SECRET}")","Issuer":"$(json_escape "${JWT_ISSUER}")","Audience":"$(json_escape "${JWT_AUDIENCE}")"},"Email":{"Host":"$(json_escape "${EMAIL_HOST}")","Token":"$(json_escape "${EMAIL_TOKEN}")","From":"$(json_escape "${EMAIL_FROM}")"},"Admin":{"UserName":"$(json_escape "${ADMIN_USERNAME}")","Email":"$(json_escape "${ADMIN_EMAIL}")","Password":"$(json_escape "${ADMIN_PASSWORD}")"},"SupportEmail":"$(json_escape "${SUPPORT_EMAIL}")"}}
JSON
)"
unset JWT_SECRET JWT_ISSUER JWT_AUDIENCE EMAIL_HOST EMAIL_TOKEN
unset JWT_SECRET JWT_ISSUER JWT_AUDIENCE EMAIL_HOST EMAIL_TOKEN EMAIL_FROM
unset ADMIN_USERNAME ADMIN_EMAIL ADMIN_PASSWORD SUPPORT_EMAIL
else
echo "[setup-step10-creds] authblocks.json already exists, skipping"
@@ -0,0 +1,779 @@
# Phase 18 — Opus Low-Data Streaming (dual-format lossless + Opus delivery)
Product spec. Status: **design / framing — open questions RESOLVED (Daniel, 2026-06-23); implementation-ready.**
Author: product-designer. Date: 2026-06-23. **No code has been written by this doc.**
> **Resolution pass (Daniel, 2026-06-23).** OQ1OQ7 are resolved (see §6 — each marked RESOLVED, kept
> visible per file convention; OQ7 — seek-index granularity — set to **0.5 s buckets**). Two resolutions
> reshaped the spec materially: (a) the listener quality
> selection lives inside a **new public-site Settings menu surface** (not a bare app-bar control) — §4 +
> §4a; and (b) Daniel rejected the "approximate page-interpolation" seek hand-wave outright — **VBR-safe
> *accurate* seeking is now a first-class part of the architecture** (a precomputed seek-index artifact +
> a separately-available setup header). §3.4 is rewritten and a dedicated seek-model section (§3.4a)
> added. The Phase 21 cross-reference is updated to read "accurate index-based mapping," not
> "approximate."
This phase is the concrete realization of the long-deferred **"Non-WAV formats"** intent
(`CONTEXT.md §5`, the "1.2" the streaming-feature items reference). It supersedes the abstract "a
processor per format + a decoder strategy" framing with a specific, Daniel-directed product: **two
delivery formats per track — the existing lossless WAV path and a new low-data Ogg Opus path — so the
listener gets a choice, with Opus the bandwidth-friendly default-candidate.**
Surfaces (named precisely):
- **Ingest / preprocessing:** `DeepDrftContent` (`AudioProcessor` / `AudioProcessorRouter` /
`TrackContentService` / `WaveformProfileService`) + `DeepDrftAPI` (upload/persist —
`UnifiedTrackService.UploadAsync`, replace-audio) + `DeepDrftManager` (CMS upload form — the
**Post-Processing phase** on the existing upload progress meter, §3.1a).
- **Delivery / decode:** `DeepDrftAPI` (the track stream endpoint + `Range` handler + the new
**seek-index** and **setup-header** sidecar endpoints, §3.4a) + `DeepDrftPublic` proxy
(`TrackProxyController`) + `DeepDrftPublic.Client` player stack (`StreamingAudioPlayerService`,
`TrackMediaClient`) + `DeepDrftPublic/Interop/audio` TS decoders (`AudioPlayer.createFormatDecoder`
registry, a new `OpusFormatDecoder`).
- **Listener settings (NEW surface):** `DeepDrftPublic.Client` — a public-site **Settings menu** (app-bar
menu/popover) hosting the quality toggle as its first occupant, with a dark-mode-pattern persistence
seam (cookie → settings object → `PersistentComponentState` → client cookie service). §4a. The
prerender-cookie read lives in `DeepDrftPublic` (alongside `DarkModeService`).
**Sequencing headline: Phase 18 comes BEFORE Phase 21 (Windowed Streaming Buffer).** Phase 21's
windowing must work across both formats — its C5 invariant already anticipated this ("must not
foreclose MP3/FLAC"); Opus is now the concrete VBR/containerized driver of that invariant. See §6 and
the Phase 21 cross-reference.
---
## 0. State of the world (what already exists — verified 2026-06-23)
This phase is **much further along than the "Non-WAV formats" backlog line implies**, on both sides.
Two prior efforts already built most of the multi-format substrate; what is *missing* is specifically
the **derived-Opus-artifact** idea, not generic format support.
**Producer side is already multi-format (router landed):**
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)` routes by extension — `.wav`
`AudioProcessor`, `.mp3``Mp3AudioProcessor`, `.flac``FlacAudioProcessor`
(`DeepDrftContent/CLAUDE.md`).
- `TrackContentService.AddTrackAsync(filePath, mimeType)` is **format-agnostic**: it selects the
processor, generates an entry GUID, and **stores the original bytes** with correct extension/MIME
in the `tracks` vault.
- So today the system can *ingest and store* WAV/MP3/FLAC. It **does not transcode** — it keeps the
original. There is no derived artifact and no second format per track.
**Decoder side is a wired strategy registry (not "implemented-not-wired" anymore):**
- `AudioPlayer.createFormatDecoder(contentType)` (`AudioPlayer.ts:117`) dispatches on `Content-Type`:
`audio/mpeg|audio/mp3``Mp3FormatDecoder`, `audio/flac|audio/x-flac``FlacFormatDecoder`,
default → `WavFormatDecoder`. All three decoders exist and implement `IFormatDecoder`.
- `IFormatDecoder` (`IFormatDecoder.ts`) is a clean per-format strategy: `tryParseHeader`,
`getAlignedSegmentSize`, `wrapSegment`, `calculateByteOffset`, plus a `FormatInfo` carrying
`byteRate`, `blockAlign`, `audioDataOffset`, and a `seekData` accelerator slot (already polymorphic:
`Mp3VbrSeekData | FlacSeekData`). **This is the seam an `OpusFormatDecoder` slots into.**
- **Correction to the Phase 21 spec's §2 C3 note** ("MP3/FLAC implemented, not yet wired"): the
registry *is* wired and dispatches on content-type today. Phase 21's invariant still holds; the
parenthetical is stale and is corrected by this phase's reconciliation.
**What this means for the gap.** Daniel's direction is **not** "add format support" — that substrate
exists. It is "**derive a second, low-data artifact (Opus fullband 320) at ingest and let the listener
pick which to stream.**" That is two genuinely new things: (1) a **transcode-at-ingest** step that
produces a derived artifact per track (the router stores originals; nothing derives Opus), and (2) a
**per-format delivery selection** so the same track can be served as either WAV or Opus on request.
---
## 1. Goal
**Dual-format delivery.** Every track is streamable in two formats:
- **Lossless** — the existing WAV path, unchanged. The archival / audiophile option.
- **Low-data** — a derived **Ogg Opus, fullband, 320 kbps** artifact. The bandwidth-friendly
default-candidate.
The listener chooses; Opus is the recommended default. The bespoke Web Audio decode→schedule graph is
**retained by deliberate choice** (Daniel) — Opus is fed through the same `IFormatDecoder` strategy
seam, not through an HTML `<media>` element or MSE.
**Why Opus fullband 320.** Opus is the modern, royalty-free, best-in-class lossy codec; "fullband"
(48 kHz, full 20 kHz audio bandwidth) at 320 kbps is transparent-to-most-listeners quality at roughly
**1/4 to 1/5 the bytes of 16-bit/44.1 stereo WAV** (~1411 kbps). For a 1 GB DJ MIX (Phase 9 `Mix`
medium), that is the difference between a ~1 GB transfer and a ~220 MB transfer — the headline
low-data win, and directly relevant to the Phase 21 long-stream case.
**Non-goals.** This phase does not retire WAV (it stays as the lossless option), does not change the
bespoke graph for MSE (explicitly rejected — see §2 / Phase 21 OQ5), and does not add new transport
mechanisms beyond the existing stream + `Range` primitive.
---
## 2. Constraints / invariants (the contract that must hold)
- **C1 — Keep the bespoke Web Audio graph. MSE is rejected (Daniel, deliberate).** The custom
decode→schedule graph is a long-term commitment, not a stopgap. Opus is fed through the existing
`IFormatDecoder``StreamDecoder``PlaybackScheduler` pipeline. (This is the same decision
recorded as **Phase 21 OQ5 = NO**; the two phases share it.)
- **C2 — Preprocessing is additive; the WAV path is untouched.** The Opus artifact is a **second
derived artifact per track**, not a replacement. The existing WAV in the `tracks` vault stays
byte-for-byte as it is today; the lossless stream path is unchanged. A track with no Opus artifact
(legacy rows, or a transcode that hasn't run yet) must still play losslessly — Opus is strictly
additive.
- **C3 — Reuse the landed `Range`/offset seek path; do not fork it.** Phase 4's
`Range: bytes=X-``206` primitive (client `TrackMediaClient``DeepDrftPublic` proxy →
`DeepDrftAPI`) is the substrate for Opus seek too. Opus seek math differs from WAV (VBR /
container-paged, see §3.4) but it is expressed through the **same** `IFormatDecoder.calculateByteOffset`
seam the MP3/FLAC decoders already use — no second seek mechanism.
- **C4 — Opus slots the `IFormatDecoder` registry; no format branches leak elsewhere.** The new
`OpusFormatDecoder` is selected by `AudioPlayer.createFormatDecoder` on `Content-Type:
audio/ogg`/`audio/opus`. The rest of the player stack stays format-agnostic. No `if (opus)` outside
the decoder and the one selection point.
- **C5 — Format selection is a delivery-time decision, resolved server-side from a listener
signal.** The same `TrackEntity` / `EntryKey` addresses both artifacts; the *format* is a parameter
on the stream request (query param or `Accept` negotiation — see §3.3), not a different track id and
not a different vault entry key. One track, two renderings (the standing "one source, multiple
views" preference applied to delivery).
- **C6 — Transcode failure must not block ingest.** If the Opus transcode fails or is slow, the
track still persists with its lossless artifact and is playable. Opus is generated best-effort and
can be (re)generated later — mirror the **waveform-datum** model (`WaveformProfileService`: compute
on upload, regenerate on demand via a CMS action), which is exactly the "derived artifact, generated
at ingest, regenerable" pattern this needs.
- **C7 — The vault model holds: derived artifact is a new entry, not a mutation.** The Opus bytes
live in the FileDatabase under the track's `EntryKey` — either in the existing `tracks` vault under
a derived key, or in a new sibling vault (see §3.2 options). Either way it is `AudioBinary` with the
`.opus`/`.ogg` extension and correct MIME, registered like any other vault resource.
---
## 3. Architectural shape
### 3.0 The mental model
A track has one **source artifact** (the uploaded WAV/MP3/FLAC, stored as-is today) and gains one
**derived low-data artifact** (Ogg Opus fullband 320, produced at ingest). The stream endpoint serves
*either*, selected per request. The player picks a decoder by the response `Content-Type` exactly as
it does today. Seeking uses the same `Range` primitive; the byte↔time math is the decoder's job.
```
INGEST (DeepDrftContent + DeepDrftAPI)
upload → AudioProcessorRouter (existing) → store SOURCE artifact in vault [unchanged]
→ TRANSCODE to Opus 320 → store DERIVED artifact [NEW]
→ WaveformProfileService (existing, unchanged)
DELIVERY (DeepDrftAPI → DeepDrftPublic proxy → DeepDrftPublic.Client → Interop/audio)
GET api/track/{id}?format=opus|lossless → serve the chosen artifact's bytes (+ Range) [NEW param]
player: createFormatDecoder(Content-Type) → OpusFormatDecoder | Wav | Mp3 | Flac [+1 decoder]
```
### 3.1 Where the transcode lives (relative to existing processing)
The transcode is a **new processor sibling** to the existing format processors, invoked **after** the
source is stored, in the same orchestration that already calls `WaveformProfileService`:
- It belongs in `DeepDrftContent` (the binary-content domain library) as e.g. an
`OpusTranscodeService` / `OpusProcessor`, **not** in a host and **not** in a controller (per the
`*.Services`-owns-domain-logic convention).
- It is invoked from `UnifiedTrackService.UploadAsync` (the same place `WaveformProfileService`
computes the high-res datum on every new track) and from the **replace-audio** path (which already
regenerates both waveform datums — Opus is the third derived thing to regenerate there).
- Like the waveform datum, it gets a **regenerate trigger**: a CMS per-track / bulk action and an
ApiKey-gated endpoint, so existing tracks can be backfilled. This mirrors the landed
"Generate All Profiles / Backfill High-res" bulk actions on `Releases.razor` — **Backfill Opus**
is the natural third bulk action.
**The transcode engine itself is staff-engineer's call** (FFmpeg/libopus via a process invocation, a
managed binding, or a libopus P/Invoke). The spec fixes the *artifact* (Ogg Opus, fullband, 320 kbps)
and the *seam* (a derived artifact produced post-store, regenerable, failure-tolerant), not the tool.
Note a real operational constraint to flag for implementation: transcoding a 1 GB WAV is **CPU- and
time-expensive** and must not block the upload response — it wants the same off-the-hot-path treatment
the upload body staging already gets (`Upload:StagingPath`). This is the single biggest implementation
risk and is called out as such. The execution model is now **decided** (OQ6): **the source is stored and
the track is playable (lossless) first, then the Opus transcode runs as a background job** — see §3.1a
for the user-visible consequence on the upload UI.
### 3.1a Transcode execution model + the Post-Processing upload phase (RESOLVED — OQ6)
**Execution model (Daniel, 2026-06-23): background process *after* the file is available.** The upload
flow is now two distinct server-side stages with a hard ordering:
1. **Transfer + store + persist (existing, synchronous).** The WAV body streams in (the landed
`ProgressStreamContent` two-phase cancellation), the source is stored in the vault, the `TrackEntity`
is persisted, the waveform datums are computed. At the end of this stage **the track is fully playable
losslessly** — nothing about Opus gates a successful upload.
2. **Opus transcode (NEW, background, after stage 1 completes).** A queued/background job reads the
stored source, transcodes to Ogg Opus 320, builds the **seek index** and extracts the **setup header**
(§3.4a), and stores all three derived artifacts. Until it finishes, `?format=opus` for that track
falls back to lossless (C2). On failure the track stays lossless-only and is eligible for Backfill-Opus
(C6).
**The upload progress meter gains a visible Post-Processing phase.** The CMS upload forms
(`BatchUpload.razor` / `BatchEdit.razor`) already render a progress meter driven by `ProgressStreamContent`
(byte-transfer progress) and the two-phase cancellation (idle window during transfer, response-wait budget
after the body completes). The transcode is a **third visible phase** appended to that meter — after the
existing "uploading bytes" and "server is persisting" phases, a **Post-Processing** phase reflects the
background transcode's status (queued → transcoding → done / failed). This is an *addition* to the
existing meter, not a new UI.
- The admin sees: bytes transfer → server persists (track now exists + plays lossless) → **Post-Processing**
(Opus being derived). The form may complete/return the admin to the catalogue after stage 1 (the track
is live); the Post-Processing phase can continue to report against that track in the browse/release view
(the Opus waveform/profile columns on `Releases.razor` already poll-and-show per-track derived-artifact
status — Post-Processing status fits the same affordance family).
- **How status reaches the UI is staff-engineer's call** (poll the track's Opus-artifact presence, an SSE/
long-poll job channel, or a status field on the track read). The spec fixes that the phase is *visible*
and *non-blocking* — the admin is never made to wait on the transcode to consider the upload done.
- This composes with the **always-on** decision (OQ4): every upload triggers the background transcode;
there is no per-upload opt-out, so the Post-Processing phase always appears.
### 3.2 Where the Opus artifact is stored (two options)
**Option S1 — derived key in the existing `tracks` vault (recommended).** Store the Opus bytes under
a derived entry key alongside the source, e.g. `{entryKey}` for source and `{entryKey}.opus` (or a
parallel key convention) in the same `tracks` vault. *Pro:* no new vault type, co-located with the
source, simplest lookup. *Con:* mixes two artifacts per logical track in one vault's index.
**Option S2 — a new sibling vault (e.g. `track-opus`).** Mirror the `track-waveforms` precedent
(Phase 12 added a dedicated vault for the derived high-res datum). Opus bytes keyed by the same
`EntryKey` in a `track-opus` vault. *Pro:* clean separation of source vs. derived, matches the
established "derived artifacts get their own vault" pattern (`track-waveforms`), easy to enumerate /
backfill / purge independently. *Con:* one more vault to register.
**Recommendation: S2** — it is the pattern the codebase already chose for the *other* derived
per-track artifact (the high-res waveform datum), so it is the least surprising and keeps the source
`tracks` vault meaning exactly one thing. **Final call is staff-engineer's**; both are viable.
### 3.3 How a listener's format choice reaches the bytes
The stream endpoint gains a **format selector**. Two candidate mechanisms:
- **D-a — explicit query param** `GET api/track/{id}?format=opus|lossless` (recommended). Mirrors the
existing `offset` query param the proxy already forwards (`TrackProxyController`). Explicit,
cache-friendly (distinct URLs), trivial to thread through the proxy, and the player already knows
which it asked for. Server resolves the param → the right artifact → sets the right `Content-Type`,
which the player's existing `createFormatDecoder` then dispatches on. **No new decoder-selection
mechanism** — the response content-type does the work it already does.
- **D-b — HTTP content negotiation** (`Accept: audio/ogg` vs `audio/wav`). More "correct" REST, but
the proxy + WASM client wiring is fussier and caches are content-type-varied. Not worth it here.
**Recommended: D-a.** The selection *policy* (which format a given listener gets by default, and how
they switch) is a genuine **product call — see OQ1/OQ2**, deliberately not decided here. The
*mechanism* (a query param resolved server-side to an artifact + content-type) is settled.
Server-side fallback rule (C2): if `format=opus` is requested but no Opus artifact exists for that
track (not yet transcoded / backfilled), the endpoint **falls back to lossless** rather than 404ing —
Opus is additive, so its absence degrades to "you get the lossless one," never to "no audio."
### 3.4 The Opus decoder (the genuinely new decode work)
`OpusFormatDecoder implements IFormatDecoder` is the new code on the delivery side. **Ogg Opus is a
containerized, paged format — not raw-frame-sliceable** the way WAV PCM is. WAV's `wrapSegment` prepends a
44-byte PCM header to any PCM-aligned byte run; the current model assumes you can wrap an arbitrary aligned
raw-audio slice and hand it to `decodeAudioData`. Ogg Opus is page-structured (Ogg pages carrying Opus
packets, plus mandatory `OpusHead`/`OpusTags` **setup pages** at the very start). A mid-stream byte slice
is **not** independently decodable: it needs (1) the setup header prepended, and (2) to begin on an Ogg
**page boundary**. So:
- `OpusFormatDecoder.getAlignedSegmentSize` aligns to **Ogg page boundaries** — scan for the `OggS`
capture pattern (analogous to FLAC's frame-sync scan; the `IFormatDecoder` interface already passes
`rawData` to `getAlignedSegmentSize` for exactly this reason).
- `wrapSegment` / the continuation path **prepends the `OpusHead`/`OpusTags` setup bytes** to a mid-stream
page run before handing it to `decodeAudioData` (analogous to FLAC's `streamInfoBytes` carry in
`FlacSeekData`). The setup bytes come from the **setup-header mechanism** (§3.4a), not from re-reading
the stream start.
- A new `OpusSeekData` variant joins `Mp3VbrSeekData | FlacSeekData` in the `seekData` accelerator slot —
but for Opus it carries the **accurate seek index** (§3.4a), not a heuristic TOC.
**The `IFormatDecoder` abstraction already has the shape for both needs** — a format-specific `seekData`
accelerator and a setup-bytes carry — because FLAC needed the same kind of thing. The genuinely new part
is **where the seek index and setup header come from**, which §3.4a designs.
> **Seek is NOT approximate for Opus (Daniel, 2026-06-23 — supersedes the earlier hand-wave).** An earlier
> draft of this section proposed "granule-position/Ogg-page interpolation" — a best-effort approximate
> offset, the Opus analogue of MP3's Xing TOC. **That is rejected.** Daniel: *"Killing seeking for
> decoding is unacceptable… Raw bytes offset for seeking is no longer adequate due to VBR. We need an
> accurate transfer function for seek time → true file byte offset."* Opus seeking is **accurate**, backed
> by a precomputed index built at transcode time. See §3.4a.
**Browser decode-support constraint (real, must be designed around).** The bespoke graph decodes
segments via `AudioContext.decodeAudioData`. Ogg-Opus support in `decodeAudioData` is long-standing in
Chrome and Firefox but arrived in **Safari only at 18.4 (macOS 15.4 / iOS 18.4, March 2025)**; older
Safari decodes Opus only in a CAF container, not Ogg. iOS Safari is a primary music-listening surface,
so this is not a corner case. Implications: (1) the **lossless WAV path is the universal fallback** for
listeners whose browser can't decode Ogg Opus — which C2's additive design already provides for free;
(2) the format default is **capability-gated** (OQ2, RESOLVED) — don't hand Ogg Opus to a Safari that
can't decode it; detect support (a probe `decodeAudioData` on a tiny Opus blob, or a UA/version gate) and
fall back to lossless. This intersects Phase 1.7 (Safari compatibility) and is flagged there too.
([Browser support: caniuse / WebKit 18.4 release notes — see Sources.])
### 3.4a VBR-safe accurate seeking (the seek-index artifact + the setup-header mechanism)
This is the architectural core of the Opus delivery path, and it must compose with **Phase 21 windowed
refill** (where most of the stream is *not* in memory). The requirement, decomposed from Daniel's
direction:
1. Seeking must be preserved for Opus **without** having the full PCM decoded in memory.
2. Raw byte-offset seek is inadequate — a VBR Opus stream has **no linear time↔byte relationship**, so
`byteRate` math and even rough page interpolation are not accurate enough.
3. We need an **accurate transfer function: seek-time → true file byte offset.**
4. The decode setup header must be **available separately** (or cached before seeking past it), because a
mid-stream slice is undecodable without `OpusHead`/`OpusTags`.
**The key insight: the one moment we already walk the entire encoded stream is the transcode.** That is
precisely when an accurate index can be built for free. We never have to guess at delivery time — we read
the answer out of a precomputed artifact.
#### A. The seek-index artifact (the accurate transfer function)
At transcode time, after the Opus bytes are produced, **walk the encoded Ogg stream once and record, for
each Ogg page (or coarser bucket), the page's `granulepos` (a 48 kHz sample count → time) paired with its
**byte offset** in the file.** That granule→byte table *is* the exact transfer function. This is the Opus
analogue of FLAC's `SEEKTABLE` / MP3's Xing TOC — but **precomputed and exact**, not derived by
interpolation guessing. Ogg granule positions are authoritative sample counts, so the mapping is true, not
estimated.
- **What it contains.** An ordered list of `(timeSeconds | granulepos, byteOffset)` entries, plus the
total duration and total byte length (for clamping a seek to range). A binary little-endian array of
fixed-width records is the natural shape (e.g. a `uint64 granulepos` + `uint64 byteOffset` per entry);
the exact encoding is staff-engineer's, but it should be a **compact binary blob**, fetched once and
parsed into a typed array client-side.
- **Granularity vs. size — RESOLVED: 0.5 s (half-second) buckets (Daniel, 2026-06-23).** One entry per
Ogg page is the most precise but largest; an Ogg page is typically a few KB of audio (~tens of ms to a
few hundred ms), so a 1-hour mix could be tens of thousands of pages. The chosen bucket is **one index
entry per 0.5 seconds of audio** (snap each bucket boundary to the *nearest enclosing page start*, so
every indexed offset is still an exact page boundary). At 0.5 s granularity a 1-hour mix is
~7,200 entries × 16 bytes ≈ **~115 KB** — still a trivial one-time fetch, and 0.5 s seek resolution is
finer than required (the decoder re-syncs to the exact page within the bucket anyway — see the client
flow — so the in-bucket trim is *sub-half-second*, tighter than the earlier ~12 s recommendation).
**Per-page precision remains the fallback if 0.5 s buckets ever prove too coarse**, at a larger index.
The bucket size is now fixed; the *shape* (precomputed exact granule→byte, bucketed, snapped to page
starts) is unchanged.
- **Sidecar, not embedded (recommended).** Store the index as a **third derived artifact** alongside the
Opus bytes and the waveform datum — the same "derived artifacts get their own vault" pattern this phase
already uses (S2 / `track-opus`; the `track-waveforms` precedent). Keep it a separate vault resource
(e.g. `{entryKey}.seekidx` in a `track-opus` vault, or its own `track-opus-index` vault) rather than
embedding it in the Ogg stream. *Why sidecar:* it is fetched **once, up front** (small, cacheable),
independent of the audio byte stream; embedding it in the Ogg would force the client to read into the
stream to find it, defeating the "resolve the offset *before* the Range fetch" flow. *Road not taken —
derive the index lazily on first seek by scanning server-side:* rejected, because it re-walks the stream
at request time (the cost we avoid by computing at transcode) and gives nothing the precomputed sidecar
doesn't.
#### B. The setup-header mechanism (decodability of any mid-stream slice)
Any post-seek slice needs `OpusHead` + `OpusTags` prepended to decode. Two ways to make those bytes
available to the client:
- **B-a — Client-side caching of the leading setup pages on first read (recommended).** On first play, the
stream already begins at byte 0, so the client *already receives* the `OpusHead`/`OpusTags` pages as the
opening bytes. `OpusFormatDecoder.tryParseHeader` captures and **retains** those setup bytes (exactly as
`WavFormatDecoder` retains the parsed WAV header for `reinitializeForRangeContinuation` today, and FLAC
retains `streamInfoBytes`). Every subsequent post-seek continuation prepends the cached setup bytes. *No
new endpoint;* it reuses the header-retention discipline already in the codebase.
- **B-b — A dedicated setup-header sidecar endpoint** (`GET api/track/{id}/opus/header` → just the
`OpusHead`/`OpusTags` bytes, also derivable at transcode time and stored as a tiny artifact). *Pro:* a
seek can be served even if the listener seeks **before** the stream start has been read (e.g. a deep-link
that begins mid-track, or a Phase 21 window that opens away from byte 0). *Con:* one more endpoint +
artifact.
**Recommendation: B-a as the primary, B-b as a cheap insurance artifact.** B-a covers the overwhelming
common case (play-then-seek) with **zero new surface** — it is the WAV-header-retention pattern applied to
Opus. But Phase 21 windowing and deep-links can legitimately open a window that never read byte 0, so the
setup header should **also** be derivable on demand. Cheapest reconciliation: **extract the setup bytes at
transcode time and store them as a tiny sidecar artifact** (they are a few hundred bytes), and expose them
**either** as a small endpoint **or** simply prepend them to the seek-index sidecar's header region so the
single up-front index fetch *also* delivers the setup bytes. The latter folds B-b into the B-a fetch: **the
client's one up-front sidecar fetch returns both the seek index and the setup header**, so it always has
both before it ever issues a seek — and never needs byte 0 to have been read. **Recommended concrete
design: one sidecar per track = `[setup-header bytes][seek-index table]`, fetched once on track load,
parsed into `OpusSeekData`.** This is the cleanest: one new artifact, one new fetch, both needs met.
#### C. The client-side seek flow, end to end
With the sidecar (`OpusSeekData` = setup header + granule→byte index) fetched and parsed at track load:
1. **Resolve time → byte offset (accurate).** Listener seeks to `t` seconds. `OpusFormatDecoder.calculateByteOffset(t)`
does a binary search in the index for the largest entry with `time ≤ t`, returns its exact (page-start)
`byteOffset`. **No interpolation, no `byteRate` math.** (For WAV this method stays the exact CBR
calculation it is today — the seam is identical; only the Opus implementation reads an index.)
2. **Range fetch from the offset.** Issue `GET api/track/{id}?format=opus` with `Range: bytes={byteOffset}-`
— the **landed Phase 4 Range primitive, unchanged**. Server streams raw Opus bytes from that exact page
boundary (`206 Partial Content`).
3. **Prepend the cached setup header + decode.** The continuation path (the Opus analogue of
`StreamDecoder.reinitializeForRangeContinuation`) prepends the retained/sidecar `OpusHead`/`OpusTags`
bytes to the incoming page run, then feeds it to `decodeAudioData`. Because the index offset is an exact
page start, the stream is immediately Ogg-sync-aligned.
4. **Fine re-sync within the bucket.** The granule of the first decoded page tells the decoder the *exact*
time it landed at (≤ the bucket granularity ahead of `t`); the scheduler trims/positions to land
playback at `t` precisely. With 0.5 s buckets the trim is sub-half-second; with per-page granularity it
is near-zero. **Either way the listener lands at the correct time, not approximately** (AC9).
#### D. Composition with Phase 21 windowed refill
Phase 21's windowed refill controller resolves "I need bytes for playback position `P`" → a byte offset →
a Range fetch. **It calls the *same* `OpusFormatDecoder.calculateByteOffset` (the index-based resolver)
for Opus** that an explicit seek does — windowed refill is just a seek the listener didn't initiate. So the
seek index serves both: explicit seeks and the window's low-water refills both resolve through the index,
and both prepend the cached setup header. This is why §3.4a is in **Phase 18** (where the transcode that
builds the index lives), and Phase 21 *consumes* it. The Phase 21 spec's "approximate mapping" language for
Opus is now wrong and is corrected to **"accurate index-based mapping."**
#### E. Reuse vs. extend (the seam discipline)
- **Reused verbatim:** the Phase 4 `Range: bytes=X-` → 206 primitive (client → proxy → API); the
`IFormatDecoder.calculateByteOffset` seam; the header-retention/continuation discipline
(`reinitializeForRangeContinuation`'s Opus analogue); the derived-artifact-in-its-own-vault pattern
(`track-waveforms``track-opus`); the derive-at-transcode-regenerate-on-backfill lifecycle.
- **Extended (new):** the seek-index + setup-header **sidecar artifact** (built at transcode, stored
beside the Opus bytes); the one-time **sidecar fetch** on track load (parsed into `OpusSeekData`); the
index **binary-search resolver** inside `OpusFormatDecoder`. Three additions, all leaf-level — no change
to the Range mechanism, the proxy, or the format-agnostic player.
### 3.5 The three candidate directions (shape-level)
Per file convention the alternatives are recorded; the recommendation follows.
**Direction A — Derived Opus artifact at ingest + format param on delivery (recommended).** What §3.1
3.4a describe: transcode to Opus 320 post-store as a **background job** (OQ6), store as derived artifacts
(S2 vault) — the Opus bytes **plus the seek-index/setup-header sidecar** (§3.4a) — serve via a `?format=`
param resolved server-side to bytes + content-type, decode via a new `OpusFormatDecoder` in the existing
registry, **seek accurately via the precomputed index**. *Why recommended:* additive (C2), reuses every
existing seam (the processor orchestration, the waveform-datum derived-artifact pattern, the `Range` path,
the decoder registry, the header-retention discipline), and the only genuinely new code is one transcode
step (+ index build) + one decoder (+ index resolver). **Three** derived artifacts per track (Opus bytes,
seek sidecar, and the existing waveform datum), all regenerable.
**Direction B — On-the-fly transcode at delivery (no stored Opus artifact).** Transcode WAV→Opus per
request in the stream endpoint, streaming the Opus out as it encodes. *Why not (default):* moves
expensive CPU onto the **hot request path** (a 1 GB mix transcoded per play is untenable), breaks
`Range`/seek (you can't byte-offset into a stream you're encoding live), and defeats caching. It *is*
storage-cheaper (no second artifact on disk), so it is the fallback only if disk cost ever dominates —
but for a music site where the same tracks are played repeatedly, precompute-once wins decisively.
Rejected as the primary.
**Direction C — Replace WAV ingest with Opus-only (transcode and discard the lossless source).** Make
Opus *the* stored format; drop WAV. *Why not:* violates Daniel's explicit "lossless streaming
*optional* — two delivery formats, listener gets a choice." Lossless is a kept option, not a thing to
transcode away. Also irreversibly lossy at ingest (you can never recover the WAV). Rejected outright;
recorded only because "just store Opus" is the tempting simplification and the spec should say why not.
### 3.6 SOLID / road-not-taken rationale
- **OCP, via the existing seams.** The transcode is a new processor sibling (the router pattern is
already open for extension); the decoder is a new `IFormatDecoder` (the registry is already open for
extension); the artifact is a new derived vault resource (the `track-waveforms` precedent is exactly
this). Phase 18 adds **three new leaf implementations** and **zero changes to existing format code**
— the strongest possible OCP signal that the seams were designed right.
- **SRP, preserved.** Transcoding **and the seek-index build** are content-domain processor concerns
(`DeepDrftContent`); delivery selection is a thin endpoint concern (`DeepDrftAPI` resolves a param to an
artifact, and serves the sidecar); decode is the `OpusFormatDecoder`'s concern; byte↔time math stays
inside that decoder via `calculateByteOffset` (now reading the index, not interpolating). No
responsibility crosses a boundary it doesn't already own. The seek index is built **once, where the
stream is already walked** (transcode) — the natural home for an exact transfer function, never
recomputed at request time.
- **DIP / "one source, multiple views."** One `TrackEntity`/`EntryKey` is the single source; "lossless
WAV" and "low-data Opus" are two *views* (renderings) of it, diverging only at the delivery/decode
layer — the same discipline the dark-mode and track-browse surfaces follow.
- **Road not taken — a separate `TrackEntity` row (or a new track id) per format.** Tempting (one row
= one streamable file) but it fractures the track identity: shares, queues, play-counts (Phase 16),
release membership, and waveform data all key on one track, and doubling rows to carry a format
would force every one of those surfaces to dedupe. Format is a *delivery attribute of one track*,
not a *second track*. Rejected — keep one identity, two artifacts.
---
## 4. Format selection — the product surface (RESOLVED — global, via a Settings menu)
**Resolved (Daniel, 2026-06-23):** the listener's quality choice is **global** (one session/visitor-level
"streaming quality" preference, not per-track), Opus is the **default** (capability-gated), and the choice
is **remembered** following the dark-mode persistence pattern. Crucially: *"Global is perfect, but we need
a menu system for settings, don't just slap the quality control directly in the app bar."* So the toggle
does **not** sit bare in the app bar — it lives inside a proper **public-site Settings menu** (§4a), of
which it is the **first occupant**.
- **What the listener sees.** A Settings affordance in the public app bar opens a Settings menu; inside it,
a "Streaming quality" control with two options — **Low-data (Opus)** / **Lossless (WAV)** — defaulting to
Low-data. Picking lossless flips the global preference; the player sends the matching `?format=` on
subsequent stream requests (§3.3). On a browser that can't decode Ogg Opus, the control is shown but the
effective stream is lossless (capability gate, §3.4 / OQ2) — surface this honestly rather than letting
the listener pick a format that silently can't play.
- **Default before any choice:** Opus, capability-gated (OQ2 RESOLVED). A first-time visitor on a capable
browser streams Opus; on an incapable browser, lossless.
- **Persistence:** mirror the dark-mode seam exactly (OQ3 RESOLVED) — see §4a.
### 4a. The Settings menu surface (NEW — scoping + the dark-mode persistence pattern)
Daniel asked for a **menu system for settings**, not a control bolted onto the app bar, and noted the
existing **dark-mode toggle** is a natural future tenant of the same menu (design for adaptability — build
the menu so dark mode *could* move into it later, but **do not force that migration now**).
**Scoping recommendation: a small sub-track *within* Phase 18 (wave 18.6), not its own phase.** Reasoning:
- The menu's only **required** occupant right now is the quality toggle, which Phase 18 owns end to end —
splitting the shell into a separate phase would create a phase whose sole deliverable is an empty menu
waiting for Phase 18's toggle. That is ceremony, not separation of concerns.
- The menu is **small** — an app-bar trigger + a MudBlazor menu/popover + the persistence seam (which the
quality toggle needs *anyway*). It is not a platform; it is a container with one tenant.
- It carries a real **design-for-adaptability** obligation (it must be able to host dark mode and future
settings later), but that is a *shape* requirement on a small surface, not a phase's worth of work.
So: **build the Settings-menu shell as part of Phase 18 (wave 18.6), with the quality toggle as its first
occupant, designed so dark mode and future preferences can plug in without restructuring.** Flag for
Daniel: *if he wants the menu shell proven/landed independently before the quality toggle plugs in*, 18.6
can be split into "menu shell" then "quality toggle plugs in" — but they are small enough to land together.
This is **not** recommended as its own top-level phase. (If Daniel disagrees and wants a dedicated
"Public Settings Menu" phase that Phase 18's toggle then targets, that is a clean alternative — it just
front-loads a surface with no second tenant yet. Recommendation stands: sub-track.)
**The menu shell — design-for-adaptability requirements (so it survives new tenants):**
- A **settings-item abstraction**, not a hard-coded list. The menu renders a small set of settings entries;
adding dark mode later is adding an entry, not rewiring the menu. Each entry is a label + a control bound
to a persisted preference.
- A **single public-site settings object** carrying all listener preferences (today: streaming quality;
tomorrow: dark mode, and whatever follows). This is the `DarkModeSettings` analogue, generalized — call
it e.g. `PublicSiteSettings` / `ListenerSettings`. Dark mode's existing `DarkModeSettings` can fold into
it *later* without disturbing the menu.
**Persistence — mirror the dark-mode seam exactly (OQ3 RESOLVED).** The quality preference follows the
*identical* path dark mode already uses (root `CLAUDE.md` "Theming and dark mode"):
1. **Cookie** — a `streamQuality` cookie (365-day, like `darkMode`), the durable truth.
2. **Server prerender read** — a service in `DeepDrftPublic` (sibling to `DarkModeService`) reads the
cookie during prerender and seeds the settings object, avoiding a wrong-default flash on first paint
(the streaming-quality analogue of the "wrong theme flash" fix).
3. **`PersistentComponentState` bridge** — the seeded preference carries from server prerender into the
WASM render (the same bridge `DarkModeSettings` and `NowPlayingStats`/`StatsClient` already use), so the
client boots already knowing the quality without a re-read flash or a re-fetch.
4. **Client cookie service** — a runtime client-side service (JS-interop cookie write, like the dark-mode
toggle) persists the choice when the listener changes it in the menu.
**Why mirror rather than invent:** the dark-mode seam is the codebase's established, working pattern for "a
listener preference seeded at prerender, carried to WASM, persisted in a cookie." Reusing its shape means
the quality preference inherits the no-flash guarantee for free, and the eventual dark-mode-into-the-menu
migration is a *consolidation of two identical seams*, not a reconciliation of two different ones. (This is
the "one source, multiple views" / design-for-adaptability discipline applied to listener settings.)
---
## 5. Use cases
- **UC1 — Listener streams the low-data Opus of a long mix (the headline win).** A ~1 GB lossless mix
transfers as ~220 MB of Opus; playback through the bespoke graph is identical in feel, far cheaper
on bandwidth. (Compounds with Phase 21 windowing for the memory side.)
- **UC2 — Listener prefers lossless and switches to it.** The same track served as WAV via
`?format=lossless`; the bespoke graph decodes it exactly as today.
- **UC3 — Legacy / not-yet-transcoded track.** `?format=opus` requested, no Opus artifact yet →
server falls back to lossless (C2); the listener still hears the track. A later Backfill-Opus pass
produces the artifact.
- **UC4 — Admin backfills Opus for the existing catalogue.** A bulk "Backfill Opus" CMS action (the
third sibling to the existing Generate-Profiles / Backfill-High-res actions) transcodes every track
lacking an Opus artifact.
- **UC5 — Replace-audio regenerates Opus.** The existing replace-audio path (which already regenerates
both waveform datums and re-derives duration) also regenerates the Opus artifact from the new
source.
- **UC6 — Seek within an Opus stream (accurately).** Backward/forward seek resolves via the existing
`Range` path; the offset comes from the `OpusFormatDecoder`'s **precomputed seek index** (§3.4a) — an
exact granule→byte lookup, then fine re-sync to the requested time within the bucket. The listener lands
at the **correct** time, not approximately, and without the full PCM decoded in memory.
- **UC7 — Safari that can't decode Ogg Opus.** Capability-gated to the lossless path (§3.4), so the
listener still plays audio. (Ties to OQ2 + Phase 1.7.)
- **UC8 — Listener switches streaming quality in the Settings menu.** The listener opens the public
Settings menu, flips "Streaming quality" from Low-data to Lossless (or back); the choice persists
(cookie, dark-mode pattern) and applies to subsequent stream requests via `?format=`. On next visit the
preference is seeded at prerender (no flash, no re-pick). (§4 / §4a.)
- **UC9 — Deep-link / windowed start away from byte 0.** A listener opens a stream at a mid-track position
(deep link, or a Phase 21 window that opens past byte 0) without ever reading the stream start. The
decoder still has the `OpusHead`/`OpusTags` setup bytes because they arrived with the up-front sidecar
fetch (§3.4a B), so the mid-stream slice is decodable immediately. (Composition case for Phase 21.)
---
## 6. Open questions — RESOLVED (Daniel, 2026-06-23)
All seven open questions are resolved. Kept visible per file convention, each with the decision and
the section that now carries it. OQ7 (raised by the seek-model design) is a narrow tuning call, now set to
0.5 s buckets.
- **OQ1 — Selection UX — RESOLVED: global, via a Settings *menu* (not a bare app-bar control).** Daniel:
*"Global is perfect, but we need a menu system for settings, don't just slap the quality control directly
in the app bar."* So: one global quality preference, surfaced inside a new **public-site Settings menu**
(§4 / §4a), of which the quality toggle is the first occupant. The menu is scoped as a **Phase 18
sub-track (wave 18.6)**, designed so dark mode (its natural future tenant) can plug in later. `[RESOLVED
— §4 / §4a]`
- **OQ2 — Default policy — RESOLVED: Opus by default, capability-gated.** Opus is the default; on a browser
that cannot decode Ogg Opus (Safari < 18.4, §3.4), fall back to lossless rather than serving an
undecodable stream. Network-awareness (Opus on cellular / lossless on wifi) remains **deferred** as
gold-plating. `[RESOLVED — §3.4, §4]`
- **OQ3 — Remembered choice — RESOLVED: persisted, following the dark-mode pattern.** A `streamQuality`
cookie seeded at server prerender → settings object → `PersistentComponentState` bridge into WASM →
client cookie service for runtime writes. The full dark-mode seam mirrored (§4a). `[RESOLVED — §4a]`
- **OQ4 — Per-upload Opus control — RESOLVED: always-on + backfill.** Opus is generated for **every**
track, always (no per-upload opt-out). **Plus** a bulk **Backfill-Opus** CMS action processes the
existing catalogue. (The listener's lossless choice already covers "I want lossless," so a per-track
opt-out earns nothing.) `[RESOLVED — §3.1, UC4, wave 18.5]`
- **OQ5 — Container — RESOLVED: Ogg Opus.** `.opus` / `audio/ogg` (broadest `decodeAudioData` support). No
CAF/WebM fallback — the lossless path already covers browsers that can't decode Ogg Opus (§3.4).
`[RESOLVED — §3.4]`
- **OQ6 — Transcode execution model — RESOLVED: background job after the file is available; uploader shows
a Post-Processing phase.** The source is stored and the track is playable losslessly **first**; the Opus
transcode (+ seek-index build) runs as a **background job** afterward; the CMS upload progress meter
gains a visible **Post-Processing** phase reflecting the transcode status (§3.1a). A freshly uploaded
track is lossless-only until its Opus finishes — accepted, and now made visible rather than implicit.
`[RESOLVED — §3.1a]`
**New open question raised by the seek-model design (§3.4a) — RESOLVED:**
- **OQ7 — Seek-index granularity — RESOLVED: 0.5 s (half-second) buckets (Daniel, 2026-06-23).** The seek
index trades precision against size: per-Ogg-page (most precise, largest) vs. coarser time buckets snapped
to page starts. Daniel set the bucket at **0.5 s** (finer than the ~12 s the spec had recommended):
~7,200 entries × 16 bytes ≈ **~115 KB** for a 1-hour mix — still a trivial one-time fetch. The decoder
fine-re-syncs within the bucket so seek *accuracy* is unaffected; at 0.5 s the in-bucket trim is
sub-half-second, tighter than before. The shape (precomputed exact granule→byte, page-snapped) is
unchanged. `[RESOLVED — §3.4a A]`
---
## 7. Acceptance criteria
- **AC1 (headline) — Dual-format delivery works.** A track can be streamed as either lossless WAV or
Ogg Opus 320 from the same `EntryKey`, selected per request; both play correctly through the bespoke
Web Audio graph.
- **AC2 — Opus is the low-data win.** The Opus artifact of a representative track is materially smaller
than its lossless source (target ~1/41/5 the bytes); a long mix's Opus transfer is correspondingly
smaller.
- **AC3 — Additive, non-breaking (C2).** The existing lossless WAV path is byte-for-byte unchanged; a
track with no Opus artifact still plays losslessly; `?format=opus` on such a track falls back to
lossless (no 404, no silence).
- **AC4 — Transcode at ingest as a background job, regenerable (C6, OQ6).** A new upload stores the source
and is playable losslessly **immediately**; the Opus artifact (+ seek-index/setup-header sidecar) is
produced by a **background job** afterward; a transcode failure does not block the upload or break
playback; a Backfill-Opus action (re)generates artifacts for existing tracks; replace-audio regenerates
the Opus artifact and its sidecar from the new source.
- **AC4a — Post-Processing phase is visible on the upload meter (OQ6, §3.1a).** After the byte-transfer and
server-persist phases, the CMS upload progress UI shows a **Post-Processing** phase reflecting the
background transcode (queued → transcoding → done/failed). The admin is never blocked waiting on the
transcode; the track is live before Post-Processing finishes.
- **AC5 — Opus seek via the existing `Range` path (C3).** Forward and backward seek in an Opus stream
resolve through the landed `Range: bytes=X-` primitive, with the offset coming from
`OpusFormatDecoder.calculateByteOffset`; no new seek *transport* mechanism is introduced.
- **AC5a — Seek-index + setup-header sidecar exists and is fetched once (§3.4a).** Every track with an Opus
artifact has a sidecar carrying the setup header (`OpusHead`/`OpusTags`) and the granule→byte seek index;
the client fetches and parses it once on track load (into `OpusSeekData`) before issuing any seek.
- **AC9 (the seek-accuracy criterion) — an Opus seek lands at the *correct* time, not approximately.**
Seeking to time `t` in an Opus stream resolves via the precomputed index and lands playback at `t`
(within the fine-resync tolerance — sub-half-second at the chosen 0.5 s bucket granularity), **measurably
accurate**, not a `byteRate`/interpolation estimate. Verifiable: seek to a known marker (e.g. a downbeat
at a known timestamp) and confirm playback resumes there, not seconds off. This holds **without** the
full PCM decoded in memory (composes with Phase 21).
- **AC6 — No format branches leak (C4).** The only Opus-specific code is `OpusFormatDecoder`, its
`OpusSeekData` (carrying the index), the one `createFormatDecoder` selection arm, the transcode processor
(+ index build), the sidecar artifact + its serving, and the delivery param resolution. The
format-agnostic player/scheduler code is unchanged.
- **AC7 — Capability-safe default (OQ2).** A browser that cannot decode Ogg Opus is served (or falls
back to) the lossless path and plays audio; no listener gets silence because of codec support.
- **AC8 — Windowing-ready (the Phase 21 handshake).** The `OpusFormatDecoder`'s **index-based** byte↔time
resolver is the one Phase 21's windowed refill calls; Opus playback must be windowable by the same
machinery, and a windowed refill that opens away from byte 0 still decodes (setup header from the
sidecar — UC9). Verified jointly when Phase 21 lands on top (see §8 / Phase 21 cross-ref).
- **AC10 — The Settings menu hosts the quality toggle and persists the choice (§4 / §4a).** The public app
bar opens a Settings menu containing a "Streaming quality" control (Low-data / Lossless, defaulting to
Low-data, capability-gated); changing it persists via the `streamQuality` cookie and is seeded at
prerender on the next visit (no flash). The menu shell is built so a future dark-mode entry can plug in
without restructuring.
---
## 8. Wave decomposition
Dependency shape: `18.1 → 18.2 → {18.3, 18.4}`, with `18.5` (backfill + e2e) and `18.6` (settings menu)
on top. **18.1 (the transcode + seek-index/setup-header derived artifacts) is the cold-start
prerequisite** — until those artifacts exist, nothing downstream has bytes to serve, decode, or seek
against. 18.3 (delivery param) and 18.4 (the decoder + index resolver) are largely parallel once 18.2
(storage/lookup) settles, but both need artifacts to test against. **18.6 (the Settings menu) is the only
wave with no audio-pipeline dependency** — it can proceed in parallel with the whole stack; it merely needs
the `?format=` mechanism (18.3) wired before the toggle has anything to drive.
- **18.1 — Ingest transcode + seek-index + setup-header (cold-start; load-bearing).** New
`OpusTranscodeService`/processor in `DeepDrftContent`, invoked post-store from
`UnifiedTrackService.UploadAsync` alongside `WaveformProfileService`, **as a background job** (OQ6,
§3.1a); produces Ogg Opus fullband 320; **walks the encoded stream once to build the granule→byte seek
index and extract the `OpusHead`/`OpusTags` setup header** (§3.4a A/B); stores the Opus bytes **and** the
combined seek/setup **sidecar** as derived artifacts (S2 vault recommended). Failure-tolerant (C6).
**Independent of the delivery/decoder waves; can begin immediately.**
- **18.2 — Storage + lookup contract.** The derived-artifact key/vault convention (Opus bytes + sidecar)
and the server-side resolution "given `EntryKey` + format, return the right `AudioBinary` + content-type
(+ the sidecar on its own endpoint/path)," including the C2 fallback (no Opus → lossless). **Depends on
18.1** (artifacts must exist to resolve to).
- **18.3 — Delivery: format param + sidecar serving + proxy threading.** `?format=opus|lossless` on the
`DeepDrftAPI` track stream endpoint (resolves via 18.2), forwarded through the `DeepDrftPublic`
`TrackProxyController` (mirror the existing `offset` param threading), and the `Range` handler serving
the chosen artifact's bytes; **plus serving the seek/setup sidecar** (a `GET …/opus/seekdata`-style path,
proxied the same way). The player sends the format param via `TrackMediaClient`. **Depends on 18.2.**
Parallel-ok with 18.4.
- **18.4 — `OpusFormatDecoder` + the index-based seek resolver in the player stack.** New `IFormatDecoder`
implementation: Ogg-page-aligned `getAlignedSegmentSize` via `OggS` scan; `OpusHead`/`OpusTags` setup
carry in `wrapSegment`/the continuation path (sourced from the cached sidecar, §3.4a B); **`calculateByteOffset`
that binary-searches the precomputed seek index** (NOT interpolation), with an `OpusSeekData` accelerator
holding the parsed index + setup bytes; the **one-time sidecar fetch + parse** on track load. One new arm
in `AudioPlayer.createFormatDecoder` on `audio/ogg`/`audio/opus`. Capability detection for the lossless
fallback (§3.4, OQ2). **Depends on 18.2** (needs Opus bytes + sidecar). Parallel-ok with 18.3; they meet
at 18.5.
- **18.5 — Backfill + replace-audio + end-to-end validation (incl. seek accuracy).** The Backfill-Opus CMS
bulk action (third sibling to Generate-Profiles / Backfill-High-res), which (re)builds Opus bytes + the
sidecar for existing tracks; replace-audio Opus + sidecar regeneration; and the AC1AC10 acceptance pass
**including AC9 (an Opus seek lands at the correct time, not approximately)** and AC8's confirmation
that Opus is windowable (index resolver + sidecar setup header) so Phase 21 can build on it. **Depends on
18.118.4.**
- **18.6 — Public Settings menu + the quality toggle (the listener selection UX).** The new public-site
Settings-menu shell (§4a): an app-bar trigger + MudBlazor menu hosting a settings-item abstraction, the
`PublicSiteSettings`/`ListenerSettings` object, and the dark-mode-pattern persistence seam (`streamQuality`
cookie + a `DeepDrftPublic` prerender-read service + `PersistentComponentState` bridge + client cookie
service). The **quality toggle is its first occupant** (Low-data/Lossless, Opus default, capability-gated),
driving the `?format=` the player sends (needs 18.3). Built design-for-adaptability so dark mode can plug
in later without restructuring (not migrated now). **Depends on 18.3** (the toggle needs the format
mechanism); the menu *shell* can be built ahead of that. *Splittable* into "menu shell" + "toggle plugs
in" if Daniel wants the shell proven first — but small enough to land together (§4a).
---
## 9. Cross-references (read before implementing)
- `CONTEXT.md §5` "Non-WAV formats" — the deferred intent this phase realizes (now concrete: derived
Opus low-data path, not generic format support).
- `PLAN.md` Phase 21 / `product-notes/phase-21-windowed-streaming-buffer.md` — **sequenced AFTER this
phase.** Phase 21's C5 invariant ("WAV-only shipping target; must not foreclose MP3/FLAC") is now
driven by Opus's VBR/paged seek math; Phase 21 OQ5 (adopt MSE) is resolved **NO** — the bespoke
graph stays (the same C1 decision recorded here). Windowing a VBR/Opus stream uses
`OpusFormatDecoder.calculateByteOffset`'s **accurate index-based mapping** (§3.4a — *not* the earlier
"approximate page-interpolation"; that language in the Phase 21 doc is corrected). Phase 21's windowed
refill calls the **same** index resolver an explicit seek does (§3.4a D), and a window that opens away
from byte 0 still decodes via the sidecar setup header (UC9).
- `PLAN.md` Phase 4 (landed) / `COMPLETED.md` — the HTTP `Range: bytes=X-` primitive Opus seek reuses.
- `PLAN.md` Phase 1.5 (gapless) / 1.6 (track-skip on error) / 1.7 (Safari) — 1.5's "encoder
padding/priming" caveat applies to Opus (it has pre-skip samples in `OpusHead`); 1.6's
byte-scan-to-next-frame is the Ogg-page-sync analogue; 1.7's Safari floor intersects §3.4's Ogg-Opus
`decodeAudioData` support (Safari < 18.4).
- `PLAN.md` Phase 12 / `product-notes/phase-12-waveform-visualizer-generalization.md` — the
`WaveformProfileService` derived-artifact-at-ingest + regenerate pattern this transcode mirrors
(compute on upload, regenerate via CMS action / endpoint, its own `track-waveforms` vault → the S2
precedent).
- `PLAN.md` Phase 9 — defines the `Mix` medium (single long track), the canonical low-data case.
- `PLAN.md` Phase 16 — play/share telemetry keys on one track identity; the §3.6 road-not-taken
(one-row-per-format) would have fractured this — kept to one identity, two artifacts.
- `DeepDrftContent/Processors/AudioProcessor.cs` + `AudioProcessorRouter` + `DeepDrftContent/CLAUDE.md`
— the existing format-router and the `WaveformProfileService` derived-artifact seam; 18.1 lives here.
- `DeepDrftPublic/Interop/audio/IFormatDecoder.ts` — the strategy interface `OpusFormatDecoder`
implements; `FlacFormatDecoder.ts` is the nearest prior art (setup-bytes carry + frame-sync scan).
- `DeepDrftPublic/Interop/audio/AudioPlayer.ts` (`createFormatDecoder`, lines 117125) — the decoder
registry gaining the Opus arm.
- `DeepDrftPublic.Client/Clients/TrackMediaClient.cs` + `DeepDrftPublic/Controllers/TrackProxyController.cs`
— the media fetch + proxy that thread the new `?format=` param (mirroring `offset`), and proxy the new
seek/setup sidecar fetch.
- Root `CLAUDE.md` "Theming and dark mode" + `DarkModeService` (in `DeepDrftPublic`) + `DarkModeSettings`
(`DeepDrftPublic.Client.Common`) — the cookie → prerender-read → `PersistentComponentState` → client
cookie-service seam the **streaming-quality preference** (§4a) mirrors exactly; the eventual dark-mode-
into-the-Settings-menu migration consolidates two copies of this seam.
- `DeepDrftPublic.Client` `NowPlayingStats.razor` / `StatsClient` — the `PersistentComponentState`
prerender-bridge precedent (prerender fetch carried into WASM without a re-fetch/flash), the pattern the
quality preference's bridge follows; see the `tracksview-persistent-state-seam` auto-memory.
## Sources
- Ogg Opus support in `decodeAudioData`: Chrome/Firefox long-standing; Safari added Ogg-Opus at 18.4
(macOS 15.4 / iOS 18.4, March 2025) — prior Safari decoded Opus only in CAF.
https://chromestatus.com/feature/5649634416394240 ;
https://www.testmuai.com/learning-hub/opus-audio-codec-browser-support/
+323
View File
@@ -0,0 +1,323 @@
# Phase 20 — Theater Mode (public Release Detail views)
Product spec. Status: **landed and merged to dev — 2026-06-20; Wave 2 refinements landed 2026-06-21.** All §9 open questions resolved at sign-off 2026-06-20.
Surface: **public listener site only** (`DeepDrftPublic` / `DeepDrftPublic.Client`). No CMS
(`DeepDrftManager`) change. No API, data, or schema change — Theater Mode is a pure
presentation-layer feature riding data the player already carries.
**Wave 2 refinements (landed 2026-06-21):** Three post-ship improvements. (1) *Full-screen detail body* — each detail page's foreground container gained `.dd-detail-fill` so the visualizer reads full-screen and the footer is pushed below the fold regardless of Theater Mode. (2) *Eased collapse* — the hard `@if` content-hide on all three detail pages and the player-bar `NowShowingPanel` was replaced by a `.dd-theater-collapsible` / `.dd-theater-collapsed` CSS pair (`grid-template-rows: 1fr → 0fr` + `opacity` + deferred `visibility`); the panel now stays mounted and collapsed rather than unmounting via `@if` (enables the ease-in; resolves OQ2 design intent). (3) *Playing-release scoping* — Theater Mode now only applies to the currently-playing release: `ReleaseDetailBase` / `CutDetailBase` each gained a cascaded `IStreamingPlayerService` subscription and predicates (`IsThisReleasePlaying`, `IsContentHidden`, `ShowTheaterToggle`); `TheaterModeToggle` gained an `Available` parameter; all three pages pass `Available="ShowTheaterToggle"`, so a detail page whose release is not playing shows no toggle and ignores the global flag.
**Wave 2 follow-up fix (landed 2026-06-22):** The eased player-bar collapse (improvement 2 above) caused a visible flash when entering or leaving Theater Mode. The `.mix-waveform-bg` ambient visualizer backdrop positions itself via `bottom: var(--player-height)`, and `spacer.ts` was writing that CSS custom property on every ResizeObserver frame — so the ~0.45 s animated bar growth rewrote `--player-height` every frame, which fired the visualizer's own canvas ResizeObserver each time and cleared the GL backing store on each resize. Fixed by adding leading + trailing-edge coalescing in `spacer.ts` (SETTLE_MS = 80 ms): a discrete height change (breakpoint reflow, minimize/expand, error banner) still writes immediately with zero added latency; a rapid animated stream only writes its settled end-state. `spacer.ts` remains the sole writer of `--player-height`; at-rest clip correctness is exact across all breakpoints.
---
## 1. Goal
On a Release Detail view, let the listener **clear the page chrome away from the visualizer** with one
toggle — hiding the release content (header/meta/track-list/blurb) so the lava-lamp + waveform field
fills the surface unobstructed — while the **player bar grows** to carry the now-essential release
identity (cover art, release title, share) that the hidden page would otherwise have shown.
It is a "lean back and watch the lamp" mode. The visualizer is already the most distinctive thing the
site does (Phase 10/12/15); Theater Mode makes it the *whole* thing on demand, and relocates the
minimum release identity to the one piece of chrome that stays — the player bar.
**One-line framing:** Theater Mode trades the release page for the visualizer, and pays for the lost
release identity by enlarging the player bar.
---
## 2. Scope — the three Release Detail views (verified against the code)
The feature must behave identically across all three release mediums. The relevant files:
| Medium | Page file | Visualizer mount | Lava-lamp toggle host |
|---------|---------------------------------------------|---------------------------------------------------|------------------------------------------------|
| CUTS | `DeepDrftPublic.Client/Pages/CutDetail.razor` | `<WaveformVisualizer>` in scaffold's `Ambient` slot (mode B) | `ReleaseDetailScaffold` `TopRightAction` slot |
| SESSIONS| `DeepDrftPublic.Client/Pages/SessionDetail.razor` | `<WaveformVisualizer>` mounted directly (does **not** use scaffold) | inline in `.session-detail-top-row` |
| MIXES | `DeepDrftPublic.Client/Pages/MixDetail.razor` | `<WaveformVisualizer>` mounted directly (mode A, full-bleed) | `ReleaseDetailScaffold` `TopRightAction` slot |
**The asymmetry to respect:** Cut and Mix compose `ReleaseDetailScaffold`; **Session deliberately does
not** (it diverges for the hero-overlay layout — see `DeepDrftPublic.Client/CLAUDE.md`). So a
"hide-content" gate placed only in the scaffold would miss Session. The feature must be expressed in a
way that all three pages consume identically without forcing Session onto the scaffold. §6 resolves
this.
**Supporting components in play:**
- `DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor` — owns `TopRightAction`, the content
regions (`Header` / `MetaContent` / `BodyContent` / share-row), and the `ShowHeader` / `ShowMeta` /
`ShowShareRow` gates.
- `DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor` — the lava-lamp icon button
unit (`MudIconButton` wrapped in `.dd-accent-icon`). The new Theater button sits **to its left**.
- `DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs` — the scoped session-persistent
holder for visualizer subsystem state (`LavaEnabled`, `WaveformEnabled`, …) and its `Changed` event.
This is the model for where Theater-Mode state and "is anything visualizing?" live (§6).
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor` (+ `.razor.cs`) — the dock UI
that grows in Theater Mode.
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor` — the now-playing identity row;
the natural home for the enlarged "now showing" presentation (§7).
- `DeepDrftModels/DTOs/TrackDto.cs` + `ReleaseDto.cs` — **already carry everything the enlarged bar
needs**: `Track.Release.Title`, `Track.Release.ImagePath` (cover art), `Track.Release.EntryKey` +
`Track.Release.Medium` (release-mode share). **No new data plumbing.**
---
## 3. The toggle button — placement and behavior
1. A new right-side action **icon button**, positioned **immediately to the left of the lava-lamp
toggle** (the `WaveformVisualizerControlPopover` trigger), in the same top-right action cluster on
each of the three pages.
2. It is **visible only when the lava-lamp OR the waveform visualizer is active** — i.e. when
`WaveformVisualizerControlState.LavaEnabled || WaveformEnabled`. If the listener has switched both
subsystems off, there is nothing to go to theater *for*, so the button is absent. (This mirrors how
the visualizer's own controls self-gate on subsystem state.)
3. It is a **toggle** with an on/off visual state (active styling when Theater Mode is ON), exactly as
the lava-lamp popover icon shows an open/closed state today.
4. **Disabled until interactive** (`!RendererInfo.IsInteractive`) — same guard the lava-lamp button and
Play buttons already carry, so it does nothing during prerender.
**Iconography:** Material `Theaters` (a film-strip glyph). A bespoke `DDIcons` glyph in the
hand-rolled house style is the higher-craft option but is **not** required for v1 (this matches the
Phase 17 OQ7 precedent — Material icons now, bespoke later). **Resolved: Material `Theaters` for v1
(OQ1, Daniel 2026-06-20).**
---
## 4. Visibility behavior (Theater ON)
When Theater Mode is ON, the release-detail **content** is conditionally removed from the render (an
`@if` gate, not CSS `display:none` — Daniel's words, and it matches how the scaffold already gates
`Header`/`MetaContent`/`BodyContent` and how `WaveformVisualizerControls` gates its rows). What hides:
- The masthead / header region (title, artist, genre, year, Play/Share affordance row).
- The metadata block and the multi-track body (the Cut track-list; the `ReleaseDescription` blurb).
- The hero overlay (Session/Mix) — the big background-image hero with its overlaid title/play/share.
**What stays visible in Theater Mode:**
- The **visualizer** (the whole point — now unobstructed).
- The **top action row**: the back link, the lava-lamp popover (so the listener can still tune the
lamp), and the Theater toggle itself (so they can leave). These are the controls *over* the
experience, not content *of* the release — they stay.
- The **player bar**, now enlarged (§5/§7).
**Toggling OFF** restores the content exactly as it appears today — the `@if` re-includes it. Because
the gate is render-inclusion, not a layout fork, OFF is byte-for-byte the current page (the Liskov
discipline the scaffold already follows for its `Ambient` slot).
**Consistency across the three pages:** all three honor the same visibility rule and the same default
(Theater starts OFF on every page load). See §6 for *how* the single rule reaches all three without
forcing Session onto the scaffold.
---
## 5. Player-bar enlargement behavior (Theater ON)
When Theater Mode is ON, the player bar **grows** to surface — for the current track in the current
release — the release identity the hidden page no longer shows:
1. **Cover art**`Track.Release.ImagePath` rendered as a thumbnail (the `deepdrft-track-detail-cover-art`
background-image idiom already used on the detail pages; reuse it, do not invent a new image
treatment). Placeholder when null, matching the detail-page placeholder treatment.
2. **Release title**`Track.Release.Title`, linking to the release detail page via the existing
`ReleaseRoutes.DetailHref(Track.Release)` resolver (the same link `TrackMetaLabel` already builds for
the track title).
3. **Share** — a release-mode `SharePopover` bound to `Track.Release.EntryKey` +
`Track.Release.Medium` (the exact wiring the detail pages already use). This is the same share the
hidden page carried, relocated to the bar.
The bar **may grow taller/larger** to accommodate this "now showing" block. The growth is conditional on
Theater Mode being ON.
**Important seam:** the enlarged presentation lives in the player bar's **own** presentation layer
(`TrackMetaLabel` / a small new sub-component), keyed off the **current track's `Release`** — not off
the detail page. This matters because the player bar is mounted at layout level
(`AudioPlayerProvider``MainLayout`), one instance for the whole app. It already shows whatever track
is current regardless of route. So the enlarged "now showing" block is a property of *the bar reacting
to Theater state*, not something the detail page pushes into it. See §6 for how the bar observes Theater
state without the detail page reaching across to it.
**Edge — Theater ON but nothing playing:** the bar's enlargement keys off `CurrentTrack?.Release`. If no
track is playing (the listener opened the page and toggled Theater without pressing play), there is no
current release to surface in the bar. **Resolved (OQ2, Daniel 2026-06-20): playing-release only**
the bar stays a pure function of player state; no detail-page→bar data push. The listener who toggles
Theater is almost always already listening; the visualizer itself is blank until a track resolves, so a
blank-ish enlarged bar in that rare pre-play window is coherent.
---
## 6. Where the toggle state lives (SOLID boundary)
**Recommendation: a small new scoped state holder, observed by both the pages and the player bar — the
same decoupling pattern `WaveformVisualizerControlState` already establishes.**
The crux: three independent pages (two via scaffold, one not) AND the layout-level player bar all need
to read one boolean and react to its change. The clean seam is a shared scoped service with a `Changed`
event — not a cascading parameter from a page (the bar is not a descendant of the page), and not state
on the scaffold (Session does not use the scaffold).
Two viable homes for the boolean:
- **Option A (recommended): extend `WaveformVisualizerControlState`** with a `TheaterMode` bool +
`DefaultTheaterMode = false` + reuse its existing `Changed` event. Rationale: Theater Mode is
*conceptually part of the visualizer experience* — it is literally "show only the visualizer," it is
gated on the visualizer's own `LavaEnabled || WaveformEnabled`, and the state object is already scoped,
already session-persistent-within-a-session, already observed by the visualizer bridge, and explicitly
designed to **widen by adding a field + default without forcing any consumer constructor to change**
(its class comment says exactly this). The Theater toggle button mutates `TheaterMode` and calls
`NotifyChanged()`; the pages and the player bar subscribe to `Changed` and re-read. **Cost:** the state
object's name now slightly under-describes its contents (it holds a presentation-mode flag, not just
visualizer dials). Acceptable — the comment can note Theater Mode as a visualizer-experience flag.
- **Option B: a dedicated `TheaterModeState` scoped holder** (`bool IsOn`, `event Action? Changed`,
`Toggle()`). Rationale: single-responsibility purity — the visualizer-control state stays strictly
about visualizer dials. **Cost:** a second tiny observer-pattern holder that does the same shape of
thing as the one next to it; the gating still has to *read* `WaveformVisualizerControlState`
(`LavaEnabled || WaveformEnabled`) to know whether to show the button, so the two are coupled at the
read site anyway.
**Resolved (OQ3, Daniel 2026-06-20): Option A** — widen `WaveformVisualizerControlState` with a
`TheaterMode` flag. The visualizer-control state is already the "how the visualizer presents" object,
Theater Mode is a visualizer-presentation concern, and the object was explicitly designed to widen this
way. Final structural call is staff-engineer's at implementation (matching the standing convention on
`IQueueService`-shape decisions).
**Why this satisfies SOLID / the "cleanly separated concerns" constraint:**
- **Single source of truth, multiple observers.** One boolean; the three pages observe it for the
content `@if`; the player bar observes it for the enlargement. No page reaches into the bar; the bar
does not reach into a page. (Memory: *one source, multiple views* — divergence lives only in
rendering.)
- **The detail pages own only the visibility `@if`.** Each page wraps its content region(s) in
`@if (!state.TheaterMode) { … }`. Cut/Mix can do this around the scaffold's slot content; Session does
it around its own content. The scaffold itself needs **no Theater knowledge** if each page gates the
fragments it passes in — keeping the scaffold's existing `ShowHeader`/`ShowMeta` gates uncomplicated.
(Alternative: give the scaffold a `ShowContent`/`Theater` gate too; only helps the two scaffold pages,
not Session — so gating at the page level is the consistent choice.)
- **The player bar owns only the enlargement.** It reads `state.TheaterMode` + `CurrentTrack.Release`
and renders the "now showing" block. No new parameter threads down from a page.
- **The toggle button owns only the mutation.** Tap → flip `TheaterMode``NotifyChanged()`.
---
## 7. Player-bar enlargement — component shape
Keep the bar from bloating. Two clean options:
- **Recommended: a new presentational sub-component** `NowShowingPanel.razor` (or fold into a new branch
of `TrackMetaLabel`) under `Controls/AudioPlayerBar/`, rendered by `AudioPlayerBar.razor` **only when**
`state.TheaterMode && CurrentTrack?.Release is not null`. It takes the current `TrackDto` (or just its
`Release`) and renders cover + title-link + release-`SharePopover`. Purely presentational; owns no
player logic and no Theater state (it is shown/hidden by the bar). This mirrors how `QueueList`,
`ReleaseHeroOverlay`, and `ReleaseDescription` are split out as presentational shells.
- Alternative: branch inside `TrackMetaLabel` on a new `Theater` bool parameter. Lighter file count, but
pushes a layout-mode branch into the always-on label component — less clean. Prefer the sub-component.
`AudioPlayerBar` subscribes to `state.Changed` (it already subscribes to `IPlayerService.StateChanged`
in `OnParametersSet` and disposes — add the visualizer-control-state subscription the same way) so the
bar re-renders when Theater flips, and `StateHasChanged` already fires on track change so the enlarged
block follows the playing release for free.
---
## 8. Theming reuse (DRY — hard requirement)
Everything binds the **existing** theme-aware token layer and the established interactive-accent icon
convention. **No new per-component dark overrides.** Concretely:
- **The Theater toggle button** is a `MudIconButton` (`Color.Secondary`) wrapped in a
**`.dd-accent-icon`** container — the exact pattern `WaveformVisualizerControlPopover` uses for the
lava-lamp trigger. This gives it the green-accent glyph (`--deepdrft-green-accent`) in **both** themes
with zero new CSS. Do **not** spawn a new dark override (root `CLAUDE.md`: "Add new green-accent icon
affordances by applying this class, not by spawning a new dark override.").
- **The enlarged player-bar "now showing" block:**
- **Surface/background, text, borders** bind the player bar's existing surface treatment
(`.player-surface` / the bar's own classes) and the theme-aware aliases —
`--deepdrft-page-surface` / `--deepdrft-page-text` / `--deepdrft-page-text-muted` for neutral
text/background, never raw source tokens. (The bar already lives inside the themed wrapper, so it is
not a portaled-popover case — no `body.deepdrft-theme-dark` re-declaration needed.)
- **The release title link** uses the bar's existing title-link treatment (`TrackMetaLabel`'s
`.track-meta-title`) — reuse it, do not restyle.
- **The Share affordance** is a `SharePopover` wrapped in **`.dd-accent-icon`** (its glyph goes
green-accent in both themes — the same treatment the detail-page hero share already uses).
- **The cover-art thumbnail** reuses the `deepdrft-track-detail-cover-art` background-image class (and
the `deepdrft-gradient-soft-secondary` placeholder for null images) — the detail pages' existing
cover idiom, theme-aware already.
- **No new palette `Color` enum value**, no new token family unless a genuinely new surface appears
(it should not — the bar surface and detail-cover idioms already exist). If the enlarged bar needs a
divider or a subtle panel inset, bind an **existing** alias; flag to Daniel if a new alias seems
unavoidable rather than inventing one silently.
---
## 9. Open questions — all resolved (Daniel, 2026-06-20)
All six open questions are resolved. Every resolution matches the spec's recommendation.
- **OQ1 — Theater toggle icon. RESOLVED: Material `Theaters` (film-strip) for v1.** Bespoke `DDIcons`
glyph deferred (Phase 17 OQ7 precedent).
- **OQ2 — bar enlargement when nothing is playing. RESOLVED: playing-release only.** The bar stays a
pure function of player state; no page→bar data path. The listener who opens Theater without pressing
play sees a blank-ish enlarged bar — coherent, because the visualizer itself is also blank.
- **OQ3 — state home. RESOLVED: Option A — widen `WaveformVisualizerControlState` with a `TheaterMode`
flag.** Theater Mode is a visualizer-presentation concern; the object was explicitly designed to widen
this way. Staff-engineer makes the final structural call at implementation.
- **OQ4 — back link in Theater Mode. RESOLVED: stays visible.** The top action row (back, lava-lamp,
theater) is controls, not release content — it remains in Theater Mode.
- **OQ5 — persistence scope. RESOLVED: session-scoped, resets to OFF on fresh page load.** Persists
across SPA navigation within a session; a full reload (F5) resets it to OFF. Matches the
visualizer-control-state precedent; no cookie round-trip.
- **OQ6 — Theater on the home hero / NowPlaying panel. RESOLVED: detail-pages-only for v1.** The three
Release Detail views are the scope; the home hero's `WaveformVisualizerControlPopover` does not get a
Theater affordance in this phase.
---
## 10. Acceptance criteria
1. On each of `/cuts/{key}`, `/sessions/{key}`, `/mixes/{key}`, a Theater toggle icon button renders
immediately to the left of the lava-lamp popover icon in the top action row.
2. The Theater button is **absent** when both `LavaEnabled` and `WaveformEnabled` are false; it **appears**
when either is true. It is disabled (inert) during prerender / before interactive.
3. Toggling Theater **ON** removes the release content from the render (header/meta/track-list/blurb,
and the hero overlay on Session/Mix) via `@if`, leaving the visualizer unobstructed plus the top
action row (back, lava, theater) and the player bar.
4. In Theater Mode the player bar **grows** and surfaces, for the current playing track's release:
cover art, release title (linked to the release detail page), and a release-mode share affordance.
5. Toggling Theater **OFF** restores the page byte-for-byte to its non-Theater appearance.
6. Behavior is **identical across all three mediums** — same button, same placement, same visibility
rule, same bar enlargement, same default (OFF on load).
7. **Light and dark both correct with zero new dark overrides:** the toggle glyph and the bar's share
glyph are green-accent in both themes via `.dd-accent-icon`; the enlarged bar's text/surface bind
existing theme-aware aliases; the cover thumbnail uses the existing detail-cover class.
8. No API / data / schema change. No CMS change. The enlarged bar reads only `CurrentTrack.Release`
fields the DTO already carries.
9. Theater Mode persists across SPA navigation within a session and resets to OFF on a fresh page load
(OQ5, confirmed).
---
## 11. What this is NOT (scope guards)
- **Not** a fullscreen API call. Theater Mode hides page content; it does not request browser
fullscreen. (A future enhancement could pair it with the Fullscreen API — note, don't build.)
- **Not** a visualizer behavior change. The renderer, the bridge, the control dials, and the read-only
contract are all untouched. Theater Mode only changes *what page chrome is shown around* the
visualizer.
- **Not** a player/queue change. The streaming seam, the queue engine, and the bar's transport controls
are untouched; only the bar's *identity presentation* grows.
- **Not** a CMS or embed-player feature. The embed (`FramePlayer` / Fixed bar mode) is out of scope —
Theater Mode is for the docked detail-page experience.
---
## 12. Borrowed precedent
- **Media-player "theater mode" / "cinema mode"** (YouTube's theater toggle, Twitch's theater mode) —
the direct namesake: collapse the surrounding page chrome to let the media fill more space, one toggle,
reversible. The transplant here is that the "media" is the visualizer and the "chrome" is the release
page.
- **The visualizer-control popover idiom** (`WaveformVisualizerControlPopover`) — the toggle button's
placement, `.dd-accent-icon` treatment, `IsInteractive` gating, and on/off visual state are lifted
directly from the lava-lamp button it sits beside.
- **`WaveformVisualizerControlState`'s observer seam** — the state-holder + `Changed`-event decoupling
is the established pattern for "one piece of state, several components react"; Theater Mode reuses its
exact shape (and, per Option A, possibly its exact object).
@@ -0,0 +1,383 @@
# Phase 21 — Windowed Streaming Buffer (bounded client memory for long streams)
Product spec. Status: **design / framing — implementation-ready pending Daniel's open-question calls.**
Author: product-designer. Date: 2026-06-23. **No code has been written by this doc.**
Surface: **public listener site only** (`DeepDrftPublic.Client` player stack + `DeepDrftPublic`
TypeScript audio interop). No CMS (`DeepDrftManager`) change. No data-model or schema change. The one
server touch is **reuse, not new surface**: the existing `DeepDrftAPI` HTTP `Range: bytes=X-`
partial-content primitive (Phase 4, landed) is the load-bearing dependency; this phase adds no new API
endpoint.
> **Sequencing dependency (Daniel, 2026-06-23): Phase 18 (Opus Low-Data Streaming) comes BEFORE this
> phase.** Format support — specifically the derived **Ogg Opus fullband 320** low-data delivery path
> (`product-notes/phase-18-opus-low-data-streaming.md`) — is a prerequisite that sequences ahead of
> windowing. Phase 21's windowing must work across **both** delivery formats (lossless WAV and Opus).
> Its C5 invariant below already anticipated this ("must not foreclose MP3/FLAC"); **Opus is now the
> concrete VBR/containerized driver of C5.** Windowing an Opus stream uses the decoder's **accurate
> index-based** byte↔time mapping (`OpusFormatDecoder.calculateByteOffset` — a binary search in the Phase 18
> precomputed seek index), exactly the C5 case — *not* the exact CBR-WAV `byteRate` math, and *not*
> approximate Ogg-page interpolation. **Correction (Daniel, 2026-06-23):** an earlier draft described the
> Opus mapping as "approximate page interpolation"; the Phase 18 seek-model resolution rejected that — Opus
> seeking is **accurate**, backed by a precomputed seek index built at transcode time, so refill resolves to
> the *exact* page offset. The windowed refill controller calls the **same** index resolver an explicit seek
> does (Phase 18 §3.4a D); a window opening away from byte 0 still decodes via the Phase 18 sidecar setup
> header. Build the window machinery format-agnostically (§2 C3/C5) so it inherits Opus for free.
---
## 1. Goal
Bound the **client memory** a playing track consumes to a small, configurable forward window —
**independent of total stream length** — so a 1 GB+ DJ MIX (Phase 9 `Mix` medium: a single long track)
plays without the whole decoded PCM accumulating in the browser.
**The defect, stated precisely.** The network path already streams in adaptive 1664 KB chunks
(`StreamingAudioPlayerService.StreamAudioWithEarlyPlayback`) — that part is fine. The accumulation is on
the **decode side**: `PlaybackScheduler` holds `private buffers: AudioBuffer[]` and **never evicts**
("Supports pause/resume/seek by **retaining all buffers**" — its own doc comment). Every 64 KB segment
the `StreamDecoder` decodes is pushed via `addBuffer()` and kept for the life of the track. Decoded PCM
is **larger than the compressed-or-raw source** in memory (Web Audio `AudioBuffer` is 32-bit float per
sample per channel — a 16-bit stereo WAV roughly **doubles** in size once decoded), so a 1 GB WAV becomes
~2 GB of retained `AudioBuffer` float data. That is the OOM.
**One-line framing:** today the player decodes the whole track into memory and keeps it; Phase 21 makes
it keep only a sliding forward window and discard what has already played, refilling on demand from the
Range primitive it already uses for seek.
---
## 2. Constraints / invariants (the contract that must hold)
These are non-negotiable. The §3.5 streaming seam (root `CLAUDE.md` "Streaming-first audio playback";
`CONTEXT.md §3.5`) is called *the most architecturally load-bearing part of the playback path* by both
docs. This phase **modifies that seam** — so the contract it must preserve is spelled out here.
- **C1 — The seek-beyond-buffer Range path is the substrate, kept intact.** Phase 4 landed HTTP
`Range: bytes={offset}-``206 Partial Content` end to end (client `TrackMediaClient`
`DeepDrftPublic` proxy → `DeepDrftAPI`), and `StreamDecoder.reinitializeForRangeContinuation` retains
the parsed format header on a continuation body (no re-parse). Windowed refill is a **generalization of
this exact path** (§3.1) — it must not require a second, divergent fetch mechanism.
- **C2 — Playback start latency unchanged.** Today playback starts as soon as a configurable minimum
buffer count is queued (header-derived duration, not full-file). The window model must keep first-audio
latency at parity — bounding memory must not reintroduce a fetch-then-play stall.
- **C3 — The format-decoder abstraction is untouched.** `IFormatDecoder` owns all format-specific
byte math; `AudioPlayer.createFormatDecoder` already dispatches on `Content-Type` (WAV/MP3/FLAC
decoders all wired today — verified 2026-06-23; an `OpusFormatDecoder` joins them in Phase 18).
Windowing lives in the
**format-agnostic** layer (`PlaybackScheduler` eviction + `StreamDecoder`/player refill
orchestration); it must add **no** format-specific branches. A future wired MP3/FLAC decoder inherits
windowing for free.
- **C4 — Read-only playback only.** This is a memory-management change, not a UX change. No new
user-visible control, no change to seek/transport semantics beyond what the listener already
experiences. Seek must still feel identical.
- **C5 — Must window both delivery formats (WAV lossless AND Opus low-data).** Byte↔time mapping for
refill is exact and cheap for WAV (CBR: `byteRate` from the header). **Phase 18 (Opus) is sequenced
before this phase and is the concrete VBR driver here** — and its mapping is **also exact**, but by a
different mechanism: an Ogg Opus 320 stream has no linear time↔byte relationship, so
`OpusFormatDecoder.calculateByteOffset` resolves via a **precomputed seek index** (granule→byte, built at
transcode; Phase 18 §3.4a), a binary search that returns the exact page offset — **not** an approximate
page interpolation. (An earlier draft of this invariant said "approximate"; the Phase 18 seek-model
resolution, Daniel 2026-06-23, made Opus seeking accurate. Corrected here.) The window machinery must
express refill purely in terms of the decoder's existing `calculateByteOffset`, so the same code windows
WAV (via `byteRate`) and Opus (via the index) — **no WAV-special-cased offset math in the window layer**,
and no approximation for either. A window that opens away from byte 0 must also prepend the decoder's
retained/sidecar setup header (Phase 18 §3.4a B) — the format-agnostic refill path already routes
continuations through the decoder's header-carry, so this comes for free. (MP3/FLAC decoders are already
wired in the registry too — the registry dispatches on content-type today; an `OpusFormatDecoder` joins
them in Phase 18.)
- **C6 — No regression to the single-instance JS decoder concurrency guarantees.** The current code is
careful that only one streaming loop touches the single JS `StreamDecoder` at a time
(`DrainActiveStreamingTaskAsync`, the `_streamingCancellation` identity dance). Windowed refill
introduces *more* mid-stream fetches; it must route through the **same** drain/cancellation discipline,
not around it.
- **C7 — The Mix visualizer's data source is independent and must stay that way.** The Phase 10/12
WebGL2 lava visualizer renders from a **preprocessed high-res waveform datum** fetched per-track
(`GET api/track/{entryKey}/waveform/high-res`), **not** from live decoded PCM. Confirmed: evicting
played `AudioBuffer`s cannot starve the visualizer — it never read them. The window model is invisible
to the visualizer. (This is the canonical 1 GB case *and* the case that proves the eviction is safe.)
---
## 3. Architectural shape
### 3.0 The mental model
A track's audio is a byte range `[0, fileLength)` on disk. At any moment the listener is at playback
position `P` (seconds → byte offset via the format decoder). The player should hold decoded
`AudioBuffer`s only for a bounded window roughly `[P - back, P + ahead]`:
- **forward fill (`ahead`)** — enough decoded lookahead that playback never starves (covers the existing
500 ms scheduler lookahead plus network jitter headroom);
- **back-retain (`back`)** — a small amount of *already-played* audio kept so a short seek-back does not
trigger a network refetch;
- **evict** — anything older than `P - back` is dropped (`AudioBuffer` references released → GC reclaims
the float data);
- **refill** — when forward decoded lookahead drops below a low-water mark, fetch+decode more from the
current byte position; when the window's tail is evicted and the listener seeks back past it, refetch
that region via the Range primitive (the seek-beyond-buffer path, run *backwards*).
This is a **ring/sliding-window buffer keyed on playback position**, driven by high/low-water marks —
the standard bounded-producer/bounded-consumer pattern, transplanted onto the decode→schedule seam.
### 3.1 Why this is a generalization of seek-beyond-buffer, not a new mechanism
The seek-beyond-buffer path already does **every primitive** the window needs, just triggered manually
and one-shot:
| Window operation | Existing seek-beyond-buffer machinery it reuses |
|-------------------------------|-----------------------------------------------------------------------------------|
| Discard buffers, keep offset | `PlaybackScheduler.clearForSeek()` + `setPlaybackOffset()` (clears buffers, retains the absolute-time anchor) |
| Fetch from a byte offset | `TrackMediaClient.GetTrackMedia(key, byteOffset)``Range: bytes=X-` → 206 |
| Decode a header-less body | `StreamDecoder.reinitializeForRangeContinuation(remainingByteLength)` |
| Map time → byte offset | `StreamDecoder.calculateByteOffset()``IFormatDecoder.calculateByteOffset()` |
| Single-loop safety on refetch | `_streamingCancellation` swap + `DrainActiveStreamingTaskAsync()` |
The difference is **eviction does not exist yet** (the scheduler only ever `clear()`s wholesale) and
**refill is one-shot** (a seek, not a continuous low-water-triggered loop). So the new work is two
seams: a *partial-evict* on the scheduler, and a *position-driven refill controller* on the player. The
fetch/decode/offset plumbing is reused verbatim.
### 3.2 The three candidate directions
Per file convention the alternatives are recorded; the recommendation follows.
**Direction A — Sliding window on the existing single forward stream (recommended).**
Keep the current model where the C# loop reads one forward HTTP stream and pumps chunks into the JS
decoder. Add two things: (1) `PlaybackScheduler` gains *partial eviction* — drop buffers whose
absolute-time end is older than `P - back`, adjusting its index bookkeeping so `getCurrentPosition()`
and scheduling stay correct against a buffer array that no longer starts at index 0; (2) a
*back-pressure* signal — when forward decoded lookahead exceeds the high-water mark, the C# loop
**pauses reading** the HTTP stream (stops calling `ReadAsync`) until playback drains it below low-water,
then resumes. Memory is bounded by high-water + back-retain. Seek-back beyond the retained window falls
through to the **existing** seek-beyond-buffer path unchanged.
*Why recommended:* smallest change to the load-bearing seam; reuses the live forward stream (no extra
connections in the common case); eviction and back-pressure are the only genuinely new mechanisms, and
both are local (one to the scheduler, one to the read loop). Back-pressure via "stop reading the socket"
is exactly how TCP flow control already wants to behave — pausing `ReadAsync` lets the kernel window
close; we are not fighting the transport.
**Direction B — Discrete window segments, each its own Range fetch.**
Treat the file as fixed-size byte segments (e.g. 4 MB). Hold N decoded segments around `P`; fetch the
next/previous segment via a fresh Range request as the window slides; discard the far segment. No live
long-lived forward stream — every window is an independent 206.
*Why not (default):* turns one connection into many short Range requests (more proxy hops through
`DeepDrftPublic`, more server-side `WavOffsetService`-style header synthesis, more places a fetch can
fail mid-stream — worsening the §1.6 error surface), and the byte↔time segment math must be exact at
every boundary. It *is* the cleaner model for true random-access (and the better base if seeking-heavy
usage dominates), so keep it as the fallback if Direction A's back-pressure proves leaky in practice.
Borrowed prior art: HLS/DASH segment windows and the MSE `SourceBuffer.remove()` eviction model — this
is how every production HTML5 adaptive player bounds memory. We are doing the hand-rolled equivalent
because the stack is a bespoke Web Audio graph, not `<media>` + MSE.
**Direction C — Adopt MediaSource Extensions (MSE) and let the browser manage the buffer.**
Stop hand-rolling the decode→schedule graph for long tracks; feed the Range stream into a `SourceBuffer`
and let the browser evict via its built-in quota + `remove()`. Memory management becomes the platform's
problem.
*Why not — RESOLVED, rejected (Daniel, 2026-06-23; see OQ5):* MSE does not accept raw WAV/PCM — it
wants containerized formats (fragmented MP4/WebM, or MP3/AAC elementary streams). The entire bespoke
visualizer/spectrum graph is wired to the Web Audio `AudioContext`, not a `<media>` element. Adopting
MSE is a **rewrite of the playback substrate**, not a windowing change. It *looked* like the real
long-term answer once compressed delivery arrived — but Daniel has decided compressed delivery
(**Phase 18 Opus**) will feed the **same bespoke graph** via the `IFormatDecoder` seam, so the
compressed-delivery move that would have justified MSE happens *without* surrendering the graph. **The
bespoke graph is a deliberate long-term commitment; MSE is rejected.** Direction A is therefore the
permanent destination, not a stopgap that MSE will retire. Recorded as considered-and-declined.
### 3.3 Recommended direction: A, with B held as the documented fallback
Direction A is the smallest coherent change that hits the headline (bounded memory under a 1 GB stream)
while honoring C1C7. It keeps the live forward stream, reuses the seek-beyond-buffer path for the only
genuinely random-access case (seek-back past the retained tail), and isolates the two new mechanisms.
**The final architecture and the exact eviction/back-pressure API are staff-engineer's call at
implementation** (per file convention); this spec fixes the *shape* and the invariants, not the method
signatures.
### 3.4 SOLID / road-not-taken rationale
- **SRP, preserved.** Eviction is a `PlaybackScheduler` concern (it already owns buffer storage); refill
orchestration is a player-service/`StreamDecoder` concern (they already own the fetch loop); byte↔time
math stays in `IFormatDecoder`. No responsibility crosses a boundary it does not already own.
- **OCP, via C3/C5.** Windowing added in the format-agnostic layer means wiring MP3/FLAC later changes
zero window code. The window expresses refill through `calculateByteOffset` — the one seam the
decoders already implement.
- **The seam stays single-writer (C6).** Every new refetch routes through the existing
cancellation/drain discipline, so "only one loop touches the JS decoder" remains true. This is the
rule most likely to be violated by a naive implementation and is called out as a hard invariant.
- **Road not taken — eager full decode with a memory cap that just stops decoding.** Tempting (decode
until you hit a byte budget, then stop) but it breaks playback of long tracks past the cap entirely —
it bounds memory by *refusing to play the rest*, not by sliding. Rejected: it is a degradation, not a
feature.
---
## 4. Use cases
- **UC1 — Play a 1 GB+ DJ MIX start to finish (the headline).** Memory stays bounded throughout; the
listener experiences continuous playback identical to a short track.
- **UC2 — Seek forward within a long track.** Already handled by seek-beyond-buffer; under windowing the
forward seek clears the window and refills at the target — no behavior change, now with eviction so the
pre-seek region does not linger.
- **UC3 — Seek back a few seconds.** Served from the back-retain window with **no** network refetch
(the reason `back` exists).
- **UC4 — Seek back far, past the evicted tail.** Falls through to the existing seek-beyond-buffer Range
fetch, run toward an earlier offset. (Open question OQ2 — see §6.)
- **UC5 — Pause a long track for a long time.** Memory stays at the bounded window size while paused (no
continued decode). On resume, forward fill restarts from the low-water trigger.
- **UC6 — Mix detail page with the lava visualizer running.** Visualizer reads its preprocessed datum
(C7); windowing is invisible to it. Confirmed non-interaction.
---
## 5. Interaction with the deferred Phase 1 streaming features
This phase touches the **same decoder/scheduler seam** as the deferred Phase 1.3/1.4/1.5 items and the
1.6/1.7 robustness items. The interactions, explicitly:
- **1.3 Preload / prefetch (deferred; preload half).** *Shares machinery, does not conflict — and should
be sequenced after.* Preload stages the **next track** into a second decoder instance during the
current track's tail; windowing bounds the **current track's** forward buffer. They are orthogonal
axes (next-track vs. current-track-window), but they compound the memory question: a naive preload of a
second 1 GB mix would reintroduce the OOM this phase fixes. **Recommendation: land windowing first**,
so that when preload arrives, the staged next-track decoder is *also* windowed by construction (it
inherits the bounded scheduler). Windowing makes preload *safe for long tracks*; without it, preload of
mixes is a memory hazard.
- **1.4 Crossfade (deferred).** Needs two simultaneous `PlaybackScheduler` instances briefly overlapping.
Both would be windowed instances — the overlap doubles the *window* size momentarily, not the whole
track. Windowing makes crossfade between two long mixes affordable. No reordering needed; 1.4 still
gates on 1.3.
- **1.5 Gapless (deferred).** Sample-accurate hand-off of the next track's first buffer at the current
track's last buffer. Windowing changes *which* buffers are retained but not the hand-off mechanism;
the only care point is that the current track's **final** window must not be evicted before the gapless
boundary is scheduled. A minor invariant for whoever builds 1.5, not a blocker. Note 1.5's existing
WAV-only caveat stands.
- **1.6 Track-skip on error (deferred).** *Windowing enlarges the error surface — call this out.* Today
a fetch failure happens at load (one fetch) or at a user seek (one fetch). Windowed refill issues
**mid-stream** fetches the listener did not initiate; one of those can fail at byte 700 M of a 1 GB
mix. So Phase 21 should ship with at least the *cheap* half of 1.6: a mid-stream refill failure must
**surface a clear error and not wedge the player** (it must not leave playback "running" with a starved
scheduler — mirror the `playFromPosition` end-of-buffer recovery already in `PlaybackScheduler`). The
rich half (byte-scan to next valid frame) stays deferred. **Recommendation: fold the minimal refill-
failure handling into Phase 21's acceptance criteria** (AC6) rather than leaving it entirely to 1.6 —
it is created by this phase.
- **1.7 Safari compatibility (deferred).** Windowing adds no new Safari-specific surface beyond what the
streaming path already has. The one adjacency: more frequent `AudioContext` activity during refill
should be checked against the older-Safari `webkitAudioContext` quirks when 1.7 is addressed — note it,
do not block on it.
---
## 6. Open questions for Daniel (genuine product decisions, not implementation detail)
These are policy calls with user-visible or resource trade-offs — flagged rather than decided here.
- **OQ1 — Window size policy.** What bounds the window — a **fixed byte/time budget** (e.g. "hold at
most ~30 s decoded ahead + ~10 s behind"), or a **configurable memory budget** (e.g. "≤ N MB of
decoded PCM") that derives the time window from the stream's byte rate? Recommend a **time-based
forward window + small time-based back-retain** as the primary knob (intuitive, format-portable), with
a hard **memory ceiling** as a secondary guard. The exact numbers are tunable post-landing; Daniel
picks the *policy axis*. `[Daniel decision]`
- **OQ2 — Seek-back past the evicted window.** When the listener seeks back earlier than the retained
tail, we must refetch (the audio is gone). Acceptable to take the same brief re-buffer the forward
seek-beyond-buffer takes today? (Recommend yes — it is the symmetric case and listeners already accept
it forward.) Or should back-retain be generous enough that this is rare? `[Daniel decision]`
- **OQ3 — Configurable total in-flight memory cap.** Should there be a single hard byte ceiling on total
decoded audio held by the player (a safety net independent of the window-size policy), exposed as a
config value? Recommend **yes, as a guard rail** even if the window policy is time-based — it is the
backstop that makes "1 GB stream never OOMs" a guarantee rather than a tuning hope. `[Daniel
decision]`
- **OQ4 — Apply windowing to all tracks, or only long ones?** A 3-minute Cut decoded whole is ~3060 MB
— harmless today. Windowing everything is simpler (one code path) but adds refill machinery to short
tracks that never needed it. Recommend **window everything** (one path, C6-safe, and short tracks
simply never hit a refill because they fit inside the forward window) — but Daniel may prefer a
size threshold. `[Daniel decision]`
- **OQ5 — Is MSE (Direction C) the real destination? — RESOLVED: NO (Daniel, 2026-06-23).** **Do not
adopt MSE. The bespoke Web Audio decode→schedule graph stays — it is bespoke by deliberate choice, a
long-term commitment, not a stopgap.** Daniel's rationale: the player is intentionally a custom
graph, not an HTML `<media>` element; the compressed-delivery move that *would* have made MSE
tempting is being met instead by **Phase 18 (Opus low-data path)** feeding the **same bespoke graph**
through the `IFormatDecoder` seam — so compressed delivery arrives *without* surrendering the graph.
Consequence for this phase: Direction A (the hand-rolled sliding window) is the destination, not a
placeholder; invest in it as permanent machinery. It will window both the WAV and the Opus path
(the sequencing note at the top). Direction C is recorded as **considered and declined** per file
convention; kept visible so a future reader sees the road not taken and why.
`[RESOLVED — bespoke graph retained; MSE rejected]`
---
## 7. Acceptance criteria
- **AC1 (headline) — Bounded memory under a 1 GB stream.** Playing a 1 GB+ WAV mix start to finish, the
browser tab's retained decoded-audio memory stays bounded to the configured window (not growing toward
~2 GB). Verifiable via browser memory tooling: peak decoded-audio footprint is independent of track
length and tracks the window-size policy, not the file size.
- **AC2 — Playback-start latency at parity (C2).** First-audio latency for a track is unchanged from
pre-windowing (within noise). Windowing does not introduce a fetch-then-play stall.
- **AC3 — Continuous playback, no starvation.** A long mix plays edge to edge with no audible gaps,
underruns, or stalls under normal network conditions — the forward fill stays ahead of the playhead.
- **AC4 — Seek-back within the window is instant (UC3).** A short backward seek into retained audio
produces no network request.
- **AC5 — Seek (forward, and back past the window) still works (UC2/UC4).** Both resolve via the
existing Range path with the same behavior the listener sees today; the pre-seek region is evicted, not
retained.
- **AC6 — A mid-stream refill failure degrades cleanly (the 1.6 adjacency).** A failed refill fetch
surfaces a clear user-visible error and leaves the player in a recoverable state (not a wedged
"playing" with a starved scheduler). It must not silently hang.
- **AC7 — The Mix visualizer is unaffected (C7).** With the lava visualizer running on a long mix, the
visualizer renders identically (it reads the preprocessed datum, never the evicted buffers).
- **AC8 — Single-decoder concurrency invariant holds (C6).** Under rapid seek + refill activity, no
interleaved `ProcessStreamingChunk` calls corrupt the single JS decoder (the existing drain/cancel
discipline still governs every fetch).
---
## 8. Wave decomposition
Dependency shape: `21.1 → 21.2 → 21.3`, with `21.4` validating the whole. 21.1 is the cold-start
prerequisite and the load-bearing change; the rest layer on it.
- **21.1 — Partial eviction in `PlaybackScheduler` (cold-start; the load-bearing change).** Give the
scheduler the ability to drop already-played buffers and keep its position/index bookkeeping correct
against a buffer array that no longer begins at absolute time 0 (today `getCurrentPosition`,
`playFromPosition`, and the scheduling loop all assume `buffers[0]` is the track start). This is the
hardest correctness work in the phase — the time-anchor math must stay exact through eviction. No
refill yet; with eviction alone and the forward read loop unchanged, this is provably memory-bounded
for the *played* region. **Independent of the §6 open questions** — it can begin immediately; the
window *sizes* (OQ1/OQ3) are parameters fed in later. Settled and cold-start.
- **21.2 — Back-pressure on the forward read loop (the bound on the *unplayed* region).** Make the C#
`StreamAudioWithEarlyPlayback` loop stop calling `ReadAsync` when forward decoded lookahead exceeds the
high-water mark, and resume below low-water. Together with 21.1, this bounds *both* the played and
unplayed sides — the full memory guarantee (AC1). Must route resume/pause through the existing
cancellation-safe single-loop discipline (C6). **Depends on 21.1** (eviction must exist so the drained
region is reclaimed, not merely un-read).
- **21.3 — Seek-back-past-window refill (close the random-access case).** Wire UC4 — when a backward
seek lands earlier than the retained tail, refetch via the existing seek-beyond-buffer Range path
pointed at the earlier offset, and the minimal AC6 refill-failure handling. Mostly **reuse** of the
landed seek path; the new work is the trigger (window-miss detection) and the clean-failure path.
**Depends on 21.1 + 21.2** (needs the window boundaries they define).
- **21.4 — Validation pass against the 1 GB target (acceptance).** Exercise AC1AC8 against a real 1 GB+
mix: memory profiling (AC1), latency parity (AC2), edge-to-edge playback (AC3), the seek matrix
(AC4/AC5), induced refill failure (AC6), visualizer-running (AC7), and rapid-seek concurrency (AC8).
Largely test/measurement; any break is likely a tuning fix in the 21.1 anchor math or the 21.2
water-marks. **Depends on 21.121.3.**
---
## 9. Cross-references (read before implementing)
- Root `CLAUDE.md` "Streaming-first audio playback" / `CONTEXT.md §3.5` — the seam this phase modifies;
the §2 invariants here restate its contract. Both flag it as the most load-bearing path.
- `PLAN.md` Phase 4 (landed) / `COMPLETED.md` — the HTTP Range `bytes=X-` primitive this generalizes.
- `PLAN.md` Phase 1.3 / 1.4 / 1.5 / 1.6 / 1.7 — the deferred decoder/scheduler-seam features; §5 above
reconciles each.
- `PLAN.md` Phase 9 — defines the `Mix` medium (single long track), the canonical 1 GB case.
- `PLAN.md` Phase 10 / `product-notes/phase-10-mix-visualizer-lava-reframe.md` /
`product-notes/phase-12-waveform-visualizer-generalization.md` — establishes the preprocessed
per-track high-res waveform datum; the basis for C7 (visualizer does not read live PCM).
- `DeepDrftPublic/Interop/audio/PlaybackScheduler.ts` — owns the unbounded `buffers: AudioBuffer[]`;
21.1 lives here.
- `DeepDrftPublic/Interop/audio/StreamDecoder.ts``reinitializeForRangeContinuation`,
`calculateByteOffset`; the refill substrate.
- `DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs` — the C# forward read loop
(`StreamAudioWithEarlyPlayback`), the seek-beyond-buffer path (`SeekBeyondBuffer`), and the
cancellation/drain discipline (C6); 21.2/21.3 live here.
- `DeepDrftPublic.Client/Clients/TrackMediaClient.cs` — the Range-capable media fetch reused by refill.
@@ -0,0 +1,439 @@
# Phase 22 — SEO Metadata Component (parameterized head/meta injection, public site)
Product spec. Status: **design / framing — implementation-ready pending Daniel's open-question calls.**
Author: product-designer. Date: 2026-06-23. **No code has been written by this doc.**
Surface: **public listener site only** (`DeepDrftPublic` ASP.NET Core host + `DeepDrftPublic.Client`
Blazor WASM assembly). The CMS (`DeepDrftManager`) is an authenticated admin surface and is
**explicitly out of scope** — it must not be touched, and admin pages should if anything carry
`noindex`. No data-model or schema change. No new API endpoint (every value the component needs is
already returned by the existing `TrackDto` / `ReleaseDto` / `HomeStatsDto` reads).
---
## 1. Goal
Give every public page a **single, reusable, parameterized component** that emits the full modern-SEO
head surface — standard meta, canonical, robots, Open Graph, Twitter Card, and schema.org JSON-LD —
so crawlers and social unfurlers see correct, page-specific metadata **in the prerendered HTML**, with
no per-page boilerplate and no double-maintenance.
**One-line framing:** today each page hand-writes a bare `<PageTitle>` and nothing else; Phase 22
replaces that with one `<SeoHead …>` component that a page configures with a handful of parameters (or
a typed model), defaulting everything else from a site-wide config, and that renders the complete head
surface server-side during prerender where crawlers can read it.
### What exists today (the starting point — verified 2026-06-23)
- `App.razor` (`DeepDrftPublic/Components/App.razor`) declares `<HeadOutlet @rendermode="InteractiveAuto" />`
in `<head>`, and a static `<head>` block with charset, viewport, `<base href="/">`, stylesheet links,
`<ImportMap />`, and a favicon. **No** `<meta name="description">`, **no** canonical, **no** OG/Twitter
tags, **no** JSON-LD. The only per-page head contribution anywhere is `<PageTitle>`.
- Pages set titles ad hoc: `Home.razor``<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>`;
`CutDetail.razor``<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>`. No shared
title-composition convention — the suffix (" - DeepDrft" vs " - Electronic Music Collective") is
already inconsistent.
- `<html lang="en">` is set once in `App.razor`.
- Detail pages (`CutDetail`, `SessionDetail`, `MixDetail`) inherit base classes (`ReleaseDetailBase` /
`CutDetailBase`) that load a `ReleaseDto` into a ViewModel in `OnParametersSetAsync` (not
`OnInitialized` — the documented same-template-nav reuse rule), and bridge the prerender fetch across
the WASM seam with `PersistentComponentState`. **This is the key fact for render-mode correctness
(§5):** the release data is already resolved during prerender, so the SEO tags it feeds can be too.
- Canonical URL composition already exists for releases: `ReleaseRoutes.DetailHref(entryKey, medium)`
`/cuts/{key}` | `/sessions/{key}` | `/mixes/{key}`. `SharePopover` already builds absolute share URLs
from this; the SEO canonical tag is the same URL, absolutized.
- Cover art resolves to `api/image/{Uri.EscapeDataString(release.ImagePath)}` — the OG image source.
### Why now / why it matters
A public music catalogue lives or dies on discoverability and on how its links unfurl in iMessage,
Discord, X, and search results. Right now a shared `/mixes/{key}` link unfurls as a bare title and a
URL — no description, no cover image, no rich card. Search engines see a title and an empty description.
The share affordance (Phase 16/17) is already a first-class feature; SEO/OG metadata is the missing
half of "this link is worth sharing."
---
## 2. Constraints / invariants (the contract that must hold)
- **C1 — Public site only.** Zero changes to `DeepDrftManager`. The component, its config, and its
registration all live in the public host + client. If anything, the CMS should later emit `noindex`
(noted as an adjacent concern in §7, not specified here).
- **C2 — Tags must be present at prerender time, not after WASM boot.** Crawlers and unfurlers read the
server-rendered HTML and (mostly) do not execute the WASM runtime. The component must contribute its
head content during the **server prerender pass**. This is the single most load-bearing correctness
requirement and is detailed in §5. It governs *where the data comes from* (must be resolvable during
prerender) and *how the component renders* (via `<HeadContent>` into the existing `<HeadOutlet>`).
- **C3 — One component, parameterized — DRY.** A page supplies only its own specifics; everything else
defaults from a site-wide config. No page re-declares the boilerplate set of ~15 tags. Adding a new
page type means passing a model, not copy-pasting a head block.
- **C4 — SOLID seam.** The component renders; it does not fetch. Page-level data (the `ReleaseDto`, the
home stats) is already loaded by the page's ViewModel — the SEO component is **presentational**, fed
by parameters, exactly like `ReleaseHeroOverlay` / `ReleaseDescription` / `NowShowingPanel`. Defaults
come from an injected config object, not from a data fetch inside the component.
- **C5 — No new fetch path, no new endpoint, no schema change.** Every value is already available:
`ReleaseDto` carries `Title`, `Artist`, `Genre`, `ReleaseDate`, `Description`, `ImagePath`, `EntryKey`,
`Medium`; `HomeStatsDto` backs the home page; the About page is static editorial. If a desired tag has
no source datum, it is either omitted or filled from config — **never** a reason to add a column.
- **C6 — Graceful partial data.** A release with no `Description`, no `ImagePath`, or no `Genre` must
still emit a valid, complete-as-possible head (fall back to config defaults; omit truly optional tags
rather than emit empty ones — mirror `ReleaseDescription`'s "null description renders nothing" rule).
- **C7 — Valid, non-duplicated output.** Exactly one `<title>`, one canonical, one OG block, one JSON-LD
script per page. `<PageTitle>` and the SEO component must not both emit a title — the component owns
the title (it composes `PageTitle` internally) so there is one source of truth. (See OQ4.)
---
## 3. Metadata surface (what the component emits)
The full modern set, grouped. Each row notes its **source** (page param / config default / derived) and
whether it is **always** emitted or **conditional**.
### 3.1 Standard / search
| Tag | Source | Emit |
|-----|--------|------|
| `<title>` | page (composed with config site-name suffix) | always |
| `<meta name="description">` | page; falls back to config default description | always |
| `<link rel="canonical" href>` | derived: config base URL + current path (releases via `ReleaseRoutes`) | always |
| `<meta name="robots">` | config default (`index,follow`); page may override (e.g. `noindex` on `/404`) | always |
| `<meta name="author">` / `<meta name="application-name">` | config (`Deep DRFT`) | optional |
### 3.2 Open Graph (link unfurling — Facebook/iMessage/Discord/Slack)
| Tag | Source | Emit |
|-----|--------|------|
| `og:title` | page (defaults to `<title>` sans suffix) | always |
| `og:description` | page (defaults to meta description) | always |
| `og:url` | = canonical | always |
| `og:type` | page (`website` default; `music.album` / `music.song` for releases — see §4) | always |
| `og:site_name` | config (`Deep DRFT`) | always |
| `og:image` | page (release cover → absolute `…/api/image/{path}`); falls back to config default OG image | always (default guarantees presence) |
| `og:image:alt` | page (e.g. `"{Title} cover art"`) | conditional (when image present) |
| `og:locale` | config (`en_US`) | optional |
| Music-vertical OG (`music:musician`, `music:release_date`, `music:duration`) | release params | conditional (release pages only) |
### 3.3 Twitter Card
| Tag | Source | Emit |
|-----|--------|------|
| `twitter:card` | config (`summary_large_image` when an image exists, else `summary`) | always |
| `twitter:title` / `twitter:description` | mirror OG | always |
| `twitter:image` | mirror `og:image` | always |
| `twitter:site` / `twitter:creator` | config (the collective's handle, if any) | optional — **OQ3** |
### 3.4 JSON-LD structured data (schema.org)
One `<script type="application/ld+json">` per page, shaped by page type. This is where the music domain
gets expressed richly (cuts/sessions/mixes map to schema.org music types):
- **Site-wide / home**`MusicGroup` (the Deep DRFT collective): `name`, `url`, `genre`, `description`,
`logo`, optional `sameAs` (social links — **OQ3**). Optionally a `WebSite` node with `potentialAction`
search (only if a public search surface exists — it does not today; defer).
- **Cut detail** (`/cuts/{key}`, a studio release, possibly multi-track) — `MusicAlbum`:
`name`=Title, `byArtist``MusicGroup`, `albumProductionType``StudioAlbum`, `datePublished`=ReleaseDate,
`genre`, `image`=cover, `url`=canonical, and `track`→ ordered list of `MusicRecording` (the album's
tracks; the page already holds `ViewModel.Tracks` in `TrackNumber` order).
- **Session detail** (`/sessions/{key}`, a live release) — `MusicAlbum` with `albumProductionType`
`LiveAlbum` (schema.org has `LiveAlbum`), or a `MusicEvent`/`MusicRecording` hybrid. **Recommend
`MusicAlbum`+`LiveAlbum`** for parity with cuts and because the catalogue treats a session as a
release, not a calendar event. (Revisit if a live *schedule* page lands — see §7.)
- **Mix detail** (`/mixes/{key}`, a single long continuous track) — `MusicRecording` (one recording,
not an album): `name`, `byArtist`, `duration` (ISO-8601 from `DurationSeconds`), `genre`, `image`,
`url`. A mix is the cleanest single-`MusicRecording` case.
- **About** (`/about`) — `AboutPage` referencing the `MusicGroup`, or simply the `MusicGroup` node again
with the editorial bio as `description`.
- **Browse pages** (`/cuts`, `/sessions`, `/mixes`, `/archive`) — `CollectionPage`, optionally with an
`ItemList` of the releases shown. Lighter touch; the detail pages carry the rich per-release schema.
> JSON-LD is the highest-leverage, music-specific part of this spec and the part most worth getting
> right. It is also the part with the most modeling latitude — the exact node shapes above are a
> **recommendation**; the precise schema.org property set is a refinement staff-engineer can tune
> against Google's Rich Results test (see §8 AC5). The spec fixes *which schema type maps to which
> medium* (the product decision) and leaves property-level polish to implementation.
---
## 4. Component design (the contract)
### 4.1 Shape: one component + a typed model + a config
Three pieces, each a clean SOLID responsibility:
1. **`SeoHead.razor`** — the single reusable presentational component. Lives in `DeepDrftPublic.Client`
(it must render in the WASM-shared component graph so it works in both prerender and interactive
passes — see §5). Renders a `<PageTitle>` and a `<HeadContent>` block containing all of §3. Owns no
data fetch and no business logic. Parameterized over a model (below). It reads the injected
`SeoOptions` for defaults and `NavigationManager` for the current absolute URL (canonical/`og:url`).
2. **`SeoModel`** (a record/class in `Common/`) — the typed per-page input. Rather than ~15 loose
`[Parameter]`s, the page hands `SeoHead` one model. Suggested surface:
- `Title` (string, required) — page title sans site suffix.
- `Description` (string?) — falls back to `SeoOptions.DefaultDescription`.
- `CanonicalPath` (string?) — defaults to `NavigationManager`'s current relative path; release pages
pass `ReleaseRoutes.DetailHref(...)` so the canonical is stable regardless of alias routes
(`/tracks/...` redirects, query strings).
- `ImagePath` (string?) — relative cover path; component absolutizes to `…/api/image/{escaped}`;
falls back to `SeoOptions.DefaultImageUrl`.
- `OgType` (enum/string, default `Website`).
- `Robots` (string?, default from config `index,follow`).
- `JsonLd` (a `RenderFragment` **or** a typed structured-data object) — the page supplies its
schema.org node; see 4.3 for the build-vs-pass decision (OQ5).
- Music-specific optionals used only by release pages: `Artist`, `Genre`, `ReleaseDate`,
`DurationSeconds`, and (for albums) the ordered track list.
A small set of **named factory helpers** keeps call sites terse and DRY — e.g.
`SeoModel.ForRelease(ReleaseDto, tracks?)`, `SeoModel.ForHome(HomeStatsDto)`, `SeoModel.ForAbout()`,
`SeoModel.ForBrowse(medium)`. Each factory encodes the medium→`OgType`→JSON-LD mapping from §3.4 in
exactly one place (DRY: a page never re-derives "a mix is a `MusicRecording`"). These factories are
pure functions over DTOs the page already holds — unit-testable without rendering.
3. **`SeoOptions`** (config, in `Common/`) — site-wide defaults: `SiteName` (`Deep DRFT`),
`TitleSuffix`, `DefaultDescription`, `BaseUrl` (the canonical production origin — **OQ1**),
`DefaultImageUrl`, `TwitterSite`/`TwitterCreator` (**OQ3**), `DefaultRobots`, `Locale`, `Genre`,
social `sameAs` links (**OQ3**). Registered in `Startup.ConfigureDomainServices` (the existing seam
that runs in **both** server and WASM `Program.cs`, per the project's static-Startup convention).
Source values from `appsettings.json` server-side; the WASM pass either hardcodes the same constants
or receives them via the existing config seam. **Note:** these are non-secret brand constants — they
belong in `appsettings.json` / a constants class, not `environment/` secrets.
### 4.2 How pages supply their specifics (DRY in practice)
- **Home** (`Home.razor`): `<SeoHead Model="SeoModel.ForHome(stats)" />` — replaces the current bare
`<PageTitle>`. Description from config or a curated home string; JSON-LD = `MusicGroup`.
- **About** (`/about`): `<SeoHead Model="SeoModel.ForAbout()" />` — static; description = the bio
lede; JSON-LD = `MusicGroup`/`AboutPage`.
- **Release detail** (`CutDetail`/`SessionDetail`/`MixDetail`): `<SeoHead Model="@_seo" />` where
`_seo = SeoModel.ForRelease(ViewModel.Release, ViewModel.Tracks)` — set once the release is resolved.
The factory reads `Medium` and picks `MusicAlbum` (cut/session) vs `MusicRecording` (mix), the
`og:type`, the canonical via `ReleaseRoutes`, and the cover image. **One call site, all 15+ tags.**
- **Browse** (`AlbumsView`/`SessionsView`/`MixesView`/`ArchiveView`): `SeoModel.ForBrowse(medium)`.
- **404** (`NotFound`): `SeoModel` with `Robots = "noindex,follow"`.
Each page touches **one line**. The boilerplate lives in `SeoHead` + the factories; the per-page values
flow in through the model. That is the DRY mechanism C3 demands.
### 4.3 Build-vs-pass for JSON-LD (the one genuine design fork — OQ5)
Two ways to produce the `<script type="application/ld+json">` body:
- **(a) Typed builder:** `SeoModel` carries strongly-typed structured-data objects (small C# records
mirroring the schema.org nodes) that a serializer renders to JSON. **Pros:** type-safe, unit-testable,
DRY (the medium→type mapping is C# in the factories), no hand-written JSON in pages. **Cons:** a small
amount of schema.org-shaped record plumbing to build once.
- **(b) RenderFragment / raw string:** the page hands `SeoHead` a pre-built JSON-LD fragment. **Pros:**
trivial component. **Cons:** pushes JSON authoring into pages (violates C3/DRY), easy to get invalid,
not testable.
**Recommend (a)** — the typed builder. It is the only option that honors DRY (the medium→schema mapping
must live in one place) and is testable (AC5 wants Rich-Results validity; pure builders make that a unit
test, not a manual check). The record set is small (a `MusicGroup`, a `MusicAlbum` with a `track` list,
a `MusicRecording`) and confined to `Common/`. **This is the load-bearing implementation choice and is
recorded as OQ5 for Daniel to confirm.**
### 4.4 SOLID / road-not-taken rationale
- **SRP:** `SeoHead` renders; `SeoModel` factories map DTOs→SEO shape; `SeoOptions` holds defaults;
pages fetch (already do). No responsibility crosses a boundary it does not already own — identical to
the `ReleaseHeroOverlay`/`ReleaseDescription` presentational-component pattern already in the codebase.
- **OCP:** a new page type or a fourth medium adds a factory method, not a new tag block. The medium
switch lives next to `ReleaseRoutes`' existing medium switch (same "Cut is the default arm so a gap is
build-visible" discipline).
- **DRY:** the ~15-tag boilerplate exists once. Pages pass values; the suffix inconsistency observed
today (`- DeepDrft` vs `- Electronic Music Collective`) is resolved by `SeoOptions.TitleSuffix`.
- **Road not taken — a server-side middleware/filter that rewrites `<head>`.** Tempting (one place,
zero component change) but it cannot see Blazor's per-page render state cleanly, fights the
`HeadOutlet` mechanism Blazor provides for exactly this, and would be a parallel metadata path the
team has to reason about separately. Rejected: use the framework's head seam, not an HTTP filter.
- **Road not taken — per-page hand-written `<HeadContent>` blocks (no shared component).** This is just
the status quo extended; it is the boilerplate-duplication C3 forbids. Rejected.
---
## 5. Render-mode correctness (the load-bearing requirement — C2)
This is the part most likely to be got subtly wrong, so it is spelled out.
**The mechanism.** Blazor's `<HeadContent>` projects child content into the `<HeadOutlet>` declared in
`App.razor`. During the **server prerender pass**, components render to HTML server-side *before* WASM
boots; their `<HeadContent>` is written into the `<head>` of the delivered document. A crawler fetching
the page sees the fully-populated `<head>` in the initial HTML response — exactly what C2 requires —
**provided the data the head depends on is available during that prerender pass.**
**Why this works here (the key enabler).** The release detail pages already resolve their `ReleaseDto`
during prerender and bridge it across the WASM seam via `PersistentComponentState` (documented in
`DeepDrftPublic.Client/CLAUDE.md` and the `tracksview-persistent-state-seam` memory). The SEO data is a
**projection of that same already-prerendered DTO.** So `SeoHead`, fed by the page's ViewModel, emits
correct tags during prerender with **no new fetch and no new bridge** — it rides the one the page
already has. This is why the component must be presentational and parameter-fed (C4): it inherits the
page's prerender-readiness for free.
**The render-mode flags to get right:**
- `<HeadOutlet>` in `App.razor` is currently `@rendermode="InteractiveAuto"`. The **prerender** of that
outlet still happens server-side (Auto prerenders on the server first), so prerendered head content is
emitted. **Confirm during implementation** that prerender is not disabled for these pages — if any SEO
page were ever set `prerender: false` (as `BatchUpload` in the CMS is), its head would be empty for
crawlers. None of the public SEO-target pages disable prerender today; the spec's requirement is that
they must not.
- **`SeoHead` lives in `DeepDrftPublic.Client`** so it participates in both the server-prerender render
tree and the WASM interactive render tree (the project's "static Startup called from both Program.cs"
convention guarantees identical DI in both passes). Putting it only in the server host would break the
interactive pass; putting it only in the client without prerender would break crawlers.
**The risk to flag — the `InteractiveAuto`/WASM boundary and double-render.** On an Auto page the head
is rendered **twice**: once server-side (prerender, what crawlers see) and again when the component
re-renders client-side after WASM boot. Two cautions:
1. **Idempotent, identical output across passes.** The tags the WASM pass produces must match the
prerender pass (same canonical, same OG, same JSON-LD), or the client re-render will replace correct
tags with different ones. Because the data is bridged via `PersistentComponentState` (not re-fetched),
the two passes see the same `ReleaseDto` and produce identical head content — **as long as the model
is built from the bridged state, not a fresh client fetch.** Guard the same way detail pages already
guard their restore: on id/key equality, to prevent cross-item bleed when prerender and WASM-boot
disagree on the current item (the documented `OnParametersSetAsync` rule).
2. **Canonical/`og:url` absolutization must not depend on a browser-only API.** During server prerender
there is no `window.location`; the absolute base must come from `SeoOptions.BaseUrl` (config), not
from JS interop. `NavigationManager` is available server-side for the *path*; the *origin* comes from
config (this is also why `BaseUrl` is OQ1 — the canonical origin is a product decision, not
discoverable at prerender from the request reliably behind the nginx proxy).
**Net:** the approach emits correct tags server-side at prerender because it projects already-prerendered
data through the framework's own head seam. The only genuinely new care points are (1) identical
output across the double render, solved by feeding from bridged state, and (2) config-sourced origin for
absolute URLs, solved by `SeoOptions.BaseUrl`.
---
## 6. Use cases
- **UC1 — A `/mixes/{key}` link pasted into Discord/iMessage unfurls richly.** Title, description, and
cover image appear in the unfurl card (OG tags present at prerender). Today: bare title + URL.
- **UC2 — Google indexes a cut with structured data.** The `MusicAlbum` JSON-LD with its `track` list is
eligible for rich results; canonical points at `/cuts/{key}` regardless of how the user arrived
(alias `/tracks/...` route, query params).
- **UC3 — The home page presents the collective.** `MusicGroup` JSON-LD + site-level OG so the root URL
unfurls and is indexed as the band's entity.
- **UC4 — A release with no cover / no description still has valid metadata.** Falls back to the config
default OG image and default description; omits `og:image:alt`; emits valid (if leaner) tags (C6).
- **UC5 — The 404 page is not indexed.** `NotFound` passes `Robots = "noindex,follow"`.
- **UC6 — Twitter/X card renders large-image.** `summary_large_image` when a cover exists, `summary`
otherwise.
---
## 7. Open questions for Daniel (product calls, not implementation detail)
- **OQ1 — Canonical production origin (`BaseUrl`).** What is the canonical public origin for absolute
canonical/`og:url`/`og:image` URLs (e.g. `https://deepdrft.com`)? This must be a fixed config value —
it cannot be reliably derived at prerender behind the nginx reverse proxy, and getting it wrong
silently breaks every absolute URL an unfurler resolves. **Also:** is there a single canonical host, or
do www/apex/staging variants need a canonical-host normalization rule? `[Daniel decision]`
- **OQ2 — Default OG image.** What is the fallback share image for pages without a cover (home, about,
browse, and cover-less releases)? A branded 1200×630 card is the OG standard. Is there an existing
brand asset to use, or does one need to be produced? Until one exists, the component can omit
`og:image` (degrades to a `summary` Twitter card) — but a default image materially improves every
unfurl. `[Daniel decision — and an asset to point at]`
- **OQ3 — Social handles / `sameAs`.** Does Deep DRFT have public social accounts (X/Twitter handle for
`twitter:site`/`creator`, and URLs for the `MusicGroup.sameAs` array)? If yes, supply them for the
config; if no, those tags are simply omitted (valid). `[Daniel decision]`
- **OQ4 — Title suffix + composition.** Standardize the title pattern: recommend `"{PageTitle} · Deep
DRFT"` (or `" - Deep DRFT"`), with the home page as a special case (`"Deep DRFT — Electronic Music
Collective"`). Confirm the suffix string and separator; this resolves the existing inconsistency
(`- DeepDrft` vs `- Electronic Music Collective`). `[Daniel decision — low stakes, pick one]`
- **OQ5 — JSON-LD: typed builder vs. passed fragment (§4.3).** Recommend the **typed builder** (option a)
for DRY + testability. Confirm, or accept the lighter passed-fragment approach if the record plumbing
is judged not worth it. `[Daniel decision — recommendation: typed builder]`
- **OQ6 — Session schema type.** Recommend modeling a Session as `MusicAlbum` + `LiveAlbum`
`albumProductionType` (parity with cuts; the catalogue treats a session as a release, not an event).
Confirm — or, if a live *schedule* surface is ever planned, a session might better be a `MusicEvent`.
`[Daniel decision — recommendation: MusicAlbum/LiveAlbum for now]`
### Adjacent but separate concerns (flagged, not specified here)
These are SEO-adjacent and worth their own small follow-ups; they are **not** in this component's scope:
- **`robots.txt`** — a static file (or a minimal endpoint) at the public host root: allow crawl, point at
the sitemap, and **disallow the CMS host** (`DeepDrftManager` is a separate app/host — its exclusion is
a deployment/robots concern, not a CMS code change). Small, separate task.
- **`sitemap.xml`** — a generated sitemap enumerating home/about/browse + every release detail URL. This
*does* need a small server-side endpoint on the public host that lists releases (reusing the existing
paged release read — no new data) and emits XML. A natural Phase 22.x follow-on, but a different unit
of work (an endpoint, not a component). Flag for Daniel: **want this folded into Phase 22 as a second
wave, or tracked as its own phase?**
- **CMS `noindex`** — ensuring `DeepDrftManager` pages are not indexed. Out of scope for this public-site
component (C1); noted so it is not forgotten. Cheapest fix is a robots disallow on the CMS host +/- a
blanket `noindex` meta in the CMS layout — a CMS-side change for a later, separately-scoped task.
---
## 8. Acceptance criteria
- **AC1 — Tags present in prerendered HTML.** `curl`-ing (no JS execution) any public SEO-target page
returns a `<head>` containing title, description, canonical, the full OG block, the Twitter block, and
a JSON-LD script — populated with that page's specifics. (The crawler-visibility guarantee, C2.)
- **AC2 — One component, one line per page.** Each target page invokes `SeoHead` with a single model;
no page hand-writes the tag set. Adding a hypothetical new page type requires a new factory method,
not a new tag block (C3/OCP).
- **AC3 — Release pages carry correct per-medium schema.** A cut → `MusicAlbum` with an ordered `track`
list; a session → `MusicAlbum`/`LiveAlbum`; a mix → `MusicRecording` with ISO-8601 `duration`. Title,
artist, genre, date, cover, and canonical all match the release.
- **AC4 — Graceful partial data.** A release with no description/cover/genre still emits valid head
content (config-default description, config-default or omitted image, omitted optional tags) — no empty
`content=""` tags, no broken JSON-LD (C6).
- **AC5 — Structured data validates.** The emitted JSON-LD passes Google's Rich Results / Schema Markup
Validator for the chosen types (no errors; warnings acceptable). Pure-function builders make this a
unit test plus one manual validator pass.
- **AC6 — Identical output across the double render.** The head content produced by the WASM interactive
pass is byte-identical to the prerender pass for the same page/item (fed from bridged
`PersistentComponentState`, not a fresh fetch) — no client re-render clobbering correct tags (§5).
- **AC7 — Canonical correctness.** Canonical and `og:url` are absolute (config origin + resolved path),
point at the dedicated release route (not an alias/redirect path or a query-string variant), and are
identical to each other.
- **AC8 — 404 is `noindex`.** The not-found page emits `robots: noindex`.
- **AC9 — Zero CMS change.** No file under `DeepDrftManager` is touched (C1).
---
## 9. Wave decomposition
Dependency shape: `22.1 → 22.2 → 22.3`, with `22.4` validating. `22.1` is the cold-start prerequisite.
- **22.1 — Core component + config + model, on the simplest pages (cold-start).** Build `SeoHead`,
`SeoModel` (+ the standard/OG/Twitter tag rendering), and `SeoOptions` (registered via the static
`Startup` seam). Wire the **static pages first** — Home and About — where data is trivially available
at prerender and there is no double-render subtlety beyond the baseline. Proves §5's prerender
emission end to end (AC1) on the easy case. **Depends on OQ1/OQ2/OQ4** (origin, default image, suffix)
— these are config values it needs; can stub with placeholders and swap in Daniel's answers.
- **22.2 — Release detail pages + per-medium JSON-LD (the rich case).** Add the `MusicGroup` /
`MusicAlbum` / `MusicRecording` builders (OQ5 recommendation: typed) and the `ForRelease` factory with
the medium→type mapping; wire `CutDetail`/`SessionDetail`/`MixDetail` from their already-bridged
`ReleaseDto`. This is where AC3/AC5/AC6/AC7 are exercised. **Depends on 22.1** and on OQ5/OQ6.
- **22.3 — Browse + 404 + remaining pages.** `CollectionPage` for browse surfaces; `noindex` for 404;
any remaining public routes. **Depends on 22.1.**
- **22.4 — Validation pass.** Exercise AC1AC9: crawler-view via no-JS fetch (AC1), Rich Results
validator (AC5), double-render-identity check (AC6), canonical/alias matrix (AC7), partial-data
releases (AC4). Largely test/measurement. **Depends on 22.122.3.**
- **(Adjacent, separate) — `robots.txt` + `sitemap.xml`.** Per §7, an endpoint-shaped follow-on, not a
component wave. Tracked as its own unit pending Daniel's call on folding-in vs. separate phase.
---
## 10. Cross-references (read before implementing)
- `DeepDrftPublic/Components/App.razor` — the `<head>` block + `<HeadOutlet @rendermode="InteractiveAuto">`
this component feeds; the place to confirm prerender is not disabled.
- `DeepDrftPublic.Client/CLAUDE.md` — the `PersistentComponentState` prerender-bridge convention, the
`OnParametersSetAsync` same-template-nav rule, the static-`Startup`-called-from-both-`Program.cs`
DI convention (where `SeoOptions` registers), and the presentational-component pattern `SeoHead` follows.
- Auto-memory `tracksview-persistent-state-seam` — why the bridge matters for the double-render identity
(AC6); the SEO model must be built from bridged state, not a fresh client fetch.
- `DeepDrftPublic.Client/Common/ReleaseRoutes.cs` — the canonical-URL source for release pages; the SEO
canonical reuses it (and extends the same "Cut is the default arm" medium-switch discipline).
- `DeepDrftPublic.Client/Pages/{Home,CutDetail,SessionDetail,MixDetail,About}.razor` — the current bare
`<PageTitle>` usage these pages replace; the ViewModels (`ReleaseDto`, `HomeStatsDto`) that feed the model.
- `DeepDrftPublic.Client/Controls/SharePopover.razor` — already builds absolute share URLs from
`ReleaseRoutes`; the canonical/OG URL is the same absolutization, sourced from `SeoOptions.BaseUrl`.
- `DeepDrftModels` (`ReleaseDto`, `TrackDto`, `HomeStatsDto`) — the data the model projects; no new field
needed (C5).
- Google Rich Results / Schema.org `MusicGroup`/`MusicAlbum`/`MusicRecording`/`LiveAlbum` docs — the
validation target (AC5) and the type vocabulary for §3.4.
@@ -0,0 +1,370 @@
# Phase 23 — SEO Crawl Directives (sitemap.xml, robots.txt, CMS noindex)
Product spec. Status: **design / framing — implementation-ready pending Daniel's open-question calls.**
Author: product-designer. Date: 2026-06-23. **No code has been written by this doc.**
Phase 23 is the **endpoint/file-shaped follow-on** to Phase 22's per-page `SeoHead` component. Phase 22 flagged
these three as "adjacent but separate concerns" (`product-notes/phase-22-seo-metadata-component.md §7`): they
are a different *unit of work* — server-side endpoints and static files that tell crawlers **which** pages exist
and **whether** to crawl them at all, as opposed to the per-page head surface that tells crawlers **what each
page is**. Phase 22 is the *content* of discoverability; Phase 23 is the *directives* layer above it.
Three items, each independently shippable:
1. **`sitemap.xml`** on the public host — a generated sitemap enumerating every indexable public URL.
2. **`robots.txt`** on the public host — allow + sitemap pointer in Production, `Disallow: /` everywhere else.
3. **CMS `noindex`** on `DeepDrftManager` — the admin app must never be indexed. The **one** item touching the CMS.
---
## 1. The environment gate is the through-line (read this first)
Phase 22 established the rule that **every non-production environment must be uncrawlable** — the beta/staging
host must not appear in search results, and a stray crawl of staging must not dilute or duplicate the production
site. Phase 22 expressed this for *page-level robots meta* via `SeoEnvironment` (a `[PersistentState]` bridge
seeded from `IWebHostEnvironment.IsProduction()`, because `SeoHead` renders in the **WASM** component graph and
WASM has no `IWebHostEnvironment`).
**Phase 23's three items all run server-side only** (endpoints and static files, never the WASM render tree), so
they read the gate the simplest possible way: **`IWebHostEnvironment.IsProduction()` injected directly.** They do
**not** need the `SeoEnvironment` PersistentState bridge — that bridge exists *solely* to ferry the flag across
the server→WASM seam, which these never cross. This is the correct reuse: same source of truth
(`IWebHostEnvironment.IsProduction()`, the exact predicate `App.razor` already seeds `SeoEnvironment` from), no
parallel gate invented, and no PersistentState plumbing where it isn't needed.
| Concern | Renders where | Gate mechanism |
|---|---|---|
| Phase 22 `SeoHead` robots meta | WASM component graph | `SeoEnvironment` `[PersistentState]` bridge (server seed → WASM read) |
| Phase 23 sitemap / robots / CMS | server-side endpoint or static file | `IWebHostEnvironment.IsProduction()` injected directly |
**Invariant E1 (the non-negotiable):** in any non-production environment, `robots.txt` is `Disallow: /` and the
sitemap is either not served or empty. A crawler must see a closed door on beta before it sees a single URL.
The fail-safe default (matching Phase 22's `SeoEnvironment` fail-safe-to-`noindex`) is **closed**: if environment
resolution is ever ambiguous, behave as non-production (disallow).
---
## 2. The architecture seam (where this code lives, and what it must not become)
Per the project convention (root `CLAUDE.md`; `DeepDrftPublic/CLAUDE.md`): **the public host owns thin HTTP
boundaries; domain logic lives in `*.Services` libraries or `DeepDrftAPI`.** Generated XML/text is a *rendering*
of data the host already has access to — it belongs in a **thin endpoint on `DeepDrftPublic`**, and any list
logic it needs must **reuse the existing release read**, not re-implement enumeration.
- **`sitemap.xml`** is *not* a pass-through proxy like `ReleaseProxyController` (which relays JSON verbatim). It
**enumerates** releases and **transforms** them into a different media type (XML). So it is a new endpoint that
*calls* the upstream `GET api/release` paged read (server-to-server via the existing `"DeepDrft.API"` named
`HttpClient`, the same client SSR prerender already uses — no proxy hop, no new data-layer code, no schema
change) and walks the pages to build the URL set. **C5 from Phase 22 holds:** no new API endpoint on
`DeepDrftAPI`, no schema change — the existing `PagedResult<ReleaseDto>` read is sufficient (it carries
`EntryKey`, `Medium`, and `ReleaseDate` — everything a `<url>` entry needs).
- **The URL composition reuses Phase 22's seams, not new ones:** absolute origin from `SeoOptions.BaseUrl`
(`https://deepdrft.com` — config, because the origin can't be derived behind the nginx proxy), and per-release
detail paths from `ReleaseRoutes.DetailHref(entryKey, medium)` (the single source of truth the Cut/Session/Mix
pages, the player bar, and `SharePopover` all already use). The sitemap thereby lists the *exact* canonical
URLs `SeoHead` emits as `<link rel="canonical">` — by construction, not by coincidence.
> **Seam note for staff-engineer.** `SeoOptions` and `ReleaseRoutes` currently live in `DeepDrftPublic.Client`
> (`Common/`). A server-side endpoint on `DeepDrftPublic` (the host) references the client assembly already (it
> loads `DeepDrftPublic.Client._Imports` as an additional WASM assembly and shares the static `Startup`), so the
> host can read these types. Confirm the reference direction at implementation; if `SeoOptions.BaseUrl` is not
> cleanly reachable from a host controller, the minimal move is to source `BaseUrl` from the same config the
> client `SeoOptions` is seeded from (it is a non-secret brand constant — `appsettings.json`, per Phase 22 §4.1),
> **not** to duplicate the constant. This is a wiring detail, not a design fork.
---
## 3. Item 1 — `sitemap.xml`
### 3.1 Mechanism and location
A new thin endpoint on `DeepDrftPublic` serving `GET /sitemap.xml` with content-type `application/xml`. It is an
endpoint (not a static file and not a Razor component) because the URL set is **dynamic** — it must include every
release detail URL, which changes as releases are added. A static file would go stale the moment a release lands.
Recommended placement: a small `SitemapController` (or a minimal-API endpoint in `Program.cs`) alongside the
existing proxy controllers in `DeepDrftPublic/Controllers/`. It is a host concern (HTTP surface + rendering),
exactly the layer the proxy controllers occupy. It injects `IWebHostEnvironment` (the gate) and
`IHttpClientFactory` (to call `"DeepDrft.API"`), mirroring `ReleaseProxyController`'s constructor shape.
### 3.2 What it enumerates
The indexable public URL set, all absolutized against `SeoOptions.BaseUrl`:
- **Static roots:** `/` (home), `/about`, and the four browse surfaces `/cuts`, `/sessions`, `/mixes`,
`/archive`. These are a fixed list (a small in-endpoint constant array, or — cleaner — derived from the same
nav index the site already maintains; see OQ-S3).
- **Every release detail URL:** walk `GET api/release?page=N&pageSize=…` until `PageNumber * PageSize >=
TotalCount`, and for each `ReleaseDto` emit `BaseUrl + ReleaseRoutes.DetailHref(dto.EntryKey, dto.Medium)` —
i.e. `/cuts/{key}`, `/sessions/{key}`, `/mixes/{key}`. No `medium` filter on the query (we want all media in
one pass); a generous `pageSize` (e.g. 100200) keeps the walk to a handful of round-trips even for a large
catalogue.
### 3.3 XML shape
Standard sitemaps.org `urlset`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://deepdrft.com/</loc></url>
<url><loc>https://deepdrft.com/about</loc></url>
<url><loc>https://deepdrft.com/cuts</loc></url>
<!-- … browse roots … -->
<url>
<loc>https://deepdrft.com/mixes/3f2a9c…</loc>
<lastmod>2026-05-12</lastmod> <!-- optional; from ReleaseDate — see OQ-S2 -->
</url>
<!-- … one <url> per release … -->
</urlset>
```
- `<loc>` is required and must be a fully-qualified absolute URL (the reason `BaseUrl` is mandatory).
- `<lastmod>` is **optional** and recommended from `ReleaseDto.ReleaseDate` (W3C date format `YYYY-MM-DD`) **for
release URLs only** — static roots have no natural lastmod and omit it. See **OQ-S2** (ReleaseDate is the
*release* date, not a content-modified date — it is a reasonable proxy but not strictly correct; the safe call
is to include it, as a stale-but-plausible lastmod is better than none and crawlers treat it as a hint).
- **No** `<changefreq>` / `<priority>` — both are widely ignored by Google and add noise. Omit them.
### 3.4 Failure posture
The endpoint must degrade gracefully — a sitemap that 500s trains crawlers to stop fetching it. If the upstream
`api/release` walk fails partway, **emit what was gathered** (static roots are always available; partial release
set is better than none) and log the failure. Never 500 the sitemap. (Mirrors `ReleaseProxyController`'s
philosophy of not collapsing valid-but-partial states, adapted to "always return a well-formed document.")
### 3.5 Acceptance criteria (sitemap)
- **AC-S1 — Valid + complete.** `GET /sitemap.xml` (in Production) returns well-formed `urlset` XML that
validates against the sitemaps.org schema and contains: the 6 static roots **and** exactly one `<url>` per
non-deleted release, addressed by `ReleaseRoutes.DetailHref` (so every `<loc>` equals the page's canonical).
- **AC-S2 — Absolute URLs.** Every `<loc>` is `https://deepdrft.com/…` (config origin, not a relative path, not
a proxy-derived host).
- **AC-S3 — Pagination walk is exhaustive.** A catalogue larger than one page is fully enumerated (no releases
dropped at a page boundary); a catalogue of zero releases yields a valid sitemap of just the static roots.
- **AC-S4 — Environment-gated.** In a non-production environment, `/sitemap.xml` is either not served (404) or
served empty/`Disallow`-consistent — it must never advertise beta release URLs to a crawler (E1). Recommend
**404 in non-production** (simplest; nothing references it because the non-prod `robots.txt` carries no
`Sitemap:` line — see Item 2).
- **AC-S5 — Resilient.** An upstream `api/release` failure yields a well-formed sitemap of the static roots (and
any releases gathered before the failure), logged — never a 500.
---
## 4. Item 2 — `robots.txt`
### 4.1 Mechanism and location — the static-vs-endpoint tradeoff (flagged)
`robots.txt` must express the environment gate (`Disallow: /` on beta, allow + sitemap pointer in Production). A
**static file** in `wwwroot/` **cannot** do this — it serves identical bytes in every environment. So the
content is environment-dependent and wants a **tiny endpoint** (`GET /robots.txt`, content-type `text/plain`),
injecting `IWebHostEnvironment` for the gate.
Three options, with the recommendation:
- **(a) Endpoint `GET /robots.txt` [RECOMMENDED].** A few lines of code in the same place as the sitemap
endpoint; reads `IWebHostEnvironment.IsProduction()`; emits the production or non-production body. Single source
of truth for the gate, co-located with the sitemap, no infra dependency. The body is trivial.
- **(b) Static file + reverse-proxy rule.** Ship a production `robots.txt` in `wwwroot/` and have nginx serve a
`Disallow: /` variant (or block the file) on the beta host. **Cons:** splits the gate across app + nginx config
(two places to reason about, two places to get wrong); the beta protection lives in infra the app can't test;
Daniel would maintain an nginx rule per environment. Rejected unless Daniel specifically wants robots managed at
the proxy layer.
- **(c) Static file only.** Cannot express the gate at all — would either crawl-allow beta (violates E1) or
disallow production. **Rejected outright.**
The endpoint (a) is the natural sibling to the sitemap endpoint and keeps E1 in one testable place. Note the
ordering subtlety from `DeepDrftPublic/CLAUDE.md`: static-file middleware runs before component/controller
mapping, so **if** a literal `wwwroot/robots.txt` ever exists it would shadow the endpoint — the endpoint
approach requires that no static `robots.txt` is shipped (a one-line thing to verify, called out so it isn't
tripped over).
### 4.2 Content
**Production:**
```
User-agent: *
Allow: /
Sitemap: https://deepdrft.com/sitemap.xml
```
**Every non-production environment (beta/staging):**
```
User-agent: *
Disallow: /
```
- The `Sitemap:` line uses the absolute `SeoOptions.BaseUrl` origin (same config source as the sitemap's
`<loc>`s) — it is the one documented way to point crawlers at the sitemap without submitting it manually.
- The non-production body carries **no** `Sitemap:` line (consistent with AC-S4's "don't advertise beta URLs").
- Consider whether to additionally `Disallow: /FramePlayer` and the `api/*` proxy paths in Production (OQ-R2) —
the embed iframe and the JSON/stream proxy endpoints are not pages worth crawling.
### 4.3 Acceptance criteria (robots)
- **AC-R1 — Production allows + points.** `GET /robots.txt` on the production host returns `Allow: /` and a
`Sitemap: https://deepdrft.com/sitemap.xml` line.
- **AC-R2 — Beta disallows everything.** `GET /robots.txt` on any non-production host returns `User-agent: *` +
`Disallow: /` and **no** `Sitemap:` line (E1).
- **AC-R3 — Single gate.** The Production-vs-beta distinction is driven by `IWebHostEnvironment.IsProduction()`
the same predicate as the sitemap and as Phase 22's `SeoEnvironment` seed — not a second config flag.
- **AC-R4 — `text/plain`.** Correct content-type; no BOM/HTML wrapper.
---
## 5. Item 3 — CMS `noindex` (the one CMS-touching item)
**This is the only Phase 23 item that touches `DeepDrftManager`.** Scoped, minimal, admin-chrome-only — **no
functional change** to any CMS page, no service/API/data change. `DeepDrftManager` is an authenticated admin app
that must never appear in any search index, in any environment (it has no "production is fine to index" case —
the CMS is *always* `noindex`, unlike the public site whose gate flips per environment).
### 5.1 Mechanism — defense in depth, cheapest-robust
Two layers; recommend **both** because they fail independently and the cost is trivial:
- **(a) `robots.txt` on the CMS host [primary].** A `Disallow: /` `robots.txt` served at the CMS root. Because the
CMS is *always* uncrawlable (no environment gate), this can be the **simplest possible static file** in the CMS
`wwwroot/` — no endpoint, no environment logic:
```
User-agent: *
Disallow: /
```
This is the cleanest single move and differs from the public `robots.txt` precisely because there is no
per-environment branch to express.
- **(b) Blanket `<meta name="robots" content="noindex,nofollow">` in the CMS layout `<head>` [belt-and-braces].**
A static meta tag in the CMS app's root `App.razor`/host `<head>` (the CMS's analogue of the public
`App.razor`'s static head block). This protects against the case where a crawler reaches a deep CMS URL that
`robots.txt` disallow doesn't *de-index* (robots disallow prevents *crawling*, but a URL linked from elsewhere
can still be *indexed* without crawling; an on-page `noindex` is what actually keeps it out of the index). It is
a single static line in the CMS host head — no per-page wiring, no component, no `SeoHead` port (the CMS does
**not** get Phase 22's component; this is one blanket tag).
Layer (a) is the floor; layer (b) is the robust ceiling. Together they cost a static file plus one `<head>` line.
### 5.2 Why the CMS does *not* reuse Phase 22's `SeoHead` / `SeoEnvironment`
Phase 22 C1/C9 explicitly kept the CMS out of scope ("Zero changes to `DeepDrftManager`"). Phase 23 makes the
**one** deliberate, minimal exception — but it does **not** drag the public component graph into the CMS. The CMS
need is a single constant directive ("never index"), not a parameterized per-page head surface; porting `SeoHead`
(a `DeepDrftPublic.Client` WASM component) into the server-rendered CMS would be wildly disproportionate. The
blanket meta + static robots is the right-sized answer. (And `SeoEnvironment`'s per-environment flip is
irrelevant here — the CMS is `noindex` in *all* environments, including production.)
### 5.3 Acceptance criteria (CMS noindex)
- **AC-C1 — CMS robots disallows.** `GET /robots.txt` on the CMS host returns `User-agent: *` + `Disallow: /`.
- **AC-C2 — Every CMS page carries `noindex`.** Any CMS page's prerendered `<head>` contains
`<meta name="robots" content="noindex,nofollow">` (the blanket layout tag), including the public-facing
`/account/login` and `/account/register` routes (which render in the lean `CmsHomeLayout`) and the home splash.
Confirm the meta lands in whichever head block both layouts inherit (the CMS host `App.razor`), so a
layout-specific head doesn't leave a route uncovered.
- **AC-C3 — No functional change.** No CMS page's behavior, auth gate, layout, or data path changes — the diff is
a static `robots.txt` and a static `<meta>` line. (Aligns with Phase 22 AC9's spirit, now scoped as the
intentional CMS exception.)
- **AC-C4 — Always-on (no env gate).** The CMS `noindex` holds in production too — it is unconditional, unlike the
public site.
---
## 6. Wave decomposition
These are **largely independent** — three separate surfaces with one shared concept (the env gate) and one shared
config value (`BaseUrl`). The dependency graph is shallow.
- **23.1 — Public env-gate primitives + `robots.txt` endpoint (cold-start, shared seam).** Stand up the
server-side `IWebHostEnvironment`-gated endpoint pattern on `DeepDrftPublic` and ship `GET /robots.txt`
(Production allow+sitemap-pointer / non-prod `Disallow: /`). This is the smallest item and it establishes the
**shared gate + BaseUrl wiring** that 23.2 also uses, so doing it first de-risks the seam. Resolves the
static-vs-endpoint call (OQ-R1). **Cold-start; nothing depends on it being done first except that 23.2 reuses
the same gate wiring.**
- **23.2 — `sitemap.xml` endpoint.** The release-enumeration walk over `GET api/release` + XML emission +
`ReleaseRoutes`/`BaseUrl` absolutization + the env gate (404 in non-prod). The largest item. **Shares the gate
+ BaseUrl wiring with 23.1** (do 23.1 first or co-develop; they touch the same controller area). The
`Sitemap:` line in 23.1's production `robots.txt` points at this — so 23.1's production body assumes 23.2 exists
(harmless if 23.2 lands slightly later: a `Sitemap:` pointer to a not-yet-built URL just 404s until it does).
- **23.3 — CMS `noindex` (the CMS-side item).** Static `robots.txt` (`Disallow: /`) in the `DeepDrftManager`
`wwwroot/` + blanket `<meta name="robots" content="noindex,nofollow">` in the CMS host `<head>`. **Fully
independent — touches only `DeepDrftManager`, shares nothing with 23.1/23.2, can run in parallel from day one.**
**Dependency shape:** `23.1 → 23.2` (shared gate/BaseUrl wiring + the `Sitemap:` pointer relationship); **23.3 ∥**
(parallel, independent, different app). The cold-start item is **23.1** (it proves the gate seam the public side
leans on); **23.3** can run start-to-finish alongside either.
**Validation (folded into each wave's ACs, not a separate wave):** the items are small enough that a dedicated
validation wave is overkill — each wave carries its own ACs (S/R/C above). A single end-of-phase check that
exercises the production-vs-beta matrix for all three (Google Search Console / a `curl` against both hosts, plus
the sitemaps.org validator) is worth doing once 23.123.3 land.
---
## 7. Open questions for Daniel (product/infra calls, not implementation detail)
### Sitemap
- **OQ-S1 — Browse variants vs. canonical roots.** The sitemap lists the **canonical** browse roots (`/cuts`,
`/sessions`, `/mixes`, `/archive`). Phase 11 put Archive filters in the URL (`/archive?q=&medium=&genre=`).
**Recommend: do NOT enumerate filtered/paginated variants** — they are filtered *views* of the same release set,
not distinct content, and listing them invites duplicate-content dilution. The per-release detail URLs carry the
indexable content; the browse roots are navigational. `[Daniel decision — recommendation: canonical roots only]`
- **OQ-S2 — `lastmod` source.** Use `ReleaseDto.ReleaseDate` as the release URLs' `<lastmod>`? It is the *release*
date, not a content-last-modified date (a re-edited description or replaced cover would not bump it). **Recommend:
include it** — a plausible-but-imperfect lastmod is a useful crawl hint and strictly better than omitting it; the
alternative (a true content-modified timestamp) would need a schema column that doesn't exist (would violate
C5/no-schema-change). Static roots omit `lastmod`. `[Daniel decision — recommendation: ReleaseDate, accept the
imprecision]`
- **OQ-S3 — Static-root list source.** Hardcode the 6 static roots in the endpoint, or derive from the site's nav
index (`DeepDrftPublic.Client/Layout/Pages.cs` `AllPages`)? **Recommend: hardcode for v1** (the indexable-roots
set is *not* the same as the nav set — e.g. `/FramePlayer` is a nav-absent route that must stay out, and a new
nav entry isn't automatically sitemap-worthy), with a code comment to revisit if the set grows. Deriving couples
the sitemap to nav decisions in a way that can silently leak or drop URLs. `[Daniel decision — recommendation:
explicit list]`
### robots
- **OQ-R1 — Endpoint vs. static + nginx (§4.1).** **Recommend the endpoint** (single testable gate, co-located
with the sitemap). Confirm, or — if Daniel prefers robots managed at the reverse-proxy layer — the static +
nginx-rule variant (b), accepting the split gate. `[Daniel decision — recommendation: endpoint]`
- **OQ-R2 — Disallow non-page routes in Production?** Should the production `robots.txt` additionally
`Disallow: /FramePlayer` (the embed iframe) and/or `Disallow: /api/` (the proxy JSON/stream paths)? **Recommend:
yes for `/FramePlayer`** (an embed shell is not a destination page and would be thin/duplicate content if
crawled), **optional for `/api/`** (proxy paths return JSON/bytes, not HTML — crawlers mostly self-skip, but an
explicit disallow is tidy). `[Daniel decision — low stakes]`
### CMS
- **OQ-C1 — Both layers or just robots? (§5.1)** **Recommend both** (static `Disallow: /` robots **and** the
blanket `noindex` meta) — they fail independently and the combined cost is a file + one line; robots-disallow
alone does not de-index a URL discovered via an external link, which is exactly what the on-page `noindex`
closes. Confirm, or accept robots-only if the meta line is judged not worth the one CMS `<head>` touch. `[Daniel
decision — recommendation: both]`
### Cross-cutting
- **OQ-X1 — Is `https://deepdrft.com` the confirmed canonical origin?** This is Phase 22's OQ1, still load-bearing
here: every `<loc>`, the `Sitemap:` line, all assume `SeoOptions.BaseUrl = https://deepdrft.com`. If that value
was confirmed when Phase 22 landed (COMPLETED.md §22 shows it shipped as `https://deepdrft.com`), this is
closed — flagged only so the dependency is explicit. `[Likely closed — confirm BaseUrl is final]`
---
## 8. Cross-references (read before implementing)
- `product-notes/phase-22-seo-metadata-component.md` — the parent spec; §7 "Adjacent but separate concerns"
flagged all three Phase 23 items; the `SeoOptions.BaseUrl` / `ReleaseRoutes` / `SeoEnvironment` seams Phase 23
reuses are defined here.
- `COMPLETED.md §22` — what Phase 22 actually landed (the `SeoEnvironment` env gate, `SeoOptions.BaseUrl =
https://deepdrft.com`, the `ReleaseRoutes`-based canonical the sitemap must match).
- `DeepDrftPublic/Controllers/ReleaseProxyController.cs` — the thin-proxy shape and the `"DeepDrft.API"` named
client the sitemap endpoint reuses to walk releases (server-to-server, no proxy hop). **Note the distinction:**
the sitemap endpoint *enumerates + transforms*, it does not relay verbatim like this proxy.
- `DeepDrftPublic/CLAUDE.md` — the host's "thin HTTP boundary, no domain logic" contract; the middleware ordering
(static files before controller mapping — relevant to the robots endpoint-vs-static-file shadowing note); the
`IWebHostEnvironment` availability server-side.
- `DeepDrftPublic.Client/Common/ReleaseRoutes.cs``DetailHref(entryKey, medium)`, the single source of truth for
per-release detail URLs; every sitemap `<loc>` for a release goes through it.
- `DeepDrftPublic/Components/App.razor` — where `SeoEnvironment.IsProduction` is seeded from
`IWebHostEnvironment.IsProduction()` (lines 3848); the Phase 23 endpoints read the **same** predicate directly.
- `DeepDrftAPI/Controllers/ReleaseController.cs` `GET api/release` — the paged `PagedResult<ReleaseDto>` read the
sitemap walks (returns `Items`, `TotalCount`, `PageNumber`, `PageSize`; `ReleaseDto` carries `EntryKey`,
`Medium`, `ReleaseDate`). No change to this endpoint (C5).
- `DeepDrftManager` host `App.razor` / `wwwroot/` — where Item 3's CMS robots file and blanket `noindex` meta land
(the one CMS-touching surface).
- sitemaps.org `0.9` schema + Google's "Manage your sitemaps" / robots.txt docs — the validation targets (AC-S1,
AC-R*).