Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d26c11e897 | |||
| 1e063d95f4 | |||
| 1fdbec2533 | |||
| 70842cb576 | |||
| f2a0d39521 | |||
| 1bda2b7bea | |||
| 8773803712 | |||
| 3cc11bcbb5 | |||
| 0ba4fc6597 | |||
| 7a0ccdd784 | |||
| ca057dc630 | |||
| 5f4807cc4a | |||
| 9a4b79d377 | |||
| 33383cd675 | |||
| 56f7013314 | |||
| 2653e62eeb | |||
| 45bd599bdd | |||
| f976af0f7c | |||
| f3b89ca9d7 | |||
| 8752fc0c98 | |||
| 274d0ace62 | |||
| e3a4364b8c | |||
| 564b704803 | |||
| 6af6677a12 | |||
| 1bdaeaa164 | |||
| a84a99c309 | |||
| 2c1571057a | |||
| 0b7d8e41e7 | |||
| 4833935925 | |||
| 7917d56af3 | |||
| 1fd63fe368 | |||
| 4e1f540945 | |||
| 1ed518b018 | |||
| 7c41aa678d | |||
| 475e5e671c | |||
| 9971474403 | |||
| 0d1da9e63c | |||
| d47c186045 | |||
| 670eaab34d | |||
| c58b1c9386 | |||
| 450204cdbf | |||
| 5c22c1626a | |||
| 8628fbf215 | |||
| a23a22a2a3 | |||
| 6e12d0161a | |||
| 9716092805 | |||
| a577df88dd | |||
| 011dbe8d81 | |||
| 2fc2d4eb6d | |||
| 14f3af41e4 | |||
| fa01b9c8e0 | |||
| 835fb71337 | |||
| 021801999c | |||
| 54cba7eea0 | |||
| fbaf545c90 | |||
| d3f89c494a | |||
| c3ec3acafa | |||
| 214f708e65 | |||
| 5058c72375 | |||
| f5edcba7b2 |
+4
-2
@@ -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/**
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
@@ -32,3 +32,4 @@
|
||||
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,7 +1,7 @@
|
||||
@page "/"
|
||||
@layout Layout.CmsHomeLayout
|
||||
|
||||
<PageTitle>Deep Drft — Admin</PageTitle>
|
||||
<PageTitle>Deep DRFT Management</PageTitle>
|
||||
|
||||
<HierarchicalRoleAuthorizeView>
|
||||
<Authorized>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -17,3 +17,4 @@
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (0–100%), with fixed three-zone gradient (green 0–60%, yellow 60–85%, orange 85–100%). 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 16–64 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";
|
||||
}
|
||||
@@ -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><script type="application/ld+json"></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></script></c> or <c><</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><script type="application/ld+json"></c>
|
||||
/// element. Replacing <c><</c>/<c>></c>/<c>&</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><</c> identically)
|
||||
/// while making <c></script></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;
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
← 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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -37,4 +37,4 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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><loc></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><url></c>
|
||||
/// per release. A release carries a <c><lastmod></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><loc></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"}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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><loc></c> is absolute and built through <see cref="ReleaseRoutes"/> (so it
|
||||
/// equals the page canonical), that <c><lastmod></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);
|
||||
}
|
||||
}
|
||||
@@ -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 AC1–AC10 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.1–18.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). **OQ1–OQ7 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 16–64 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.1–21.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 1–5. Phase numbers are organisational, not sequencing.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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 0–10: 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 |
|
||||
|
||||
@@ -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).** OQ1–OQ7 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 ~1–2 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 ~1–2 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/4–1/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 AC1–AC10 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.1–18.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 117–125) — 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/
|
||||
@@ -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 16–64 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 C1–C7. 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 ~30–60 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 AC1–AC8 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.1–21.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 AC1–AC9: 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.1–22.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. 100–200) 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.1–23.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 38–48); 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*).
|
||||
Reference in New Issue
Block a user