Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 64e1f71e18 | |||
| 7807d4ebe1 | |||
| 4410132409 | |||
| 00ff9e2702 | |||
| bb086e5869 | |||
| 674d772986 | |||
| ee296db7f6 | |||
| 8a4da2f0b9 | |||
| c28a2b1cf5 | |||
| 1427c92092 | |||
| 2fbb1c9b95 | |||
| 4c56eededc | |||
| f9d99b2c98 | |||
| 59608a23c5 | |||
| 2ddc57edb1 | |||
| 0bb656a512 | |||
| bb5a1fcad4 | |||
| 896b37792e | |||
| 2619fc67c8 | |||
| 4c14c67c33 | |||
| 494668bf24 | |||
| c4e22c706c | |||
| c747f3200f | |||
| 1dd1646cce | |||
| 6bbec2fc8e | |||
| 0c22ce8f09 | |||
| 67645cfd05 | |||
| 2591710f09 | |||
| 30999b038c | |||
| b5106d090f | |||
| a2ed334d0d | |||
| 9300c794b4 | |||
| 95dd48018a | |||
| c21b85afdf | |||
| 234a57d6b7 | |||
| 4bec507aab | |||
| a30d15f79d | |||
| b90604d311 | |||
| 77d0562b08 | |||
| aeda7e67a8 | |||
| bd9c67fc65 | |||
| 62fe27224c | |||
| 0708bb7352 | |||
| e6d5b9b77a | |||
| 04847391ad | |||
| 3d71b6836e | |||
| 833b5a921e | |||
| 3bf95538bd | |||
| eb7e977f3c | |||
| 0b8593950b | |||
| 51ac1a76de | |||
| 949bccfb8e | |||
| cfaf63468d | |||
| d6dcd82a53 | |||
| 3485acf3a8 | |||
| c04c2a9e98 | |||
| f1276faabc | |||
| 6029e226d5 | |||
| 135cc48301 | |||
| 54766fd5fc | |||
| fcc95b9195 | |||
| 042641d841 | |||
| 0358df82ac | |||
| 0f7088fe86 | |||
| 5408d0779c | |||
| abe94953b9 | |||
| 03fdcda054 |
+4
-2
@@ -317,5 +317,7 @@ Database/Vaults/*
|
|||||||
!DeepDrftPublic.Client/wwwroot/js/*.js
|
!DeepDrftPublic.Client/wwwroot/js/*.js
|
||||||
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest;
|
# 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.
|
# gitignored TS output is absent when manifest is generated, so absent from publish output.
|
||||||
!DeepDrftShared.Client/wwwroot/js/parallax/
|
# Re-include the whole RCL js/ tree so every compiled module (parallax, knob, theme, and
|
||||||
!DeepDrftShared.Client/wwwroot/js/knob/
|
# any added later) ships, rather than maintaining a per-module allowlist.
|
||||||
|
!DeepDrftShared.Client/wwwroot/js/
|
||||||
|
!DeepDrftShared.Client/wwwroot/js/**
|
||||||
@@ -9,8 +9,8 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
|
|||||||
### Core Projects
|
### 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**: 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.
|
- **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. 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`. 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. 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.
|
- **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.
|
||||||
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -76,17 +76,20 @@ Keep this seam clean — it is the most architecturally load-bearing part of the
|
|||||||
|
|
||||||
### Theming and dark mode
|
### Theming and dark mode
|
||||||
|
|
||||||
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined inline in `MainLayout.razor`.
|
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined in `DeepDrftShared.Client/Common/DeepDrftPalettes.cs`. `MainLayout.razor` mounts `<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />` — the palettes are not inline in the layout.
|
||||||
- Dark mode toggles via cookie (`darkMode`, 365 days). Client-side via JS interop.
|
- Dark mode toggles via cookie (`darkMode`, 365 days). Client-side via JS interop.
|
||||||
- During server prerender, `DarkModeService` (in `DeepDrftPublic`) reads the cookie and seeds `DarkModeSettings.IsDarkMode`, which carries into WASM render via `PersistentComponentState`. Avoids "wrong theme flash" on initial paint.
|
- During server prerender, `DarkModeService` (in `DeepDrftPublic`) reads the cookie and seeds `DarkModeSettings.IsDarkMode`, which carries into WASM render via `PersistentComponentState`. Avoids "wrong theme flash" on initial paint.
|
||||||
- `DarkModeSettings` lives in `DeepDrftPublic.Client.Common` (consumed by both server prerender and client components).
|
- `DarkModeSettings` lives in `DeepDrftPublic.Client.Common` (consumed by both server prerender and client components).
|
||||||
|
- **Theme-aware token layer:** `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two kinds of CSS custom properties. *Source tokens* (`--deepdrft-navy`, `--deepdrft-white`, `--deepdrft-green-accent`, etc.) are brand constants — identical in `:root` and `.deepdrft-theme-dark`. *Theme-aware aliases* are defined in both blocks and flip when the theme wrapper class changes. Component and page CSS must bind the **alias**, not the source token, so neutral surfaces invert for free. Current alias families: `--deepdrft-page-surface`/`-text`/`-text-muted` (neutral page backgrounds and text), `--deepdrft-play-chip`/`-glyph`/`-chip-soft` (play-state icon chip and glyph), `--deepdrft-popover-surface` (default MudBlazor popover background — light: `color-mix(navy 4%, white)`, a near-page-background surface; dark: references source token `--deepdrft-popover-surface-dark`, a `color-mix(navy-mid 80%, green-accent 20%)` bluer navy defined once in `:root` and referenced by both the `.deepdrft-theme-dark` wrapper block and `body.deepdrft-theme-dark` so portaled popovers are reached). The bespoke glass panels (visualizer/queue/privacy) now bind their own theme-aware `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` family: dark-glass charcoal (sourced from the `--deepdrft-panel-ground` constant) with light text in dark theme, and a light translucent glass with dark text in light theme. These tokens are re-declared in `body.deepdrft-theme-dark` because the panels are MudOverlay panels that portal to `<body>` (same portal scope as popovers); the `--deepdrft-panel-ground` source token is now consumed only via the dark `--deepdrft-panel-surface` value.
|
||||||
|
- **Portaled-popover body-class bridge:** MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark popover token never reached them. Fix: `MainLayout.razor` stamps `deepdrft-theme-dark` on `<body>` via the `setBodyThemeClass(isDark)` helper in `DeepDrftShared.Client/Interop/theme/theme.ts` (lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`). The call fires only on first render or when `_isDarkMode` actually changes (gated by `_lastAppliedDarkMode` comparison) to avoid redundant JS calls on unrelated re-renders. The `body.deepdrft-theme-dark` selector in `deepdrft-tokens.css` resolves `--deepdrft-popover-surface` from `--deepdrft-popover-surface-dark` for these portaled elements.
|
||||||
|
- **Interactive-accent icon treatment (`.dd-accent-icon` / `.dd-accent-fill`):** one reusable rule in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` for green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger), replacing the former pile of per-site dark overrides. Wrap the affordance container in `.dd-accent-icon` to colour its glyphs green-accent in both themes; add `.dd-accent-fill` when the container also holds a `Color.Secondary` filled button that must go green-accent in dark. It is a CSS class (not a palette `Color`) because no MudBlazor `Color` enum is green in both themes, and it targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important` to beat MudBlazor's standalone `.mud-secondary-text` (0,1,0) `!important` on the glyph svg — specificity wins; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too (already green-accent in light via `Color.Secondary`, so folding them in keeps light pixel-identical and fixes dark). The gas-lamp toggle (`GasLampLit`) is self-colored in its SVG (`fill="#2A5C4F"` on the frame) — no dark-only CSS rule is needed; `GasLamp` (unlit, light mode) continues to use `currentColor` and inherits nav text colour. New green-accent icons use this class, not a new override. (Convention detail in `DeepDrftPublic.Client/CLAUDE.md`.)
|
||||||
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DeepDrftShared.Client/Common/DDIcons.cs`.
|
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DeepDrftShared.Client/Common/DDIcons.cs`.
|
||||||
|
|
||||||
### TypeScript interop, not raw JS
|
### TypeScript interop, not raw JS
|
||||||
|
|
||||||
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging. A second interop module lives at `DeepDrftPublic/Interop/about/about-rail.ts` (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).
|
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging. A second interop module lives at `DeepDrftPublic/Interop/about/about-rail.ts` (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).
|
||||||
|
|
||||||
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"` → `outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`) and `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
|
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"` → `outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`), `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`), and `Interop/theme/theme.ts` (`setBodyThemeClass(isDark)` — stamps/removes `deepdrft-theme-dark` on `<body>` so portaled MudBlazor elements inherit the dark popover token; consumed by `MainLayout.razor`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -40,7 +40,7 @@ The CMS is now inlined as the primary content of `DeepDrftManager`, a dedicated
|
|||||||
- A `[HierarchicalRoleAuthorize("Admin")]` attribute (from `AuthBlocksWeb.HierarchicalAuthorize`) on every CMS page component, so `Admin` and any descendant role are admitted by the bundled hierarchical role handler.
|
- A `[HierarchicalRoleAuthorize("Admin")]` attribute (from `AuthBlocksWeb.HierarchicalAuthorize`) on every CMS page component, so `Admin` and any descendant role are admitted by the bundled hierarchical role handler.
|
||||||
- Controllers and minimal-API endpoints for CMS operations (`POST api/cms/track`, `DELETE api/cms/track/{id}`, `PUT api/cms/track/{id}`). Controllers are host-owned per the existing convention. Protected by `[Authorize(Roles = "Admin")]` — the JWT bearer middleware AuthBlocks installs validates the access token on each request.
|
- Controllers and minimal-API endpoints for CMS operations (`POST api/cms/track`, `DELETE api/cms/track/{id}`, `PUT api/cms/track/{id}`). Controllers are host-owned per the existing convention. Protected by `[Authorize(Roles = "Admin")]` — the JWT bearer middleware AuthBlocks installs validates the access token on each request.
|
||||||
- The `AddAuthBlocks(...)` call in `Program.cs` and the matching `await app.Services.UseAuthBlocksStartupAsync()` post-build hook. This installs JWT bearer middleware, the hierarchical role authorization handler, the `AuthDbContext`, the EF migrations, and seeds system roles plus the configured admin user on first boot.
|
- The `AddAuthBlocks(...)` call in `Program.cs` and the matching `await app.Services.UseAuthBlocksStartupAsync()` post-build hook. This installs JWT bearer middleware, the hierarchical role authorization handler, the `AuthDbContext`, the EF migrations, and seeds system roles plus the configured admin user on first boot.
|
||||||
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pending-registrations/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
|
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pendingregistration/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
|
||||||
|
|
||||||
**Render mode:** `InteractiveServer` for all CMS pages and routes. AuthBlocks's bundled UI (`AuthBlocksWeb` pages) is server-rendered MudBlazor with `JwtAuthenticationStateProvider` reading tokens from browser `localStorage` via JS interop. `InteractiveServer` is the right fit because: (a) it matches what the bundled login UI uses, (b) `InputFile` uploads are natively server-side, (c) CMS endpoints live in the `DeepDrftManager` process with direct access to services.
|
**Render mode:** `InteractiveServer` for all CMS pages and routes. AuthBlocks's bundled UI (`AuthBlocksWeb` pages) is server-rendered MudBlazor with `JwtAuthenticationStateProvider` reading tokens from browser `localStorage` via JS interop. `InteractiveServer` is the right fit because: (a) it matches what the bundled login UI uses, (b) `InputFile` uploads are natively server-side, (c) CMS endpoints live in the `DeepDrftManager` process with direct access to services.
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ Concretely, from reading the library source:
|
|||||||
|
|
||||||
- Real per-user accounts (`ApplicationUser` table). No shared password.
|
- Real per-user accounts (`ApplicationUser` table). No shared password.
|
||||||
- One seeded admin on first boot via `AdminUserSettings`. Username, email, password come from `DeepDrftManager/environment/authblocks.json` (gitignored, same pattern as `apikey.json`).
|
- One seeded admin on first boot via `AdminUserSettings`. Username, email, password come from `DeepDrftManager/environment/authblocks.json` (gitignored, same pattern as `apikey.json`).
|
||||||
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pending-registrations`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
|
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pendingregistration`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
|
||||||
- **Mutation attribution.** `TrackEntity` gains a nullable `CreatedByUserId : long?` column in the W1.2 migration. Populated on every CMS-originated mutation; null for historical CLI-added rows and for any pre-CMS data. Captures attribution from day one even though Wave 1 has exactly one user (`feedback_design_for_adaptability`).
|
- **Mutation attribution.** `TrackEntity` gains a nullable `CreatedByUserId : long?` column in the W1.2 migration. Populated on every CMS-originated mutation; null for historical CLI-added rows and for any pre-CMS data. Captures attribution from day one even though Wave 1 has exactly one user (`feedback_design_for_adaptability`).
|
||||||
- **Role gate.** Every CMS page and every `api/cms/*` endpoint requires the `Admin` system role. We use `Admin` rather than introducing a new `CmsAdmin` role because the collective is small and the existing hierarchy already covers the case; if Wave 3 ever needs finer grain (e.g. a `ContentEditor` role that can edit but not delete), that is a `SystemRole.cs` edit upstream, not a redesign here.
|
- **Role gate.** Every CMS page and every `api/cms/*` endpoint requires the `Admin` system role. We use `Admin` rather than introducing a new `CmsAdmin` role because the collective is small and the existing hierarchy already covers the case; if Wave 3 ever needs finer grain (e.g. a `ContentEditor` role that can edit but not delete), that is a `SystemRole.cs` edit upstream, not a redesign here.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,104 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
- **What:** A DRY token pass resolving six theming symptoms (five in dark mode, one in light) that all traced to three root causes: neutral page surfaces bound to constant brand tokens, the play chip bound to a constant light-grey, and no theme-aware popover-surface token. Resolved as one coherent pass via a shared token layer rather than per-component patches.
|
||||||
|
|
||||||
|
- **Why:** Symptom consolidation and root-cause analysis showed all six symptoms shared the same underlying structure — component CSS bypassing the theme-aware alias layer and binding constant source tokens directly. A single additive token pass in `deepdrft-tokens.css` plus targeted re-pointing of consumers fixes all six without scattering dark-mode rules.
|
||||||
|
|
||||||
|
- **Shape:**
|
||||||
|
- **Token foundation (`deepdrft-tokens.css`):** Three new theme-aware token families added to `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css`, each defined in both `:root` (light) and `.deepdrft-theme-dark` (dark):
|
||||||
|
- `--deepdrft-page-surface` / `--deepdrft-page-text` / `--deepdrft-page-text-muted` — neutral page surface family. Light: `--deepdrft-white` / `--deepdrft-navy` / `--deepdrft-muted`. Dark: `var(--mud-palette-background)` (#0D1B2A, the true page ground) / `--deepdrft-white` / `color-mix(muted 70%, white)` — neutral sections dissolve into the site background as one continuous dark field rather than reading as raised panels.
|
||||||
|
- `--deepdrft-play-chip` / `--deepdrft-play-glyph` / `--deepdrft-play-chip-soft` — play-chip family. Light: soft-grey chip (matching prior `--deepdrft-soft`). Dark: `--deepdrft-green-accent` chip + `--deepdrft-navy` glyph (navy-on-green for solid chips); `--deepdrft-play-chip-soft` is `color-mix(green-accent 30%, transparent)` (the player-bar translucent override).
|
||||||
|
- `--deepdrft-popover-surface` — popover surface. Light: `color-mix(navy 8%, white)` soft desaturated-navy wash. Dark: `#162437` (pixel-identical to `DeepDrftPalettes.Dark.Surface` — dark popovers unchanged, only light is retoned).
|
||||||
|
- **Neutral-surface inversion (T2):** `Home.razor.css`, `About.razor.css`, `DeepDrftFooter.razor.css` re-pointed from constant `--deepdrft-white`/`--deepdrft-navy` to `--deepdrft-page-surface`/`--deepdrft-page-text`. Decorative navy/green sections (`.section-dark`, `.split-left`, `.cta-banner`, hero overlays) untouched — classification encoded in which token each section binds.
|
||||||
|
- **Play-chip theming (T3):** `PlayStateIcon.razor.css` `.icon-container` re-pointed to `--deepdrft-play-chip`; glyph to `--deepdrft-play-glyph`. Player-bar context overrides chip to `--deepdrft-play-chip-soft` (translucent green wash). Light-mode parity and connect-option hover also corrected.
|
||||||
|
- **Popover surface (T4):** `deepdrft-styles.css` binds `--deepdrft-popover-surface` to the MudBlazor default popover surface. Bespoke dark-glass panels (`--deepdrft-panel-ground`) untouched.
|
||||||
|
- **Wave 2 refinements (on top of T1–T4):** App bar background moved to navy (`#112338`) from near-black (`#0D1B2A`). Neutral page surfaces re-pointed to `var(--mud-palette-background)` (`#0D1B2A`) as the true dark ground — sections dissolve into the body background rather than reading as navy-mid raised panels (resolves Wave 1's open question in favour of ground). Dark-mode hero legibility (superseded in Wave 3 — see below). Play-glyph settled on navy-on-green (solid chips) and green-on-green (player bar, via `--deepdrft-play-chip-soft`).
|
||||||
|
- **Wave 3 — hero dark-mode legibility fix:** `DeepDrftHero.razor.css` hero text re-worked to bind theme-aware tokens directly in the base rules rather than via `:global(.deepdrft-theme-dark)` overrides (matching the About page's proven pattern). `.hero-title` and `.hero-desc` now bind `--deepdrft-page-text` directly; `.hero-subtitle` (previously bound to the constant `--deepdrft-muted`) now binds `--deepdrft-page-text-muted`, making it theme-aware for the first time. Only `.hero-title em` retains an explicit dark override (`:global(.deepdrft-theme-dark) .hero-title em` → `--deepdrft-green-accent`, lifting the low-contrast `--deepdrft-green` on the dark ground). Global hero-button dark treatment added to `deepdrft-styles.css`: `.deepdrft-theme-dark .btn-primary` → `--deepdrft-green-accent` fill + `--deepdrft-navy` text (hover: `--deepdrft-green-interactive`); `.deepdrft-theme-dark .btn-ghost` → `--deepdrft-page-text` color + `--deepdrft-border-light` border.
|
||||||
|
- **Open questions resolved:** Dark neutral surface = ground (continuous field, `--mud-palette-background`) — not elevated navy-mid. Popover target: `color-mix(navy 8%, white)` in light; dark binds `#162437` (MudBlazor dark Surface) unchanged.
|
||||||
|
|
||||||
|
- **Design memo:** `product-notes/theme-dark-mode-remediation.md`.
|
||||||
|
|
||||||
|
### Phase 18 — Wave 4 — Popover-surface retune + portaled-popover body-class bridge (landed 2026-06-20)
|
||||||
|
|
||||||
|
**Landed:** 2026-06-20 on dev.
|
||||||
|
|
||||||
|
- **What:** Follow-on retune of `--deepdrft-popover-surface` values and a root-cause fix for portaled MudBlazor popovers that were never reaching the dark token.
|
||||||
|
|
||||||
|
- **Why:** Wave 1–3 shipped `--deepdrft-popover-surface` light at `color-mix(navy 8%, white)` (too saturated — read as a grey slab) and dark at flat `#162437`. More importantly, MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark token never applied to them at all. Both needed fixing as a pair.
|
||||||
|
|
||||||
|
- **Shape:**
|
||||||
|
- **Token retune (`deepdrft-tokens.css`):** Light value changed from 8% → 4% navy mix (near-page-background, clearly light). Dark value changed from `#162437` to `color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%)` — a bluer navy with a slight green accent. Dark value hoisted into a new source token `--deepdrft-popover-surface-dark` (defined once in `:root`), referenced by both the `.deepdrft-theme-dark` wrapper block and a new `body.deepdrft-theme-dark` block so portaled content is reached from either selector.
|
||||||
|
- **Portaled-popover body-class bridge (`MainLayout.razor` + new TS module):** `MainLayout.razor` now stamps/removes `deepdrft-theme-dark` on `<body>` after each render via a new `DeepDrftShared.Client/Interop/theme/theme.ts` module exporting `setBodyThemeClass(isDark: boolean)`. Lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`. Call is gated to fire only on first render or when `_isDarkMode` changes (`_lastAppliedDarkMode` comparison) — no redundant JS calls on unrelated re-renders. `IJSObjectReference _themeModule` is disposed in `DisposeAsync` to clean up the module reference when the circuit tears down.
|
||||||
|
|
||||||
|
### Phase 18 — Wave 5 — Glass-panel theme-aware token family (landed 2026-06-20)
|
||||||
|
|
||||||
|
**Landed:** 2026-06-20 on dev.
|
||||||
|
|
||||||
|
- **What:** The three `MudOverlay`-based glass panels — the queue panel (`.deepdrft-queue-modal`), the waveform visualizer control deck, and the privacy modal — now render as a light translucent glass with legible dark text in light theme, while remaining the existing dark-glass charcoal in dark theme. Dark mode is visually unchanged; a latent white-on-light bug in the inline embed queue row was incidentally fixed by the token flip.
|
||||||
|
|
||||||
|
- **Why:** Prior to this wave, all three panels were bound to the constant `--deepdrft-panel-ground` token, exempting them from the theme-aware alias layer established in Waves 1–3. In light theme this produced white text on a near-white glass surface — unreadable. The panels needed their own theme-aware family (separate from `--deepdrft-popover-surface`, which targets MudBlazor default popovers) and the same `body.deepdrft-theme-dark` portal-scope treatment introduced for popovers in Wave 4.
|
||||||
|
|
||||||
|
- **Shape:**
|
||||||
|
- **New token family (`deepdrft-tokens.css`):** `--deepdrft-panel-surface` / `--deepdrft-panel-text` / `--deepdrft-panel-text-muted` / `--deepdrft-panel-border` / `--deepdrft-panel-row-hover` — each defined in `:root` (light values: translucent glass with dark text), `.deepdrft-theme-dark` (dark-glass charcoal with light text, sourced from the existing `--deepdrft-panel-ground` constant), and `body.deepdrft-theme-dark` (same dark values re-declared so the tokens resolve correctly when the panels portal to `<body>` via `MudOverlay`).
|
||||||
|
- **Consumer re-pointing:** The three panels and their descendants (queue rows, visualizer deck, privacy modal) previously bound `--deepdrft-panel-ground` directly; they are now re-pointed to the appropriate `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` aliases.
|
||||||
|
- **Exemption lifted:** This deliberately removes the previously-documented exemption of these panels from the theme-aware layer. `--deepdrft-panel-ground` is now consumed only as the dark-theme value of `--deepdrft-panel-surface`, not directly by any component CSS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Phase 17 — Player-Bar Queue View: Wave 17.3 — Fixed embed panel + iframe resize (landed 2026-06-19)
|
## Phase 17 — Player-Bar Queue View: Wave 17.3 — Fixed embed panel + iframe resize (landed 2026-06-19)
|
||||||
|
|
||||||
**Landed:** 2026-06-19 on dev.
|
**Landed:** 2026-06-19 on dev.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
|
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
|
||||||
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
|
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
|
||||||
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" />
|
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.39" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -103,10 +103,13 @@ builder.Services.AddAuthBlocks(options =>
|
|||||||
options.JwtSettings.Audience = builder.Configuration["AuthBlocks:Jwt:Audience"]
|
options.JwtSettings.Audience = builder.Configuration["AuthBlocks:Jwt:Audience"]
|
||||||
?? throw new InvalidOperationException("AuthBlocks:Jwt:Audience is required");
|
?? throw new InvalidOperationException("AuthBlocks:Jwt:Audience is required");
|
||||||
|
|
||||||
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
|
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
|
||||||
?? throw new InvalidOperationException("AuthBlocks:Email:Host is required");
|
?? throw new InvalidOperationException("AuthBlocks:Email:Host is required");
|
||||||
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
|
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
|
||||||
?? throw new InvalidOperationException("AuthBlocks:Email:Token is required");
|
?? throw new InvalidOperationException("AuthBlocks:Email:Token is required");
|
||||||
|
options.EmailConnection.FromAddress = builder.Configuration["AuthBlocks:Email:From"]
|
||||||
|
?? throw new InvalidOperationException("AuthBlocks:Email:From is required");
|
||||||
|
options.EmailConnection.TestInbox = builder.Configuration["AuthBlocks:Email:TestInbox"];
|
||||||
|
|
||||||
options.AdminUserSettings = new AdminUserSettings
|
options.AdminUserSettings = new AdminUserSettings
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
},
|
},
|
||||||
"Email": {
|
"Email": {
|
||||||
"Host": "smtp.your-provider.com",
|
"Host": "smtp.your-provider.com",
|
||||||
"Token": "your-email-token-here"
|
"Token": "your-email-token-here",
|
||||||
|
"From": "noreply@yourdomain.com",
|
||||||
|
"TestInbox": "<sandbox-id>"
|
||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"UserName": "admin",
|
"UserName": "admin",
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
|
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
|
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
|
||||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.30" />
|
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.35" />
|
||||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.30" />
|
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.35" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -6,9 +6,23 @@
|
|||||||
|
|
||||||
<MudLayout>
|
<MudLayout>
|
||||||
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
|
<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;">
|
<a href="/" class="mx-2">
|
||||||
Deep Drft — Admin
|
<MudStack Row AlignItems="AlignItems.Center">
|
||||||
</MudText>
|
<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>
|
</MudAppBar>
|
||||||
<MudMainContent>
|
<MudMainContent>
|
||||||
<MudContainer MaxWidth="MaxWidth.Small"
|
<MudContainer MaxWidth="MaxWidth.Small"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@using DeepDrftShared.Client.Common
|
@using DeepDrftShared.Client.Common
|
||||||
|
@using AuthBlocksWeb.Components.Layout
|
||||||
|
|
||||||
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
|
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
|
||||||
<MudPopoverProvider />
|
<MudPopoverProvider />
|
||||||
@@ -8,9 +9,27 @@
|
|||||||
|
|
||||||
<MudLayout>
|
<MudLayout>
|
||||||
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
|
<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;">
|
<MudIconButton Icon="@Icons.Material.Filled.Menu"
|
||||||
Deep Drft — Admin
|
Color="Color.Inherit"
|
||||||
</MudText>
|
Edge="Edge.Start"
|
||||||
|
OnClick="ToggleDrawer" />
|
||||||
|
<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 />
|
<MudSpacer />
|
||||||
<MudTooltip Text="Catalogue">
|
<MudTooltip Text="Catalogue">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Home"
|
<MudIconButton Icon="@Icons.Material.Filled.Home"
|
||||||
@@ -18,6 +37,20 @@
|
|||||||
Color="Color.Inherit" />
|
Color="Color.Inherit" />
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
</MudAppBar>
|
</MudAppBar>
|
||||||
|
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" Variant="DrawerVariant.Responsive" ClipMode="DrawerClipMode.Always">
|
||||||
|
<MudNavMenu>
|
||||||
|
<MudNavLink Href="/catalogue" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Catalogue</MudNavLink>
|
||||||
|
<MudNavLink Href="/releases" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LibraryMusic">Releases</MudNavLink>
|
||||||
|
<MudNavLink Href="/tracks/upload" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Upload</MudNavLink>
|
||||||
|
<UserAdminMenu />
|
||||||
|
<HierarchicalRoleAuthorizeView RolesList="@([SystemRoleConstants.UserAdmin])">
|
||||||
|
<Authorized>
|
||||||
|
<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">
|
<MudMainContent Class="pt-14 pb-8">
|
||||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
||||||
@Body
|
@Body
|
||||||
@@ -25,6 +58,12 @@
|
|||||||
</MudMainContent>
|
</MudMainContent>
|
||||||
</MudLayout>
|
</MudLayout>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _drawerOpen = true;
|
||||||
|
|
||||||
|
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||||
|
}
|
||||||
|
|
||||||
<div id="blazor-error-ui" data-nosnippet>
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
An unhandled error has occurred.
|
An unhandled error has occurred.
|
||||||
<a href="." class="reload">Reload</a>
|
<a href="." class="reload">Reload</a>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@page "/404"
|
@page "/404"
|
||||||
|
|
||||||
<PageTitle>SkipperHaven - Page Not Found</PageTitle>
|
<PageTitle>Deep DRFT Management - Page Not Found</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h1" Color="Color.Primary">
|
<MudText Typo="Typo.h1" Color="Color.Primary">
|
||||||
404 - Resource Not Found
|
404 - Resource Not Found
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@layout Layout.CmsHomeLayout
|
@layout Layout.CmsHomeLayout
|
||||||
|
|
||||||
<PageTitle>Deep Drft — Admin</PageTitle>
|
<PageTitle>Deep DRFT Management</PageTitle>
|
||||||
|
|
||||||
<HierarchicalRoleAuthorizeView>
|
<HierarchicalRoleAuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
@inject ICmsReleaseService CmsReleaseService
|
@inject ICmsReleaseService CmsReleaseService
|
||||||
@inject ILogger<Index> Logger
|
@inject ILogger<Index> Logger
|
||||||
|
|
||||||
<PageTitle>DeepDrft CMS</PageTitle>
|
<PageTitle>Deep DRFT Management - Catalogue</PageTitle>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
@inject ILogger<BatchEdit> Logger
|
@inject ILogger<BatchEdit> Logger
|
||||||
|
|
||||||
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
|
<PageTitle>Edit Release — Deep DRFT Management</PageTitle>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
|
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject ILogger<BatchUpload> Logger
|
@inject ILogger<BatchUpload> Logger
|
||||||
|
|
||||||
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
|
<PageTitle>Upload Release — Deep DRFT Management</PageTitle>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
|
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
|
||||||
@@ -129,6 +129,11 @@
|
|||||||
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
|
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
|
||||||
private bool _heroWarningAcknowledged;
|
private bool _heroWarningAcknowledged;
|
||||||
|
|
||||||
|
// Captured once at component initialization on the live interactive circuit, while the token
|
||||||
|
// is known-good, so a mid-session token expiry at submit time cannot discard a long-composed
|
||||||
|
// release. Only assigned when the id parses successfully.
|
||||||
|
private long? _createdByUserId;
|
||||||
|
|
||||||
private string _albumName = string.Empty;
|
private string _albumName = string.Empty;
|
||||||
private string _artist = string.Empty;
|
private string _artist = string.Empty;
|
||||||
private string _genre = string.Empty;
|
private string _genre = string.Empty;
|
||||||
@@ -156,6 +161,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
// Capture the user id once at load, while the token is known-good. The CMS host runs with
|
||||||
|
// prerender: false (InteractiveServer), so this is the single init pass — auth state is
|
||||||
|
// fully available. The page is [Authorize]-gated, so the parse should always succeed.
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (long.TryParse(userIdValue, out var userId))
|
||||||
|
{
|
||||||
|
_createdByUserId = userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Switching to a single-track medium collapses any multi-track selection to the first row so the
|
// Switching to a single-track medium collapses any multi-track selection to the first row so the
|
||||||
// single-track invariant holds before submit. The predicate reads the same MediumRules cardinality
|
// single-track invariant holds before submit. The predicate reads the same MediumRules cardinality
|
||||||
// declaration the upload service enforces, so the form and the domain cannot drift.
|
// declaration the upload service enforces, so the form and the domain cannot drift.
|
||||||
@@ -275,13 +293,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
if (_createdByUserId is not long createdByUserId)
|
||||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
|
||||||
{
|
{
|
||||||
// The page is gated by [Authorize] under the Admin role, so a missing or
|
// _createdByUserId is set at component initialization from the authenticated principal.
|
||||||
// unparseable id here is a configuration bug, not normal client state.
|
// A null here means the id was unavailable even at load — a genuine configuration bug,
|
||||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
// since the page is [Authorize]-gated.
|
||||||
|
Logger.LogError("User id was not captured at initialization — NameIdentifier claim missing or unparseable.");
|
||||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
|
<PageTitle>Mixes — Deep DRFT Management</PageTitle>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudButton Variant="Variant.Text"
|
<MudButton Variant="Variant.Text"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
|
<PageTitle>Sessions — Deep DRFT Management</PageTitle>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudButton Variant="Variant.Text"
|
<MudButton Variant="Variant.Text"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
<PageTitle>Releases — DeepDrft CMS</PageTitle>
|
<PageTitle>Releases — Deep DRFT Management</PageTitle>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
AdditionalAssemblies="new[] { typeof(AuthBlocksWeb._Imports).Assembly }"
|
AdditionalAssemblies="new[] { typeof(AuthBlocksWeb._Imports).Assembly }"
|
||||||
NotFoundPage="typeof(NotFound)">
|
NotFoundPage="typeof(NotFound)">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<AuthorizeRouteView RouteData="routeData"
|
<AuthorizeRouteView RouteData="routeData"
|
||||||
DefaultLayout="typeof(Layout.CmsLayout)">
|
DefaultLayout="@_currentLayout">
|
||||||
<NotAuthorized Context="authState">
|
<NotAuthorized Context="authState">
|
||||||
@if (authState.User.Identity?.IsAuthenticated == true)
|
@if (authState.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
@@ -18,3 +18,20 @@
|
|||||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
</Found>
|
</Found>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter] private Task<AuthenticationState>? AuthenticationState { get; set; }
|
||||||
|
|
||||||
|
private Type _currentLayout = typeof(Layout.CmsHomeLayout);
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (AuthenticationState is not null)
|
||||||
|
{
|
||||||
|
var authState = await AuthenticationState;
|
||||||
|
_currentLayout = authState.User.Identity?.IsAuthenticated == true
|
||||||
|
? typeof(Layout.CmsLayout)
|
||||||
|
: typeof(Layout.CmsHomeLayout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MudBlazor" Version="8.15.0" />
|
<PackageReference Include="MudBlazor" Version="8.15.0" />
|
||||||
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.33" />
|
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.39" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -7,8 +7,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
|
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
|
||||||
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.30" />
|
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.35" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -10,17 +10,18 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
|||||||
|
|
||||||
## Actual structure
|
## 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).
|
- `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.
|
- `Controls/`: Reusable components.
|
||||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
|
- `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.
|
- `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.
|
- `AppNavLink.razor`: Nav link with active-page highlight.
|
||||||
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
|
- `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).
|
- `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).
|
- `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/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/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/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.
|
- `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.
|
- `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`).
|
- `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).
|
- `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`).
|
- `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.
|
- `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`.
|
- `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`.
|
- `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).
|
- `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).
|
- `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).
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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).
|
- 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.
|
- `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).
|
- `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.
|
- `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`).
|
- `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).
|
- `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).
|
- `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).
|
- `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.
|
- `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.
|
- `Common/`: Shared utilities.
|
||||||
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
|
- `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).
|
- `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`.
|
- `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`.
|
||||||
- `_Imports.razor`: Global using statements and component imports.
|
- `_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.
|
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
|
## MVVM convention
|
||||||
|
|
||||||
Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only.
|
Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only.
|
||||||
@@ -140,6 +150,22 @@ Component state lives in ViewModels (registered scoped in DI). Components render
|
|||||||
- CSS classes prefixed `deepdrft-` live in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (shared across server and client).
|
- CSS classes prefixed `deepdrft-` live in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (shared across server and client).
|
||||||
- Custom SVG icons: `DeepDrftShared.Client/Common/DDIcons.cs` (hand-rolled gas-lamp, lava-lamp, etc. — shared across public and CMS surfaces).
|
- Custom SVG icons: `DeepDrftShared.Client/Common/DDIcons.cs` (hand-rolled gas-lamp, lava-lamp, etc. — shared across public and CMS surfaces).
|
||||||
|
|
||||||
|
### Interactive-accent icons (`.dd-accent-icon` / `.dd-accent-fill`)
|
||||||
|
|
||||||
|
Green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger, etc.) use a **single reusable treatment** in `deepdrft-styles.css`, not per-site dark overrides. Wrap the affordance(s) in a container carrying `.dd-accent-icon`; the rule colours the inner `.mud-icon-root` glyph green-accent (`--deepdrft-green-accent`, the brand constant — same value in both palettes) in **both** themes. Add `.dd-accent-fill` to the same container when it also holds a filled `Color.Secondary` `MudButton` whose fill must go green-accent in **dark** (dark-only — light already renders green fill + white text).
|
||||||
|
|
||||||
|
Two reasons this is needed and why it's a class, not a palette colour: (1) no MudBlazor `Color` enum is green in both themes (`Dark.Secondary` is off-white), so palette-only solutions can't express "green in both"; (2) MudBlazor stamps the standalone rule `.mud-secondary-text { color: …secondary !important }` (0,1,0) on the glyph `<svg>`, so wrapper-level overrides never reach it — the reusable rule targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important`, which beats it on specificity alone; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too: they were already green-accent in light (via `Color.Secondary` → `Light.Secondary`), so folding them in keeps light pixel-identical while fixing the dark over-image glyphs — they are not actually theme-divergent. **Add new green-accent icon affordances by applying this class, not by spawning a new dark override.**
|
||||||
|
|
||||||
|
**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
|
## Development commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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
|
@using DeepDrftPublic.Client.Services
|
||||||
|
|
||||||
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the
|
@* 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
|
cascaded IQueueService's Enqueue/EnqueueRange (which append to the END without disturbing current
|
||||||
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select.
|
playback; a first add into a dormant queue seeds the head from the externally-playing track when one
|
||||||
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole
|
exists, then appends) — never PlayRelease/PlayTrack/Start/Select. Track mode (Track set) appends a
|
||||||
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@
|
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">
|
<MudTooltip Text="@Tooltip">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
|
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ else
|
|||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
|
||||||
<MudPaper Elevation="8" Class="player-surface pa-3">
|
<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">
|
<div class="player-layout">
|
||||||
<PlayerTransportZone IsLoaded="IsLoaded"
|
<PlayerTransportZone IsLoaded="IsLoaded"
|
||||||
CanPlay="CanPlay"
|
CanPlay="CanPlay"
|
||||||
@@ -45,7 +61,7 @@ else
|
|||||||
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
|
@* 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).
|
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
|
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
|
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
|
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
|
||||||
shrunken height to the host iframe. *@
|
shrunken height to the host iframe. *@
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
|
[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 _isMinimized = true;
|
||||||
private bool _isSeeking = false;
|
private bool _isSeeking = false;
|
||||||
private double _seekPosition = 0;
|
private double _seekPosition = 0;
|
||||||
private bool _queueOpen = false;
|
private bool _queueOpen = false;
|
||||||
private IStreamingPlayerService? _subscribedService;
|
private IStreamingPlayerService? _subscribedService;
|
||||||
private IQueueService? _subscribedQueue;
|
private IQueueService? _subscribedQueue;
|
||||||
|
private bool _subscribedToVisualizerState;
|
||||||
|
|
||||||
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
|
// 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
|
// 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).
|
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
|
||||||
private bool ShowFixedPanel => Fixed && HasQueue;
|
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;
|
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
|
||||||
|
|
||||||
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
|
// 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;
|
QueueService.QueueChanged += OnQueueChanged;
|
||||||
_subscribedQueue = QueueService;
|
_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 OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
private void OnQueueChanged()
|
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
|
// 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
|
// 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.
|
// mount too, so this keeps state and view consistent.
|
||||||
@@ -189,12 +219,14 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
|
|
||||||
private void ClearUpcoming() => QueueService?.ClearUpcoming();
|
private void ClearUpcoming() => QueueService?.ClearUpcoming();
|
||||||
|
|
||||||
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that
|
// Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY,
|
||||||
// touches playback — it streams the chosen track via the player.
|
// 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)
|
private async Task OnQueueJump(int index)
|
||||||
{
|
{
|
||||||
if (QueueService == null) return;
|
if (QueueService == null) return;
|
||||||
await QueueService.PlayRelease(QueueService.Items, index);
|
await QueueService.JumpTo(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
@@ -387,6 +419,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
|||||||
_subscribedQueue = null;
|
_subscribedQueue = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_subscribedToVisualizerState)
|
||||||
|
{
|
||||||
|
VisualizerControlState.Changed -= OnVisualizerStateChanged;
|
||||||
|
_subscribedToVisualizerState = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (_spacerModule is not null)
|
if (_spacerModule is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -42,6 +42,68 @@
|
|||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PLAYER-BAR play-chip override (Phase 18, T3). PlayStateIcon's chip defaults to the solid
|
||||||
|
--deepdrft-play-chip (moss-green in dark) used on release heroes and Cut track rows. On the
|
||||||
|
player dock that solid green reads too hot, so here — and only here — swap to the
|
||||||
|
translucent --deepdrft-play-chip-soft (same green, much less opaque).
|
||||||
|
The glyph stays --mud-palette-primary (green on the soft translucent wash), giving the
|
||||||
|
preferred green-on-green look on the player bar in dark mode. */
|
||||||
|
::deep .player-surface .icon-container {
|
||||||
|
background-color: var(--deepdrft-play-chip-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
::deep .player-surface .icon-container .mud-icon-button {
|
||||||
|
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 floating dock — positioning + hover only; colour from MudFab */
|
||||||
.minimized-dock {
|
.minimized-dock {
|
||||||
position: fixed;
|
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!;
|
||||||
|
}
|
||||||
@@ -14,14 +14,6 @@
|
|||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
Disabled="!CanPlay"
|
Disabled="!CanPlay"
|
||||||
OnToggle="@TogglePlayPause"/>
|
OnToggle="@TogglePlayPause"/>
|
||||||
@if (!Fixed || HasPrevious || HasNext)
|
|
||||||
{
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
|
||||||
Color="Color.Primary"
|
|
||||||
Size="Size.Large"
|
|
||||||
OnClick="@SkipNext"
|
|
||||||
Disabled="!HasNext"/>
|
|
||||||
}
|
|
||||||
@if (!Fixed)
|
@if (!Fixed)
|
||||||
{
|
{
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||||
@@ -30,4 +22,12 @@
|
|||||||
OnClick="@Stop"
|
OnClick="@Stop"
|
||||||
Disabled="!IsLoaded"/>
|
Disabled="!IsLoaded"/>
|
||||||
}
|
}
|
||||||
|
@if (!Fixed || HasPrevious || HasNext)
|
||||||
|
{
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Large"
|
||||||
|
OnClick="@SkipNext"
|
||||||
|
Disabled="!HasNext"/>
|
||||||
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|||||||
@@ -23,17 +23,19 @@
|
|||||||
@* Queue toggle: a second row between the transport controls and the timestamp (§3.1 placement —
|
@* Queue toggle: a second row between the transport controls and the timestamp (§3.1 placement —
|
||||||
"below the control buttons, to the left of the timestamps"). Shown only when a queue is loaded,
|
"below the control buttons, to the left of the timestamps"). Shown only when a queue is loaded,
|
||||||
mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@
|
mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@
|
||||||
@if (ShowQueueButton)
|
<MudStack Row AlignItems="AlignItems.Center">
|
||||||
{
|
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
||||||
<MudTooltip Text="Queue">
|
@if (ShowQueueButton)
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
|
{
|
||||||
Color="Color.Primary"
|
<MudTooltip Text="Queue">
|
||||||
Size="Size.Medium"
|
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
|
||||||
OnClick="QueueToggle"
|
Color="Color.Primary"
|
||||||
aria-label="Queue"
|
Size="Size.Medium"
|
||||||
aria-expanded="@QueueOpen"
|
OnClick="QueueToggle"
|
||||||
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
|
aria-label="Queue"
|
||||||
</MudTooltip>
|
aria-expanded="@QueueOpen"
|
||||||
}
|
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
|
||||||
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.28em;
|
||||||
color: var(--deepdrft-green-accent);
|
color: var(--deepdrft-green);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: 1.8rem;
|
margin-bottom: 1.8rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--deepdrft-green-accent);
|
background: var(--deepdrft-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
@@ -36,14 +36,14 @@
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 0.92;
|
line-height: 0.92;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
animation-delay: 0.22s;
|
animation-delay: 0.22s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title em {
|
.hero-title em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--deepdrft-green);
|
color: var(--deepdrft-green-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
font-size: clamp(1rem, 2vw, 1.35rem);
|
font-size: clamp(1rem, 2vw, 1.35rem);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--deepdrft-muted);
|
color: var(--deepdrft-page-text-muted);
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
animation-delay: 0.34s;
|
animation-delay: 0.34s;
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
font-family: var(--deepdrft-font-body);
|
font-family: var(--deepdrft-font-body);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
max-width: 36ch;
|
max-width: 36ch;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
@@ -81,3 +81,11 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark-mode accent override (Phase 18, Wave 3).
|
||||||
|
.hero-title and .hero-desc bind --deepdrft-page-text directly above (theme-aware).
|
||||||
|
The em italic is the only element needing an explicit dark lift:
|
||||||
|
--deepdrft-green (#1A3C34) is low-contrast on the navy ground; lift to green-accent. */
|
||||||
|
:global(.deepdrft-theme-dark) .hero-title em {
|
||||||
|
color: var(--deepdrft-green-accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
background-color: var(--deepdrft-soft);
|
background-color: var(--deepdrft-play-chip);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
@@ -10,5 +10,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon-container:hover {
|
.icon-container:hover {
|
||||||
background-color: color-mix(var(--deepdrft-soft), var(--deepdrft-navy-mid) 25%);
|
background-color: color-mix(in srgb, var(--deepdrft-play-chip), var(--deepdrft-navy-mid) 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In dark mode the chip is moss-green and MudIconButton's Color.Primary/Secondary green
|
||||||
|
glyph would vanish against it, so pin the glyph to --deepdrft-play-glyph (navy) in dark
|
||||||
|
only. In light mode the token also resolves to navy, but applying it there overrides
|
||||||
|
Color.Secondary (green-accent) on hero/row mounts — a visible regression. Scoping to
|
||||||
|
.deepdrft-theme-dark preserves the MudBlazor Color prop in light and fixes only dark.
|
||||||
|
::deep reaches the portaled-in-scope MudIconButton icon, which doesn't carry this
|
||||||
|
component's scope attribute. */
|
||||||
|
.deepdrft-theme-dark .icon-container ::deep .mud-icon-button {
|
||||||
|
color: var(--deepdrft-play-glyph);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PlayStateIcon is authoritative over its own glyph colour — a surrounding .dd-accent-icon
|
||||||
|
must NOT recolor the play-chip glyph in dark. The consolidation rule is:
|
||||||
|
.dd-accent-icon .mud-icon-button .mud-icon-root (0,3,0) !important
|
||||||
|
After Blazor scoped-CSS compilation this rule becomes:
|
||||||
|
.deepdrft-theme-dark .icon-container[b-xxx] .mud-icon-button .mud-icon-root (0,5,0) !important
|
||||||
|
(0,5,0) beats (0,3,0) — wins on specificity; !important parity is irrelevant.
|
||||||
|
Dark only: light already renders the navy glyph via the MudBlazor Color prop. */
|
||||||
|
.deepdrft-theme-dark .icon-container ::deep .mud-icon-button .mud-icon-root {
|
||||||
|
color: var(--deepdrft-play-glyph) !important;
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,13 @@
|
|||||||
|
|
||||||
private MudDropContainer<QueueRow>? _dropContainer;
|
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
|
// 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.
|
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
|
||||||
private List<QueueRow> Rows =>
|
private List<QueueRow> Rows =>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
Class="deepdrft-queue-overlay">
|
Class="deepdrft-queue-overlay">
|
||||||
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
|
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
|
||||||
<div class="deepdrft-queue-modal-header">
|
<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"
|
<MudButton Variant="Variant.Text"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ namespace DeepDrftPublic.Client.Controls;
|
|||||||
public partial class ReleaseDetailScaffold : ComponentBase
|
public partial class ReleaseDetailScaffold : ComponentBase
|
||||||
{
|
{
|
||||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||||
|
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||||
|
|
||||||
[Parameter] public required string Title { get; set; }
|
[Parameter] public required string Title { get; set; }
|
||||||
[Parameter] public string? Artist { 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;
|
if (Track is null || PlayerService is null) return;
|
||||||
|
|
||||||
// Toggle if this track is already active (playing or paused); otherwise start a fresh
|
// Toggle if this track is already active (playing or paused); otherwise PLAY it —
|
||||||
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
|
// 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;
|
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
|
||||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||||
{
|
{
|
||||||
await PlayerService.TogglePlayPause();
|
await PlayerService.TogglePlayPause();
|
||||||
}
|
}
|
||||||
|
else if (Queue is not null)
|
||||||
|
{
|
||||||
|
await Queue.PlayTrack(Track);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await PlayerService.SelectTrackStreaming(Track);
|
await PlayerService.SelectTrackStreaming(Track);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
</MudStack>
|
</MudStack>
|
||||||
@if (ShareContent is not null)
|
@if (ShareContent is not null)
|
||||||
{
|
{
|
||||||
<div class="release-hero-share">
|
<div class="release-hero-share dd-accent-icon">
|
||||||
@ShareContent
|
@ShareContent
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (PlayContent is not null)
|
@if (PlayContent is not null)
|
||||||
{
|
{
|
||||||
<div class="release-hero-play">
|
<div class="release-hero-play dd-accent-icon">
|
||||||
@PlayContent
|
@PlayContent
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,14 +151,13 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The play affordance and share button sit over a dark image — force their icon glyphs to the
|
/* The play/share glyphs are coloured by the shared .dd-accent-icon treatment (green-accent in
|
||||||
light theme color regardless of MudBlazor's Secondary palette. Both PlayStateIcon and
|
both themes) applied on .release-hero-play / .release-hero-share in ReleaseHeroOverlay.razor —
|
||||||
SharePopover render MudIconButton / MudProgressCircular internals, so ::deep is required. */
|
see deepdrft-styles.css. No co-located colour rule here: the former white override was removed
|
||||||
::deep .release-hero-play .mud-icon-button,
|
because its glyph clauses (.mud-icon-button .mud-icon-root) could not reach the
|
||||||
::deep .release-hero-play .mud-progress-circular,
|
.mud-secondary-text !important glyph at wrapper specificity, and its spinner clause
|
||||||
::deep .release-hero-share .mud-icon-button {
|
(.mud-progress-circular) was live but is now correctly covered by .dd-accent-icon —
|
||||||
color: var(--deepdrft-white);
|
making the spinner green-accent (was white) in light mode, the one intentional light delta. */
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 599.98px) {
|
@media (max-width: 599.98px) {
|
||||||
.release-hero {
|
.release-hero {
|
||||||
|
|||||||
@@ -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 string LoadingLabel { get; set; } = "Finding a track…";
|
||||||
[Parameter] public EventCallback OnStreamStarted { get; set; }
|
[Parameter] public EventCallback OnStreamStarted { get; set; }
|
||||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||||
|
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||||
[Inject] public required ITrackDataService TrackData { get; set; }
|
[Inject] public required ITrackDataService TrackData { get; set; }
|
||||||
|
|
||||||
private bool _streamLoading;
|
private bool _streamLoading;
|
||||||
@@ -79,7 +80,12 @@
|
|||||||
_findingTrack = false;
|
_findingTrack = false;
|
||||||
StateHasChanged();
|
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);
|
await PlayerService.SelectTrackStreaming(track);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
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;
|
||||||
|
}
|
||||||
@@ -29,15 +29,17 @@
|
|||||||
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This
|
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This
|
||||||
host only toggles open/closed and centers the panel — it stays purely presentational. *@
|
host only toggles open/closed and centers the panel — it stays purely presentational. *@
|
||||||
|
|
||||||
<MudTooltip Text="Visualizer settings">
|
<div class="dd-accent-icon">
|
||||||
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
|
<MudTooltip Text="Visualizer settings">
|
||||||
Size="@IconSize"
|
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
|
||||||
Color="Color.Secondary"
|
Size="@IconSize"
|
||||||
Disabled="@(!RendererInfo.IsInteractive)"
|
Color="Color.Secondary"
|
||||||
OnClick="@Toggle"
|
Disabled="@(!RendererInfo.IsInteractive)"
|
||||||
aria-label="Visualizer settings"
|
OnClick="@Toggle"
|
||||||
aria-expanded="@_open" />
|
aria-label="Visualizer settings"
|
||||||
</MudTooltip>
|
aria-expanded="@_open" />
|
||||||
|
</MudTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
@* The tinted modal scrim that also HOLDS the panel. DarkBackground = the mild tint; OnClick on the scrim
|
@* The tinted modal scrim that also HOLDS the panel. DarkBackground = the mild tint; OnClick on the scrim
|
||||||
dismisses (knob-drag-safe, see header). The panel is the overlay's centered child; it stops click
|
dismisses (knob-drag-safe, see header). The panel is the overlay's centered child; it stops click
|
||||||
|
|||||||
@@ -34,147 +34,164 @@
|
|||||||
|
|
||||||
@if (Visible)
|
@if (Visible)
|
||||||
{
|
{
|
||||||
@* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
|
<MudGrid>
|
||||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
@* ── 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.">
|
<MudItem xs="10">
|
||||||
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
|
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||||
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
|
<MudTooltip Text="Show the sound, or hide the ribbon.">
|
||||||
Color="Color.Primary"
|
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
|
||||||
OnClick="@ToggleWaveform"
|
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
|
||||||
aria-label="Waveform ribbon on or off"
|
Color="Color.Primary"
|
||||||
aria-pressed="@ControlState.WaveformEnabled"/>
|
OnClick="@ToggleWaveform"
|
||||||
</div>
|
aria-label="Waveform ribbon on or off"
|
||||||
</MudTooltip>
|
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). *@
|
present, so visible only when BOTH are on (§3 truth table). *@
|
||||||
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
|
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
|
||||||
{
|
{
|
||||||
<MudTooltip Text="How hard the blobs body-check the beat.">
|
<MudTooltip Text="How hard the blobs body-check the beat.">
|
||||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
|
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
|
||||||
<RadialKnob Value="@ControlState.CollisionStrength"
|
<RadialKnob Value="@ControlState.CollisionStrength"
|
||||||
ValueChanged="@OnCollisionStrengthChanged"
|
ValueChanged="@OnCollisionStrengthChanged"
|
||||||
Min="0" Max="1" Step="0.001"
|
Min="0" Max="1" Step="0.001"
|
||||||
Size="64"
|
Size="64"
|
||||||
Color="Color.Primary"/>
|
Color="Color.Primary"/>
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
<MudIcon Icon="@Icons.Material.Filled.Compress"
|
||||||
</div>
|
Size="Size.Small"
|
||||||
</MudTooltip>
|
Color="Color.Primary"
|
||||||
}
|
Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||||
@* </div> *@
|
</div>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
|
||||||
@* Color applies to the whole field regardless of which subsystems are on, so it is pinned
|
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
|
||||||
far-right of row 1 and never reflows when collisions hides (§3). *@
|
{
|
||||||
<MudTooltip Text="How fast the lamp drifts through its colors.">
|
<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">
|
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
|
||||||
<RadialKnob Value="@ControlState.GradientRotationSpeed"
|
<RadialKnob Value="@ControlState.GradientRotationSpeed"
|
||||||
ValueChanged="@OnGradientRotationSpeedChanged"
|
ValueChanged="@OnGradientRotationSpeedChanged"
|
||||||
Min="0" Max="1" Step="0.001"
|
Min="0" Max="1" Step="0.001"
|
||||||
Size="64"
|
Size="64"
|
||||||
Color="Color.Primary"/>
|
Color="Color.Primary"/>
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||||
</div>
|
</div>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
|
||||||
<MudTooltip Text="Light the lamp — or let it go cold.">
|
<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">
|
<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)"
|
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
OnClick="@ToggleLava"
|
OnClick="@ToggleLava"
|
||||||
aria-label="Lava field on or off"
|
aria-label="Lava field on or off"
|
||||||
aria-pressed="@ControlState.LavaEnabled"/>
|
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" />
|
|
||||||
</div>
|
</div>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</div>
|
</MudItem>
|
||||||
}
|
|
||||||
|
@* ── Row 2 — WAVE section (only when waveform on). ── *@
|
||||||
@* ── Row 3 — LAVA section (only when lava on). ── *@
|
@if (ControlState.WaveformEnabled)
|
||||||
@if (ControlState.LavaEnabled)
|
{
|
||||||
{
|
<MudItem xs="2" Class="d-flex align-center">
|
||||||
<div class="wvc-row wvc-row-section">
|
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
|
||||||
<span class="wvc-section-label">LAVA:</span>
|
<span class="wvc-section-label">WAVE:</span>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
<MudItem xs="10">
|
||||||
<MudTooltip Text="How heavy the wax feels — float, or sink.">
|
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
|
<MudTooltip Text="How fast the sound rolls by.">
|
||||||
<RadialKnob Value="@ControlState.LavaGravity"
|
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
|
||||||
ValueChanged="@OnLavaGravityChanged"
|
<RadialKnob Value="@ControlState.ScrollSpeed"
|
||||||
Min="0" Max="1" Step="0.001"
|
ValueChanged="@OnScrollSpeedChanged"
|
||||||
Size="64"
|
Min="0" Max="1" Step="0.001"
|
||||||
Color="Color.Primary" />
|
Size="64"
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
Color="Color.Primary"/>
|
||||||
</div>
|
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Color="Color.Surface" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||||
</MudTooltip>
|
</div>
|
||||||
|
</MudTooltip>
|
||||||
|
|
||||||
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
|
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
|
||||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
|
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
|
||||||
<RadialKnob Value="@ControlState.LavaHeat"
|
<RadialKnob Value="@ControlState.WaveformWidth"
|
||||||
ValueChanged="@OnLavaHeatChanged"
|
ValueChanged="@OnWaveformWidthChanged"
|
||||||
Min="0" Max="1" Step="0.001"
|
Min="0" Max="1" Step="0.001"
|
||||||
Size="64"
|
Size="64"
|
||||||
Color="Color.Primary" />
|
Color="Color.Primary"/>
|
||||||
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||||
</div>
|
</div>
|
||||||
</MudTooltip>
|
</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.">
|
<MudItem xs="10" Class="d-flex align-center">
|
||||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
|
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||||
<RadialKnob Value="@ControlState.FluidAmount"
|
<MudTooltip Text="How heavy the wax feels — float, or sink.">
|
||||||
ValueChanged="@OnFluidAmountChanged"
|
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
|
||||||
Min="0" Max="1" Step="0.001"
|
<RadialKnob Value="@ControlState.LavaGravity"
|
||||||
Size="64"
|
ValueChanged="@OnLavaGravityChanged"
|
||||||
Color="Color.Primary" />
|
Min="0" Max="1" Step="0.001"
|
||||||
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
Size="64"
|
||||||
</div>
|
Color="Color.Primary"/>
|
||||||
</MudTooltip>
|
<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.">
|
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
|
||||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
|
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
|
||||||
<RadialKnob Value="@ControlState.FluidViscosity"
|
<RadialKnob Value="@ControlState.LavaHeat"
|
||||||
ValueChanged="@OnFluidViscosityChanged"
|
ValueChanged="@OnLavaHeatChanged"
|
||||||
Min="0" Max="1" Step="0.001"
|
Min="0" Max="1" Step="0.001"
|
||||||
Size="64"
|
Size="64"
|
||||||
Color="Color.Primary" />
|
Color="Color.Primary"/>
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||||
</div>
|
</div>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
</MudStack>
|
|
||||||
</div>
|
<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>
|
</div>
|
||||||
@@ -209,12 +226,14 @@
|
|||||||
private void ToggleLava()
|
private void ToggleLava()
|
||||||
{
|
{
|
||||||
ControlState.LavaEnabled = !ControlState.LavaEnabled;
|
ControlState.LavaEnabled = !ControlState.LavaEnabled;
|
||||||
|
ControlState.CoerceTheaterMode();
|
||||||
ControlState.NotifyChanged();
|
ControlState.NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleWaveform()
|
private void ToggleWaveform()
|
||||||
{
|
{
|
||||||
ControlState.WaveformEnabled = !ControlState.WaveformEnabled;
|
ControlState.WaveformEnabled = !ControlState.WaveformEnabled;
|
||||||
|
ControlState.CoerceTheaterMode();
|
||||||
ControlState.NotifyChanged();
|
ControlState.NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,8 @@
|
|||||||
color: var(--mud-palette-primary);
|
color: var(--mud-palette-primary);
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wvc-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
aria-label="Close privacy note"
|
aria-label="Close privacy note"
|
||||||
Class="deepdrft-privacy-modal-close" />
|
Class="deepdrft-privacy-modal-close" />
|
||||||
</div>
|
</div>
|
||||||
<p class="deepdrft-privacy-modal-body">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag’s gone.</p>
|
<p class="deepdrft-privacy-modal-body">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else.</p>
|
||||||
</div>
|
</div>
|
||||||
</MudOverlay>
|
</MudOverlay>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
WaveformVisualizer backdrop (z-index:0), keeping footer text fully legible. */
|
WaveformVisualizer backdrop (z-index:0), keeping footer text fully legible. */
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: var(--deepdrft-white);
|
background: var(--deepdrft-page-surface);
|
||||||
border-top: 1px solid var(--deepdrft-border);
|
border-top: 1px solid var(--deepdrft-border);
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
font-family: var(--deepdrft-font-display);
|
font-family: var(--deepdrft-font-display);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.deepdrft-footer-logo span {
|
.deepdrft-footer-logo span {
|
||||||
@@ -44,19 +44,19 @@
|
|||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: 0.18em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--deepdrft-muted);
|
color: var(--deepdrft-page-text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deepdrft-footer-links a:hover,
|
.deepdrft-footer-links a:hover,
|
||||||
.deepdrft-footer-links button:hover { color: var(--deepdrft-navy); }
|
.deepdrft-footer-links button:hover { color: var(--deepdrft-page-text); }
|
||||||
|
|
||||||
.deepdrft-footer-copy {
|
.deepdrft-footer-copy {
|
||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
color: var(--deepdrft-muted);
|
color: var(--deepdrft-page-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PRIVACY trigger — reset button chrome so it reads as a link, not a button element.
|
/* PRIVACY trigger — reset button chrome so it reads as a link, not a button element.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
|
|
||||||
@* Desktop Menu *@
|
@* Desktop Menu *@
|
||||||
<div class="d-none d-sm-flex">
|
<div class="d-none d-md-flex">
|
||||||
<nav class="@NavClass">
|
<nav class="@NavClass">
|
||||||
<MudStack Row AlignItems="AlignItems.Center">
|
<MudStack Row AlignItems="AlignItems.Center">
|
||||||
<a class="dd-nav-brand" href="/">
|
<a class="dd-nav-brand" href="/">
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Mobile Menu *@
|
@* Mobile Menu *@
|
||||||
<div class="d-flex d-sm-none">
|
<div class="d-flex d-md-none">
|
||||||
<nav class="@NavClass">
|
<nav class="@NavClass">
|
||||||
<MudStack Row AlignItems="AlignItems.Center">
|
<MudStack Row AlignItems="AlignItems.Center">
|
||||||
<a class="dd-nav-brand" href="/">
|
<a class="dd-nav-brand" href="/">
|
||||||
|
|||||||
@@ -16,7 +16,12 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
|
||||||
padding: 1.5rem 3rem;
|
/* Height is pinned to the shared --deepdrft-nav-height token so the main-content
|
||||||
|
clearance (.dd-main-content) always matches the bar exactly. Contents stay
|
||||||
|
vertically centred via align-items; horizontal padding only here. */
|
||||||
|
height: var(--deepdrft-nav-height);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 3rem;
|
||||||
|
|
||||||
border-bottom: 1px solid var(--deepdrft-border);
|
border-bottom: 1px solid var(--deepdrft-border);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -226,6 +231,6 @@
|
|||||||
/* Mobile padding — give the nav room to breathe without crowding */
|
/* Mobile padding — give the nav room to breathe without crowding */
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
.dd-nav {
|
.dd-nav {
|
||||||
padding: 1rem 1.25rem;
|
padding: 0 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />
|
<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />
|
||||||
<MudPopoverProvider />
|
<MudPopoverProvider />
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
|
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
|
||||||
<AudioPlayerProvider>
|
<AudioPlayerProvider>
|
||||||
<DeepDrftMenu Elevation="4" @bind-IsDarkMode="_isDarkMode" />
|
<DeepDrftMenu Elevation="4" @bind-IsDarkMode="_isDarkMode" />
|
||||||
<MudMainContent Class="flex-grow-1 pt-16 pb-8">
|
<MudMainContent Class="flex-grow-1 pb-8 dd-main-content">
|
||||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
||||||
@Body
|
@Body
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
@@ -42,10 +43,13 @@
|
|||||||
private string _audioPlayerClass = "minimized";
|
private string _audioPlayerClass = "minimized";
|
||||||
private const string DarkModeKey = "darkMode";
|
private const string DarkModeKey = "darkMode";
|
||||||
private bool _isDarkMode = false;
|
private bool _isDarkMode = false;
|
||||||
|
private bool? _lastAppliedDarkMode = null;
|
||||||
private PersistingComponentStateSubscription _persistingSubscription;
|
private PersistingComponentStateSubscription _persistingSubscription;
|
||||||
|
private IJSObjectReference? _themeModule;
|
||||||
|
|
||||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||||
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
|
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
|
||||||
|
[Inject] public required IJSRuntime JS { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
@@ -66,6 +70,24 @@
|
|||||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode);
|
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync dark mode class on <body> so 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.
|
||||||
|
// Gated: only fires on first render or when _isDarkMode actually changes, to avoid redundant
|
||||||
|
// JS calls on unrelated re-renders (e.g. audio player minimize/expand).
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
await base.OnAfterRenderAsync(firstRender);
|
||||||
|
|
||||||
|
if (firstRender || _isDarkMode != _lastAppliedDarkMode)
|
||||||
|
{
|
||||||
|
_lastAppliedDarkMode = _isDarkMode;
|
||||||
|
_themeModule ??= await JS.InvokeAsync<IJSObjectReference>(
|
||||||
|
"import", "./_content/DeepDrftShared.Client/js/theme/theme.js");
|
||||||
|
await _themeModule.InvokeVoidAsync("setBodyThemeClass", _isDarkMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Theme wrapper class for CSS targeting
|
// Theme wrapper class for CSS targeting
|
||||||
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
|
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
|
||||||
|
|
||||||
@@ -80,6 +102,15 @@
|
|||||||
_persistingSubscription.Dispose();
|
_persistingSubscription.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_themeModule != null)
|
||||||
|
{
|
||||||
|
try { await _themeModule.DisposeAsync(); }
|
||||||
|
catch (JSDisconnectedException) { /* circuit torn down */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ToggleAudioPlayerMinimized(bool isMinimized)
|
private void ToggleAudioPlayerMinimized(bool isMinimized)
|
||||||
{
|
{
|
||||||
_audioPlayerClass = isMinimized ? "minimized" : "expanded";
|
_audioPlayerClass = isMinimized ? "minimized" : "expanded";
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@inject IJSRuntime JsRuntime
|
@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.
|
THE LINER NOTES — a numbered three-movement editorial essay.
|
||||||
@@ -23,8 +24,8 @@
|
|||||||
|
|
||||||
@* ── HERO — the page opener. Reuses the .hero-* type scale with About's own words.
|
@* ── HERO — the page opener. Reuses the .hero-* type scale with About's own words.
|
||||||
NOT DeepDrftHero (that hard-codes the Deep/DRFT masthead + streaming CTA). ── *@
|
NOT DeepDrftHero (that hard-codes the Deep/DRFT masthead + streaming CTA). ── *@
|
||||||
<section class="hero">
|
<section class="hero pb-20">
|
||||||
<MudGrid Spacing="0" Style="height: 100%;">
|
<MudGrid Spacing="0">
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<div class="hero-left">
|
<div class="hero-left">
|
||||||
<div class="hero-eyebrow @AnimClass">Charleston, South Carolina</div>
|
<div class="hero-eyebrow @AnimClass">Charleston, South Carolina</div>
|
||||||
@@ -343,12 +344,7 @@
|
|||||||
}
|
}
|
||||||
return sb.ToString();
|
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(
|
private record Member(
|
||||||
string Name,
|
string Name,
|
||||||
string Role,
|
string Role,
|
||||||
@@ -361,13 +357,13 @@
|
|||||||
new(
|
new(
|
||||||
Name: "Khabran Peters",
|
Name: "Khabran Peters",
|
||||||
Role: "Production · Sound Design · Live",
|
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",
|
PortraitImage1: "img/dd-khabran-bw.jpeg",
|
||||||
PortraitImage2: "img/dd-khabran.jpeg"),
|
PortraitImage2: "img/dd-khabran.jpeg"),
|
||||||
new(
|
new(
|
||||||
Name: "Daniel Harvey",
|
Name: "Daniel Harvey",
|
||||||
Role: "Production · Sound Design · Live",
|
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",
|
PortraitImage1: "img/dd-daniel-bw.jpeg",
|
||||||
PortraitImage2: "img/dd-daniel.jpeg"),
|
PortraitImage2: "img/dd-daniel.jpeg"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
|
|
||||||
/* ── HERO — the page opener (type scale from Home's .hero-*) ── */
|
/* ── HERO — the page opener (type scale from Home's .hero-*) ── */
|
||||||
.hero {
|
.hero {
|
||||||
min-height: 100vh;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 6rem 3rem;
|
padding: 6rem 3rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--deepdrft-white);
|
background: var(--deepdrft-page-surface);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--deepdrft-white);
|
background: var(--deepdrft-page-surface);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@
|
|||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.28em;
|
||||||
color: var(--deepdrft-green-accent);
|
color: var(--deepdrft-green);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: 1.8rem;
|
margin-bottom: 1.8rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -70,7 +69,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--deepdrft-green-accent);
|
background: var(--deepdrft-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
@@ -79,21 +78,21 @@
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 0.92;
|
line-height: 0.92;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
animation-delay: 0.22s;
|
animation-delay: 0.22s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title em {
|
.hero-title em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--deepdrft-green);
|
color: var(--deepdrft-green-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-desc {
|
.hero-desc {
|
||||||
font-family: var(--deepdrft-font-body);
|
font-family: var(--deepdrft-font-body);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
max-width: 36ch;
|
max-width: 36ch;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
@@ -109,7 +108,7 @@
|
|||||||
.movement {
|
.movement {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(140px, 14%) minmax(0, 1fr);
|
grid-template-columns: minmax(140px, 14%) minmax(0, 1fr);
|
||||||
background: var(--deepdrft-white);
|
background: var(--deepdrft-page-surface);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,14 +140,14 @@
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
opacity: 0.14;
|
opacity: 0.14;
|
||||||
padding-left: 1.4rem;
|
padding-left: 1.4rem;
|
||||||
transition: color 0.5s ease, opacity 0.5s ease;
|
transition: color 0.5s ease, opacity 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement.is-active .rail-numeral {
|
.movement.is-active .rail-numeral {
|
||||||
color: var(--deepdrft-green-accent);
|
color: var(--deepdrft-green);
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +162,7 @@
|
|||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
letter-spacing: 0.24em;
|
letter-spacing: 0.24em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--deepdrft-muted);
|
color: var(--deepdrft-page-text-muted);
|
||||||
writing-mode: vertical-rl;
|
writing-mode: vertical-rl;
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
@@ -195,7 +194,7 @@
|
|||||||
|
|
||||||
.wave-stroke path {
|
.wave-stroke path {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--deepdrft-green-accent);
|
stroke: var(--deepdrft-green);
|
||||||
stroke-width: 1.4;
|
stroke-width: 1.4;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
@@ -207,7 +206,7 @@
|
|||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.28em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +220,7 @@
|
|||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.28em;
|
||||||
color: var(--deepdrft-green-accent);
|
color: var(--deepdrft-green);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: 1.4rem;
|
margin-bottom: 1.4rem;
|
||||||
}
|
}
|
||||||
@@ -231,20 +230,20 @@
|
|||||||
font-size: clamp(2.6rem, 5vw, 4.2rem);
|
font-size: clamp(2.6rem, 5vw, 4.2rem);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.02;
|
line-height: 1.02;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-title em {
|
.movement-title em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--deepdrft-green);
|
color: var(--deepdrft-green-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.movement-prose {
|
.movement-prose {
|
||||||
font-family: var(--deepdrft-font-body);
|
font-family: var(--deepdrft-font-body);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.85;
|
line-height: 1.85;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
max-width: 56ch;
|
max-width: 56ch;
|
||||||
}
|
}
|
||||||
@@ -279,14 +278,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Graceful-degrade slot shown until a portrait file lands. A flat tonal panel in
|
/* Graceful-degrade slot shown until a portrait file lands. A flat tonal panel in
|
||||||
the navy family, matching the circular portrait frame. */
|
the navy family, matching the circular portrait frame. Mixes a touch of navy into
|
||||||
|
--deepdrft-page-surface so the gradient inverts with the section in dark mode. */
|
||||||
.bio-portrait-placeholder {
|
.bio-portrait-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
background:
|
background:
|
||||||
linear-gradient(160deg,
|
linear-gradient(160deg,
|
||||||
color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-white)) 0%,
|
color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-page-surface)) 0%,
|
||||||
color-mix(in srgb, var(--deepdrft-navy) 16%, var(--deepdrft-white)) 100%);
|
color-mix(in srgb, var(--deepdrft-navy) 16%, var(--deepdrft-page-surface)) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The marginalia caption — mono, sits directly under the framed portrait. */
|
/* The marginalia caption — mono, sits directly under the framed portrait. */
|
||||||
@@ -295,7 +295,7 @@
|
|||||||
font-size: 0.56rem;
|
font-size: 0.56rem;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--deepdrft-muted);
|
color: var(--deepdrft-page-text-muted);
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
padding-left: 0.1rem;
|
padding-left: 0.1rem;
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@
|
|||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +317,7 @@
|
|||||||
font-family: var(--deepdrft-font-body);
|
font-family: var(--deepdrft-font-body);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
font-size: 0.56rem;
|
font-size: 0.56rem;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--deepdrft-muted);
|
color: var(--deepdrft-page-text-muted);
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.28em;
|
||||||
color: var(--deepdrft-green-accent);
|
color: var(--deepdrft-green);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: 1.2rem;
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
@@ -467,7 +467,7 @@
|
|||||||
font-family: var(--deepdrft-font-display);
|
font-family: var(--deepdrft-font-display);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
min-width: 7rem;
|
min-width: 7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +477,7 @@
|
|||||||
font-family: var(--deepdrft-font-body);
|
font-family: var(--deepdrft-font-body);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +502,7 @@
|
|||||||
font-size: clamp(1.8rem, 3.4vw, 2.9rem);
|
font-size: clamp(1.8rem, 3.4vw, 2.9rem);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══════════════════ CLOSING CTA (reused vocabulary) ══════════════════ */
|
/* ══════════════════ CLOSING CTA (reused vocabulary) ══════════════════ */
|
||||||
@@ -606,6 +606,14 @@
|
|||||||
|
|
||||||
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
|
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
|
||||||
|
|
||||||
|
/* ── DARK-MODE OVERRIDES ── */
|
||||||
|
/* In dark mode, decorative em accents that use --deepdrft-green (#1A3C34) become
|
||||||
|
near-invisible on the navy ground. Switch to --deepdrft-green-accent (#3D7A68). */
|
||||||
|
:global(.deepdrft-theme-dark) .hero-title em,
|
||||||
|
:global(.deepdrft-theme-dark) .movement-title em {
|
||||||
|
color: var(--deepdrft-green-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ══════════════════ RESPONSIVE COLLAPSE ══════════════════
|
/* ══════════════════ RESPONSIVE COLLAPSE ══════════════════
|
||||||
|
|
||||||
Below 960px the rail collapses: the spine + vertical numeral can't survive a
|
Below 960px the rail collapses: the spine + vertical numeral can't survive a
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
@page "/cuts"
|
@page "/cuts"
|
||||||
|
@using DeepDrftModels.Enums
|
||||||
@using DeepDrftPublic.Client.Controls
|
@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.
|
@* 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. *@
|
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
@page "/archive"
|
@page "/archive"
|
||||||
@using DeepDrftModels.Enums
|
@using DeepDrftModels.Enums
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>DeepDrft Archive</PageTitle>
|
<SeoHead Model="@SeoModel.ForBrowse(Seo, null, "/archive")" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 36px 0 20px 0;
|
padding: 12px 0 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-controls-search {
|
.archive-controls-search {
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
@inherits CutDetailBase
|
@inherits CutDetailBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
|
|
||||||
|
|
||||||
@if (ViewModel.IsLoading)
|
@if (ViewModel.IsLoading)
|
||||||
{
|
{
|
||||||
@@ -17,6 +16,9 @@
|
|||||||
}
|
}
|
||||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
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-container">
|
||||||
<div class="deepdrft-track-detail-masthead">
|
<div class="deepdrft-track-detail-masthead">
|
||||||
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
||||||
@@ -37,6 +39,14 @@ else
|
|||||||
var hasYear = release.ReleaseDate is not null;
|
var hasYear = release.ReleaseDate is not null;
|
||||||
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : 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"
|
<ReleaseDetailScaffold Title="@release.Title"
|
||||||
Artist="@release.Artist"
|
Artist="@release.Artist"
|
||||||
Track="@firstTrack"
|
Track="@firstTrack"
|
||||||
@@ -54,11 +64,24 @@ else
|
|||||||
TrackEntryKey="@firstTrack?.EntryKey" />
|
TrackEntryKey="@firstTrack?.EntryKey" />
|
||||||
</Ambient>
|
</Ambient>
|
||||||
<TopRightAction>
|
<TopRightAction>
|
||||||
@* Lava-lamp icon → popover panel (full parity, §3d-revised). Sits top-right across from the
|
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both are
|
||||||
back link, clear of the header's own Play/Share affordances below. *@
|
controls over the experience, not release content, so both stay in Theater Mode (§4/OQ4).
|
||||||
<WaveformVisualizerControlPopover />
|
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>
|
</TopRightAction>
|
||||||
<Header>
|
<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). *@
|
@* 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-header">
|
||||||
<div class="cut-detail-meta">
|
<div class="cut-detail-meta">
|
||||||
@@ -83,7 +106,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="cut-detail-actions">
|
<div class="cut-detail-actions dd-accent-icon dd-accent-fill">
|
||||||
@* Header Play loads the full album into the queue at index 0 (§3.4 seam,
|
@* Header Play loads the full album into the queue at index 0 (§3.4 seam,
|
||||||
closed P11 W1). Disabled until at least one streamable track is resolved. *@
|
closed P11 W1). Disabled until at least one streamable track is resolved. *@
|
||||||
<MudButton Variant="Variant.Filled"
|
<MudButton Variant="Variant.Filled"
|
||||||
@@ -117,8 +140,13 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
<BodyContent>
|
<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. *@
|
@* Blurb sits between the header and the track-list divider. *@
|
||||||
<ReleaseDescription Description="@release.Description" />
|
<ReleaseDescription Description="@release.Description" />
|
||||||
<MudDivider Class="cut-detail-divider" />
|
<MudDivider Class="cut-detail-divider" />
|
||||||
@@ -133,7 +161,7 @@ else
|
|||||||
{
|
{
|
||||||
var track = ViewModel.Tracks[i];
|
var track = ViewModel.Tracks[i];
|
||||||
var index = i;
|
var index = i;
|
||||||
<div class="cut-detail-track-row">
|
<div class="cut-detail-track-row dd-accent-icon">
|
||||||
<span class="cut-detail-track-number">@track.TrackNumber</span>
|
<span class="cut-detail-track-number">@track.TrackNumber</span>
|
||||||
<div class="cut-detail-track-play">
|
<div class="cut-detail-track-play">
|
||||||
<PlayStateIcon Track="@track"
|
<PlayStateIcon Track="@track"
|
||||||
@@ -149,12 +177,15 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</BodyContent>
|
</BodyContent>
|
||||||
</ReleaseDetailScaffold>
|
</ReleaseDetailScaffold>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@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; }
|
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||||
|
|
||||||
// Header Play: load the full album into the queue starting at track 0.
|
// Header Play: load the full album into the queue starting at track 0.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
|
using DeepDrftPublic.Client.Services;
|
||||||
using DeepDrftPublic.Client.ViewModels;
|
using DeepDrftPublic.Client.ViewModels;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
@@ -19,7 +20,41 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
|
|||||||
[Inject] public required CutDetailViewModel ViewModel { get; set; }
|
[Inject] public required CutDetailViewModel ViewModel { get; set; }
|
||||||
[Inject] public required PersistentComponentState PersistentState { 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 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.
|
// 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
|
// /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;
|
private bool _loaded;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
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()
|
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;
|
if (_loaded && _loadedKey == EntryKey) return;
|
||||||
|
|
||||||
// Capture the key synchronously before any await so a re-entrant call (rapid navigation or a
|
// 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;
|
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.
|
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
|
||||||
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
|
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>
|
<SeoHead Model="@SeoModel.ForHome(Seo)" />
|
||||||
|
|
||||||
@* Hero - split 50/50 *@
|
@* Hero - split 50/50 *@
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 6rem 3rem;
|
padding: 6rem 3rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--deepdrft-white);
|
background: var(--deepdrft-page-surface);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 2rem 3rem;
|
padding: 2rem 3rem;
|
||||||
background: var(--deepdrft-white);
|
background: var(--deepdrft-page-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider-line {
|
.divider-line {
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
letter-spacing: 0.25em;
|
letter-spacing: 0.25em;
|
||||||
color: var(--deepdrft-muted);
|
color: var(--deepdrft-page-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
/* ── SECTION (sound) ── */
|
/* ── SECTION (sound) ── */
|
||||||
.section {
|
.section {
|
||||||
padding: 7rem 3rem;
|
padding: 7rem 3rem;
|
||||||
background: var(--deepdrft-white);
|
background: var(--deepdrft-page-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
letter-spacing: 0.28em;
|
letter-spacing: 0.28em;
|
||||||
color: var(--deepdrft-green-accent);
|
color: var(--deepdrft-green);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: 1.2rem;
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
@@ -74,12 +74,12 @@
|
|||||||
font-size: clamp(2.8rem, 5vw, 4.5rem);
|
font-size: clamp(2.8rem, 5vw, 4.5rem);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title em {
|
.section-title em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--deepdrft-green);
|
color: var(--deepdrft-green-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The body column is already full height; make it a flex container that
|
/* The body column is already full height; make it a flex container that
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
font-family: var(--deepdrft-font-body);
|
font-family: var(--deepdrft-font-body);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
max-width: 52ch;
|
max-width: 52ch;
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.medium-card {
|
.medium-card {
|
||||||
background: var(--deepdrft-white);
|
background: var(--deepdrft-page-surface);
|
||||||
border: 1px solid var(--deepdrft-border);
|
border: 1px solid var(--deepdrft-border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
color: var(--deepdrft-muted);
|
color: var(--deepdrft-page-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
font-family: var(--deepdrft-font-display);
|
font-family: var(--deepdrft-font-display);
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
font-family: var(--deepdrft-font-body);
|
font-family: var(--deepdrft-font-body);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +333,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--deepdrft-white);
|
background: var(--deepdrft-page-surface);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +387,7 @@
|
|||||||
font-family: var(--deepdrft-font-display);
|
font-family: var(--deepdrft-font-display);
|
||||||
font-size: clamp(2rem, 3.5vw, 3rem);
|
font-size: clamp(2rem, 3.5vw, 3rem);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
@@ -417,7 +417,7 @@
|
|||||||
|
|
||||||
.connect-option:hover {
|
.connect-option:hover {
|
||||||
border-color: var(--deepdrft-green-accent);
|
border-color: var(--deepdrft-green-accent);
|
||||||
background: #f3f6f4;
|
background: color-mix(in srgb, var(--deepdrft-page-surface), var(--deepdrft-green-accent) 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-icon {
|
.option-icon {
|
||||||
@@ -426,14 +426,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--deepdrft-navy);
|
/* Inversion pair with the glyph below: a contrast chip against the page surface
|
||||||
|
(navy chip / white glyph in light; white chip / navy glyph on the dark ground). */
|
||||||
|
background: var(--deepdrft-page-text);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-icon svg {
|
.option-icon svg {
|
||||||
width: 0.9rem;
|
width: 0.9rem;
|
||||||
height: 0.9rem;
|
height: 0.9rem;
|
||||||
stroke: var(--deepdrft-white);
|
stroke: var(--deepdrft-page-surface);
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
@@ -442,14 +444,14 @@
|
|||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
color: var(--deepdrft-navy);
|
color: var(--deepdrft-page-text);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-text-sub {
|
.option-text-sub {
|
||||||
font-family: var(--deepdrft-font-body);
|
font-family: var(--deepdrft-font-body);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--deepdrft-muted);
|
color: var(--deepdrft-page-text-muted);
|
||||||
margin-top: 0.1rem;
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,6 +560,14 @@
|
|||||||
|
|
||||||
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
|
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
|
||||||
|
|
||||||
|
/* ── DARK-MODE OVERRIDES ── */
|
||||||
|
/* In dark mode, decorative em accents that use --deepdrft-green (#1A3C34) become
|
||||||
|
near-invisible on the navy ground. Switch to --deepdrft-green-accent (#3D7A68). */
|
||||||
|
:global(.deepdrft-theme-dark) .section-title em,
|
||||||
|
:global(.deepdrft-theme-dark) .connect-title em {
|
||||||
|
color: var(--deepdrft-green-accent);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
.cta-banner {
|
.cta-banner {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
@inherits ReleaseDetailBase
|
@inherits ReleaseDetailBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
|
|
||||||
|
|
||||||
@if (ViewModel.IsLoading)
|
@if (ViewModel.IsLoading)
|
||||||
{
|
{
|
||||||
@@ -16,6 +15,9 @@
|
|||||||
}
|
}
|
||||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
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-container">
|
||||||
<div class="deepdrft-track-detail-masthead">
|
<div class="deepdrft-track-detail-masthead">
|
||||||
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
|
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
|
||||||
@@ -32,6 +34,11 @@ else if (ViewModel.NotFound || ViewModel.Release is null)
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var release = ViewModel.Release;
|
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
|
@* 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
|
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
|
||||||
@@ -41,7 +48,7 @@ else
|
|||||||
TrackId="@ViewModel.Track?.Id"
|
TrackId="@ViewModel.Track?.Id"
|
||||||
TrackEntryKey="@ViewModel.Track?.EntryKey" />
|
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">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
|
||||||
@* Mix keeps the scaffold solely for the Phase 10 top row (back link | controls | lava-lamp).
|
@* 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
|
Title/artist/genre/date/share/play all move into the overlaid hero, so the scaffold's
|
||||||
@@ -56,13 +63,26 @@ else
|
|||||||
ShowMeta="false"
|
ShowMeta="false"
|
||||||
ShowShareRow="false">
|
ShowShareRow="false">
|
||||||
<TopRightAction>
|
<TopRightAction>
|
||||||
@* Lava-lamp icon → popover panel, top-right across from the back link (Phase 12
|
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both stay
|
||||||
§3d-revised). Replaces the former inline TopContent knob-bar: the icon IS the toggle
|
visible in Theater Mode — controls over the experience, not release content (§4/OQ4).
|
||||||
and the popover IS the panel. Mix takes the cleanest anchor case (§8e) — the popover's
|
Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
|
||||||
default bottom-right anchor opens down over the full-bleed field. *@
|
<div class="dd-detail-top-actions">
|
||||||
<WaveformVisualizerControlPopover />
|
@* 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>
|
</TopRightAction>
|
||||||
<Hero>
|
<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-as-background hero with all metadata overlaid, square `mix-hero` sizing. The
|
||||||
cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
|
cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
|
||||||
to null). Share and play ride in as slots, matching Sessions. *@
|
to null). Share and play ride in as slots, matching Sessions. *@
|
||||||
@@ -86,10 +106,17 @@ else
|
|||||||
}
|
}
|
||||||
</PlayContent>
|
</PlayContent>
|
||||||
</ReleaseHeroOverlay>
|
</ReleaseHeroOverlay>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Hero>
|
</Hero>
|
||||||
<BodyContent>
|
<BodyContent>
|
||||||
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
|
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Hero region. *@
|
||||||
<ReleaseDescription Description="@release.Description" />
|
<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>
|
</BodyContent>
|
||||||
</ReleaseDetailScaffold>
|
</ReleaseDetailScaffold>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
@@ -99,11 +126,14 @@ else
|
|||||||
@code {
|
@code {
|
||||||
protected override string PersistKey => "mix-detail";
|
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
|
// 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
|
// 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()
|
private async Task PlayTrack()
|
||||||
{
|
{
|
||||||
var track = ViewModel.Track;
|
var track = ViewModel.Track;
|
||||||
@@ -114,6 +144,10 @@ else
|
|||||||
{
|
{
|
||||||
await PlayerService.TogglePlayPause();
|
await PlayerService.TogglePlayPause();
|
||||||
}
|
}
|
||||||
|
else if (Queue is not null)
|
||||||
|
{
|
||||||
|
await Queue.PlayTrack(track);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await PlayerService.SelectTrackStreaming(track);
|
await PlayerService.SelectTrackStreaming(track);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
@page "/mixes"
|
@page "/mixes"
|
||||||
|
@using DeepDrftModels.Enums
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@inherits MediumBrowseBase
|
@inherits MediumBrowseBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>DeepDrft Mixes</PageTitle>
|
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Mix, "/mixes")" />
|
||||||
|
|
||||||
<ReleaseGallery Releases="@Releases"
|
<ReleaseGallery Releases="@Releases"
|
||||||
Loading="@Loading"
|
Loading="@Loading"
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
@page "/404"
|
@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">
|
<MudText Typo="Typo.h3">
|
||||||
Not Found
|
Not Found
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using DeepDrftModels.DTOs;
|
using DeepDrftModels.DTOs;
|
||||||
|
using DeepDrftPublic.Client.Services;
|
||||||
using DeepDrftPublic.Client.ViewModels;
|
using DeepDrftPublic.Client.ViewModels;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
@@ -17,7 +18,41 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
|
|||||||
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
|
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
|
||||||
[Inject] public required PersistentComponentState PersistentState { 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 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.
|
// 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
|
// /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 abstract string PersistKey { get; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
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()
|
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
|
// Re-run whenever the route key changes. Component instances are reused across
|
||||||
// same-template navigations, so the load decision must live here, not in
|
// same-template navigations, so the load decision must live here, not in
|
||||||
// OnInitialized (which fires once per instance).
|
// OnInitialized (which fires once per instance).
|
||||||
@@ -69,7 +123,16 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
|
|||||||
return Task.CompletedTask;
|
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.
|
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
|
||||||
protected sealed record BridgedDetail(ReleaseDto Release, TrackDto? Track);
|
protected sealed record BridgedDetail(ReleaseDto Release, TrackDto? Track);
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
@inherits ReleaseDetailBase
|
@inherits ReleaseDetailBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
|
|
||||||
|
|
||||||
@if (ViewModel.IsLoading)
|
@if (ViewModel.IsLoading)
|
||||||
{
|
{
|
||||||
@@ -20,6 +19,9 @@
|
|||||||
}
|
}
|
||||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
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-container">
|
||||||
<div class="deepdrft-track-detail-masthead">
|
<div class="deepdrft-track-detail-masthead">
|
||||||
<MudText Typo="Typo.h4" Align="Align.Center">Session not found.</MudText>
|
<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.
|
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
|
||||||
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
|
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
|
@* 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
|
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
|
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"
|
TrackId="@ViewModel.Track?.Id"
|
||||||
TrackEntryKey="@ViewModel.Track?.EntryKey" />
|
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">
|
<div class="session-detail-top-row">
|
||||||
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
|
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
|
||||||
← All sessions
|
← All sessions
|
||||||
</MudLink>
|
</MudLink>
|
||||||
|
|
||||||
@* Lava-lamp icon → popover panel (full parity, §3e/§3d-revised). Anchored top-right, clear of
|
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). The whole top
|
||||||
the hero overlay and the share/play affordances overlaid on the hero below. *@
|
row (back + theater + lava) stays in Theater Mode — controls, not release content (§4/OQ4). *@
|
||||||
<WaveformVisualizerControlPopover />
|
<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>
|
</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 —
|
@* 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
|
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. *@
|
thumb would duplicate the background. That logic lives in ReleaseHeroOverlay. *@
|
||||||
@@ -86,6 +104,8 @@ else
|
|||||||
</ReleaseHeroOverlay>
|
</ReleaseHeroOverlay>
|
||||||
|
|
||||||
<ReleaseDescription Description="@release.Description" />
|
<ReleaseDescription Description="@release.Description" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
}
|
}
|
||||||
@@ -93,11 +113,14 @@ else
|
|||||||
@code {
|
@code {
|
||||||
protected override string PersistKey => "session-detail";
|
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
|
// 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
|
// 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()
|
private async Task PlayTrack()
|
||||||
{
|
{
|
||||||
var track = ViewModel.Track;
|
var track = ViewModel.Track;
|
||||||
@@ -108,6 +131,10 @@ else
|
|||||||
{
|
{
|
||||||
await PlayerService.TogglePlayPause();
|
await PlayerService.TogglePlayPause();
|
||||||
}
|
}
|
||||||
|
else if (Queue is not null)
|
||||||
|
{
|
||||||
|
await Queue.PlayTrack(track);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await PlayerService.SelectTrackStreaming(track);
|
await PlayerService.SelectTrackStreaming(track);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
@page "/sessions"
|
@page "/sessions"
|
||||||
|
@using DeepDrftModels.Enums
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@inherits MediumBrowseBase
|
@inherits MediumBrowseBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>DeepDrft Sessions</PageTitle>
|
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Session, "/sessions")" />
|
||||||
|
|
||||||
<ReleaseGallery Releases="@Releases"
|
<ReleaseGallery Releases="@Releases"
|
||||||
Loading="@Loading"
|
Loading="@Loading"
|
||||||
|
|||||||
@@ -10,18 +10,35 @@ namespace DeepDrftPublic.Client.Services;
|
|||||||
/// — it adds no new playback semantics.
|
/// — it adds no new playback semantics.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are
|
/// <b>Two-level deque model (the load-bearing invariant).</b> The queue is a deque whose
|
||||||
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/
|
/// <see cref="Current"/> track (the item at <see cref="CurrentIndex"/>) is the live "front of play".
|
||||||
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/>
|
/// Two families of mutation enter the deque from opposite ends:
|
||||||
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> +
|
/// <list type="bullet">
|
||||||
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only
|
/// <item><b>PLAY (manual)</b> — <see cref="PlayTrack"/> / <see cref="PlayRelease"/> prepend to the
|
||||||
/// adding new ones — so consumers written against today's surface keep working.
|
/// <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>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// With an empty queue (<see cref="CurrentIndex"/> == -1) the queue is dormant: it drives nothing and
|
/// 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
|
/// 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>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IQueueService
|
public interface IQueueService
|
||||||
@@ -41,9 +58,10 @@ public interface IQueueService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet —
|
/// 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
|
/// 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"/>)
|
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="PlayTrack"/>/<see cref="Next"/>/
|
||||||
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through
|
/// <see cref="Previous"/>) or on <see cref="Clear"/>. The player bar reads this to route the first
|
||||||
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone.
|
/// play gesture through <see cref="Start"/> (which begins the armed release) rather than streaming
|
||||||
|
/// the staged track alone.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool IsArmed { get; }
|
bool IsArmed { get; }
|
||||||
|
|
||||||
@@ -55,26 +73,40 @@ public interface IQueueService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised whenever the queue's contents or current position change. The player bar subscribes
|
/// 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>
|
/// </summary>
|
||||||
event Action? QueueChanged;
|
event Action? QueueChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming
|
/// Manual PLAY of a single track: prepends <paramref name="track"/> to the <em>front</em> of the
|
||||||
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts
|
/// deque, removes the previously-current track, makes <paramref name="track"/> the new head/current,
|
||||||
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses
|
/// and starts streaming it. The rest of the queue (everything that sat after the old current) stays
|
||||||
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to
|
/// intact behind the new head. Into a dormant queue this simply becomes the sole head and plays.
|
||||||
/// the end from there. No-op when <paramref name="tracks"/> is empty.
|
/// 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>
|
/// </summary>
|
||||||
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
|
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT
|
/// 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
|
/// 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
|
/// performs no JS interop, so it runs identically during prerender and after WASM boot. It replaces
|
||||||
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which
|
/// the queue (an armed embed is a fresh staged release, not a prepend). The first play gesture (see
|
||||||
/// keeps the loaded release queued so it advances through its tracks. No-op when
|
/// <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which keeps the loaded
|
||||||
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed).
|
/// release queued so it advances through its tracks. No-op when <paramref name="tracks"/> is empty
|
||||||
|
/// (the queue stays empty and disarmed).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void Arm(IEnumerable<TrackDto> tracks);
|
void Arm(IEnumerable<TrackDto> tracks);
|
||||||
|
|
||||||
@@ -88,18 +120,24 @@ public interface IQueueService
|
|||||||
Task Start();
|
Task Start();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Appends a track to the end of the queue without changing what is currently playing.
|
/// 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) the append leaves a coherent
|
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) while a track is already playing
|
||||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
/// externally (through the attached player but not via the queue), the append first seeds the head
|
||||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
/// 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>
|
/// </summary>
|
||||||
void Enqueue(TrackDto track);
|
void Enqueue(TrackDto track);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Appends tracks to the end of the queue without changing what is currently playing.
|
/// Appends tracks to the <em>end</em> of the queue without changing what is currently playing.
|
||||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
|
/// Into a dormant queue while a track is already playing externally, the append first seeds the head
|
||||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
/// with that now-playing track (see <see cref="Enqueue"/>), then appends the range. Into a fully
|
||||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
/// 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>
|
/// </summary>
|
||||||
void EnqueueRange(IEnumerable<TrackDto> tracks);
|
void EnqueueRange(IEnumerable<TrackDto> tracks);
|
||||||
|
|
||||||
@@ -136,6 +174,15 @@ public interface IQueueService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task Previous();
|
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>
|
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
|
||||||
void Clear();
|
void Clear();
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ using DeepDrftModels.DTOs;
|
|||||||
namespace DeepDrftPublic.Client.Services;
|
namespace DeepDrftPublic.Client.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <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,
|
/// <see cref="IStreamingPlayerService"/>. Holds the ordered list and current index as pure state,
|
||||||
/// drives playback through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>,
|
/// 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>
|
/// <para>
|
||||||
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
|
/// 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
|
/// 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
|
/// 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
|
/// 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>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class QueueService : IQueueService, IDisposable
|
public sealed class QueueService : IQueueService, IDisposable
|
||||||
@@ -54,23 +57,42 @@ public sealed class QueueService : IQueueService, IDisposable
|
|||||||
_player.TrackEnded += OnTrackEnded;
|
_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)
|
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
|
||||||
{
|
{
|
||||||
var list = tracks.ToList();
|
var list = tracks.ToList();
|
||||||
if (list.Count == 0) return;
|
if (list.Count == 0) return;
|
||||||
|
|
||||||
var start = Math.Clamp(startIndex, 0, list.Count - 1);
|
var start = Math.Clamp(startIndex, 0, list.Count - 1);
|
||||||
|
PrependForPlay(list, start);
|
||||||
_items.Clear();
|
|
||||||
_items.AddRange(list);
|
|
||||||
CurrentIndex = start;
|
|
||||||
// Playback is now starting for real, so the queue is no longer merely armed.
|
|
||||||
IsArmed = false;
|
|
||||||
QueueChanged?.Invoke();
|
QueueChanged?.Invoke();
|
||||||
|
|
||||||
await PlayCurrent();
|
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)
|
public void Arm(IEnumerable<TrackDto> tracks)
|
||||||
{
|
{
|
||||||
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||||
@@ -94,27 +116,47 @@ public sealed class QueueService : IQueueService, IDisposable
|
|||||||
|
|
||||||
public void Enqueue(TrackDto track)
|
public void Enqueue(TrackDto track)
|
||||||
{
|
{
|
||||||
|
SeedHeadFromPlayerIfDormant();
|
||||||
_items.Add(track);
|
_items.Add(track);
|
||||||
// OQ8: appending into a dormant (empty) queue leaves a coherent CurrentIndex so the next
|
EnsureCoherentDormantIndex();
|
||||||
// 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;
|
|
||||||
QueueChanged?.Invoke();
|
QueueChanged?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void EnqueueRange(IEnumerable<TrackDto> tracks)
|
public void EnqueueRange(IEnumerable<TrackDto> tracks)
|
||||||
{
|
{
|
||||||
var before = _items.Count;
|
var toAdd = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||||
_items.AddRange(tracks);
|
if (toAdd.Count == 0) return;
|
||||||
if (_items.Count == before) return;
|
|
||||||
// OQ8: see Enqueue — first append into a dormant queue stages a coherent CurrentIndex
|
SeedHeadFromPlayerIfDormant();
|
||||||
// without playing. The first newly-appended track becomes current.
|
_items.AddRange(toAdd);
|
||||||
if (CurrentIndex == -1)
|
EnsureCoherentDormantIndex();
|
||||||
CurrentIndex = 0;
|
|
||||||
QueueChanged?.Invoke();
|
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)
|
public void Move(int fromIndex, int toIndex)
|
||||||
{
|
{
|
||||||
if (fromIndex == toIndex) return;
|
if (fromIndex == toIndex) return;
|
||||||
@@ -192,6 +234,16 @@ public sealed class QueueService : IQueueService, IDisposable
|
|||||||
await PlayCurrent();
|
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()
|
public void Clear()
|
||||||
{
|
{
|
||||||
if (_items.Count == 0 && CurrentIndex == -1) return;
|
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,
|
// 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
|
// 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
|
// Guard: only act when the track that just ended is the queue's own current item. Call sites that
|
||||||
// that stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
|
// stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
|
||||||
// overwrite the player's CurrentTrack without touching the queue. If their track reaches natural
|
// 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
|
// 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.
|
// 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()
|
private void OnTrackEnded()
|
||||||
{
|
{
|
||||||
if (!HasNext) return;
|
|
||||||
if (_player?.CurrentTrack?.Id != Current?.Id) return;
|
if (_player?.CurrentTrack?.Id != Current?.Id) return;
|
||||||
// Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's
|
if (Current is null) return;
|
||||||
// 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
|
if (HasNext)
|
||||||
// not own playback error handling.
|
{
|
||||||
_ = Next();
|
// 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()
|
private async Task PlayCurrent()
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ public sealed class WaveformVisualizerControlState
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const bool DefaultWaveformEnabled = true;
|
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
|
/// <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>
|
/// time-span via <see cref="WaveformZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
|
||||||
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
|
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
|
||||||
@@ -137,12 +144,35 @@ public sealed class WaveformVisualizerControlState
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled;
|
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>
|
/// <summary>
|
||||||
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
|
/// 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>
|
/// </summary>
|
||||||
public event Action? Changed;
|
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>
|
/// <summary>Raise <see cref="Changed"/>. Called by the controls component after mutating a value.</summary>
|
||||||
public void NotifyChanged() => Changed?.Invoke();
|
public void NotifyChanged() => Changed?.Invoke();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ public static class Startup
|
|||||||
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
|
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
|
||||||
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
||||||
services.AddScoped<ShareTracker>();
|
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)
|
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@using DeepDrftPublic.Client
|
@using DeepDrftPublic.Client
|
||||||
|
@using DeepDrftPublic.Client.Common
|
||||||
@using DeepDrftPublic.Services
|
@using DeepDrftPublic.Services
|
||||||
@using DeepDrftShared.Client.Components
|
@using DeepDrftShared.Client.Components
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -34,11 +35,16 @@
|
|||||||
@code {
|
@code {
|
||||||
|
|
||||||
[Inject] public required DarkModeService DarkModeService { get; set; }
|
[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()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
base.OnInitialized();
|
base.OnInitialized();
|
||||||
DarkModeService.CheckDarkMode();
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,81 @@
|
|||||||
* read it. One observer at a time, re-pointed on each `observe` call; the var
|
* 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
|
* resets to 0 on `unobserve` (player minimized / disposed) so the spacer
|
||||||
* collapses.
|
* 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';
|
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.
|
// 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 {
|
export function observe(element: Element): void {
|
||||||
@@ -27,20 +94,28 @@ export function observe(element: Element): void {
|
|||||||
observer = new ResizeObserver(entries => {
|
observer = new ResizeObserver(entries => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
// Prefer the border-box measurement; fall back to contentRect on the
|
publishHeight(measure(entry));
|
||||||
// (older) engines that don't populate borderBoxSize.
|
|
||||||
const box = entry.borderBoxSize?.[0];
|
|
||||||
writeHeight(box ? box.blockSize : entry.contentRect.height);
|
|
||||||
});
|
});
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
|
|
||||||
// Seed synchronously so the spacer is correct on this frame, before the
|
// Seed synchronously so the spacer is correct on this frame, before the
|
||||||
// first ResizeObserver callback fires.
|
// first ResizeObserver callback fires. A fresh observe target is a discrete
|
||||||
writeHeight(element.getBoundingClientRect().height);
|
// 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 {
|
export function unobserve(): void {
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
observer = null;
|
observer = null;
|
||||||
writeHeight(0);
|
if (settleTimer !== null) {
|
||||||
|
clearTimeout(settleTimer);
|
||||||
|
settleTimer = null;
|
||||||
|
}
|
||||||
|
pendingHeight = -1;
|
||||||
|
setVar(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 525 KiB |
@@ -18,6 +18,15 @@ html, body {
|
|||||||
color: var(--mud-palette-text-primary);
|
color: var(--mud-palette-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Main-content clearance for the fixed frosted-glass nav (.dd-nav). The nav is
|
||||||
|
position:fixed (so content scrolls under its backdrop blur) and thus out of flow;
|
||||||
|
in MainLayout's flex column the content would otherwise start at the top and slide
|
||||||
|
under the bar. Pad the top by the shared --deepdrft-nav-height token so the clearance
|
||||||
|
tracks the bar exactly across breakpoints. Replaces the old hardcoded MudBlazor pt-16. */
|
||||||
|
.dd-main-content {
|
||||||
|
padding-top: var(--deepdrft-nav-height, 88px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Ensure the theme wrapper fills the full viewport so no background gap shows. */
|
/* Ensure the theme wrapper fills the full viewport so no background gap shows. */
|
||||||
.deepdrft-theme-dark,
|
.deepdrft-theme-dark,
|
||||||
.deepdrft-theme-light {
|
.deepdrft-theme-light {
|
||||||
@@ -342,6 +351,75 @@ h2, h3, h4, h5, h6,
|
|||||||
gap: 0.5rem;
|
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 {
|
.deepdrft-track-detail-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -358,6 +436,22 @@ h2, h3, h4, h5, h6,
|
|||||||
font-family: var(--deepdrft-font-mono) !important;
|
font-family: var(--deepdrft-font-mono) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Default MudBlazor popover surface (Phase 18, T4 — symptom #1). Selects, menus, and the
|
||||||
|
share-popover body render inside .mud-popover. (Tooltips are NOT covered here — MudBlazor
|
||||||
|
tooltips paint from --mud-palette-text, not the popover surface.) Their visible surface is the
|
||||||
|
inner .mud-paper, which paints background-color: var(--mud-palette-surface). Inspection settled
|
||||||
|
the root cause: the "too dark" is NOT --deepdrft-panel-ground leakage (the bespoke dark-glass
|
||||||
|
panels are MudOverlay .mud-overlay-content surfaces and never match .mud-popover) — it is simply
|
||||||
|
that the popover surface tracks --mud-palette-surface with no desaturated-navy treatment. So
|
||||||
|
re-point --mud-palette-surface to the theme-aware --deepdrft-popover-surface *within the popover
|
||||||
|
scope only*: a soft desaturated-navy wash in light, the existing panel-ground charcoal in dark.
|
||||||
|
Scoping the variable (not a flat background) means any inner .mud-paper, .mud-list, or menu picks
|
||||||
|
it up for free, while the global surface used elsewhere on the page is unaffected. */
|
||||||
|
.mud-popover {
|
||||||
|
--mud-palette-surface: var(--deepdrft-popover-surface);
|
||||||
|
background-color: var(--deepdrft-popover-surface);
|
||||||
|
}
|
||||||
|
|
||||||
.deepdrft-share-popover-body {
|
.deepdrft-share-popover-body {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
@@ -399,11 +493,11 @@ h2, h3, h4, h5, h6,
|
|||||||
section labels are LIGHT (static). The slider track/thumb and the lamp toggles are green.
|
section labels are LIGHT (static). The slider track/thumb and the lamp toggles are green.
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
.waveform-visualizer-control-panel.mix-visualizer-controls-bar {
|
.waveform-visualizer-control-panel.mix-visualizer-controls-bar {
|
||||||
/* Greyed panel ground — desaturated charcoal so the blue slider reads against it (defect #1).
|
/* Theme-aware glass ground — dark charcoal in dark theme, light translucent glass in light
|
||||||
Token is tunable in deepdrft-tokens.css without touching this rule. */
|
(so the deck reads against the light page). Tunable in deepdrft-tokens.css. */
|
||||||
background: var(--deepdrft-panel-ground);
|
background: var(--deepdrft-panel-surface);
|
||||||
/* Square corners + thin light border — NowPlayingCard chrome (§5). */
|
/* Square corners + thin theme-aware border — NowPlayingCard chrome (§5). */
|
||||||
border: 1px solid var(--deepdrft-border-light);
|
border: 1px solid var(--deepdrft-panel-border);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
/* Optional backdrop blur — cheap on a small modal panel, nice over the visualizer (§5). */
|
/* Optional backdrop blur — cheap on a small modal panel, nice over the visualizer (§5). */
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
@@ -415,12 +509,12 @@ h2, h3, h4, h5, h6,
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-width: 420px;
|
max-width: 480px;
|
||||||
/* Pin the MudBlazor palette vars the portaled RadialKnob + slider consume. */
|
/* 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-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 */
|
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */
|
||||||
--mud-palette-surface-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler */
|
--mud-palette-surface-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler */
|
||||||
--mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light */
|
--mud-palette-text-primary: var(--deepdrft-panel-text); /* knob value label — flips dark on light glass */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Row layout (§3). Each row is a horizontal band. Row 1 (MODE) and row 3 (WAVE) use
|
/* ── Row layout (§3). Each row is a horizontal band. Row 1 (MODE) and row 3 (WAVE) use
|
||||||
@@ -461,13 +555,13 @@ h2, h3, h4, h5, h6,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section label "LAVA:" / "WAVE:" (§3, §5). NowPlayingCard .np-label TYPOGRAPHY (mono, uppercase,
|
/* ── Section label "LAVA:" / "WAVE:" (§3, §5). NowPlayingCard .np-label TYPOGRAPHY (mono, uppercase,
|
||||||
tracked), recoloured LIGHT — labels are static, so light by the colour principle (§5, §10.3). ── */
|
tracked), coloured via --deepdrft-panel-text — theme-aware (navy in light, off-white in dark). ── */
|
||||||
.waveform-visualizer-control-panel .wvc-section-label {
|
.waveform-visualizer-control-panel .wvc-section-label {
|
||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
letter-spacing: 0.25em;
|
letter-spacing: 0.25em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--deepdrft-white);
|
color: var(--deepdrft-panel-text);
|
||||||
align-self: center;
|
align-self: center;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
@@ -495,10 +589,11 @@ h2, h3, h4, h5, h6,
|
|||||||
opacity: 0.38;
|
opacity: 0.38;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Caption icons render LIGHT (§5/§9: static/decorative = light). !important beats the scoped
|
/* Caption icons inherit the portaled panel's body text — theme-aware (dark text on light glass,
|
||||||
.mix-visualizer-control ::deep .mix-visualizer-control-icon rule (which sets green for the legacy
|
off-white on dark glass). !important beats the scoped .mix-visualizer-control ::deep
|
||||||
inline mount) when the icon also carries mix-visualizer-control-icon. Lamp toggles are MudIconButton
|
.mix-visualizer-control-icon rule (which sets green for the legacy inline mount) when the icon also
|
||||||
not MudIcon so they are unaffected — they stay green (interactive, Color.Primary). (defect #3) */
|
carries mix-visualizer-control-icon. Lamp toggles are MudIconButton not MudIcon so they are
|
||||||
|
unaffected — they stay green (interactive, Color.Primary). (defect #3) */
|
||||||
.waveform-visualizer-control-panel .waveform-visualizer-control-icon {
|
.waveform-visualizer-control-panel .waveform-visualizer-control-icon {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
translate: 0 -1rem;
|
translate: 0 -1rem;
|
||||||
@@ -597,6 +692,28 @@ body:has(.waveform-visualizer-control-overlay) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark-mode button overrides (Phase 18, Wave 3).
|
||||||
|
In dark, --deepdrft-navy fill/text blends into the #0D1B2A page ground.
|
||||||
|
Primary: green-accent fill + navy text reads as a clear CTA (matches play-chip language).
|
||||||
|
Ghost: white text + light border stands off the dark ground. */
|
||||||
|
.deepdrft-theme-dark .btn-primary {
|
||||||
|
background: var(--deepdrft-green-accent);
|
||||||
|
color: var(--deepdrft-navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-theme-dark .btn-primary:hover {
|
||||||
|
background: var(--deepdrft-green-interactive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-theme-dark .btn-ghost {
|
||||||
|
color: var(--deepdrft-page-text);
|
||||||
|
border-color: var(--deepdrft-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-theme-dark .btn-ghost:hover {
|
||||||
|
border-color: var(--deepdrft-page-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
CUT ALBUM DETAIL (/cuts/{id})
|
CUT ALBUM DETAIL (/cuts/{id})
|
||||||
Header splits left-meta / right-cover; the cover carries an explicit theme
|
Header splits left-meta / right-cover; the cover carries an explicit theme
|
||||||
@@ -703,6 +820,69 @@ body:has(.waveform-visualizer-control-overlay) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
INTERACTIVE-ACCENT ICON TREATMENT (.dd-accent-icon / .dd-accent-fill)
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
The single, reusable green-accent treatment for interactive icon affordances —
|
||||||
|
replaces the per-site dark-mode overrides that previously had to fight the palette.
|
||||||
|
|
||||||
|
WHY a class and not a palette colour: no MudBlazor Color enum is green in BOTH
|
||||||
|
themes (Dark.Secondary is off-white, Dark.Primary is green; Light.Secondary is
|
||||||
|
green, Light.Primary is navy), so every "green in both" affordance had to be
|
||||||
|
patched per-site. --deepdrft-green-accent (#3D7A68) is the brand constant — the
|
||||||
|
SAME value in both palettes — so a non-theme-scoped rule is correct: light already
|
||||||
|
renders these glyphs green-accent (via Color.Secondary → Light.Secondary), so this
|
||||||
|
keeps light pixel-identical while fixing dark.
|
||||||
|
|
||||||
|
WHY it reaches the glyph: MudBlazor colours a Color.Secondary icon by stamping
|
||||||
|
.mud-secondary-text on the inner .mud-icon-root <svg>, and that rule is `!important`
|
||||||
|
(color: var(--mud-palette-secondary) !important). Targeting only the .mud-icon-button
|
||||||
|
wrapper therefore never wins — the svg keeps its own !important colour. The documented
|
||||||
|
override bug. The glyph clause .dd-accent-icon .mud-icon-button .mud-icon-root is
|
||||||
|
specificity (0,3,0) + !important, which beats MudBlazor's standalone .mud-secondary-text
|
||||||
|
(0,1,0) + !important on specificity alone — source order is not load-bearing for the
|
||||||
|
glyph clause. The .mud-icon-button selector carries the
|
||||||
|
Color.Inherit affordances (lava-lamp glyph inherits the wrapper colour, no
|
||||||
|
.mud-secondary-text to fight); the spinner covers the PlayStateIcon loading state.
|
||||||
|
|
||||||
|
Apply .dd-accent-icon to a CONTAINER of the affordance(s); add .dd-accent-fill
|
||||||
|
alongside it when the container ALSO holds a filled MudButton whose Color.Secondary
|
||||||
|
fill must go green-accent in dark (a filled button is a background fill, not a glyph —
|
||||||
|
light already renders green-accent fill + white text, so .dd-accent-fill is DARK-ONLY
|
||||||
|
to keep light pixel-identical). The Session/Mix hero Share/Play glyphs use this class
|
||||||
|
too (they were already green-accent in light via Color.Secondary, so folding them in
|
||||||
|
keeps light pixel-identical and fixes dark — the over-image glyphs are not actually
|
||||||
|
theme-divergent). The one genuinely theme-divergent affordance (gas-lamp = inherited
|
||||||
|
nav text in light) does NOT use this class — it keeps a dark-only rule below.
|
||||||
|
|
||||||
|
The glyph rule targets glyphs inside an ICON button (.mud-icon-button .mud-icon-root)
|
||||||
|
only — the filled Play button is a .mud-button-filled (not .mud-icon-button), so its
|
||||||
|
StartIcon is naturally excluded and keeps its own contrast colour (white in light,
|
||||||
|
navy in dark). The bare .mud-icon-button selector carries the Color.Inherit case
|
||||||
|
(lava-lamp glyph inherits the wrapper colour); the spinner covers the loading state. */
|
||||||
|
.dd-accent-icon .mud-icon-button .mud-icon-root,
|
||||||
|
.dd-accent-icon .mud-icon-button,
|
||||||
|
.dd-accent-icon .mud-progress-circular {
|
||||||
|
color: var(--deepdrft-green-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filled-button variant (DARK-ONLY): green-accent fill + navy glyph/label, matching the
|
||||||
|
play-chip language. In dark, Color.Secondary fill resolves to off-white (unreadable);
|
||||||
|
here it becomes a clear green CTA. Light is untouched (already green fill + white text). */
|
||||||
|
.deepdrft-theme-dark .dd-accent-fill .mud-button-filled {
|
||||||
|
background-color: var(--deepdrft-green-accent);
|
||||||
|
color: var(--deepdrft-navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deepdrft-theme-dark .dd-accent-fill .mud-button-filled .mud-icon-root {
|
||||||
|
color: var(--deepdrft-navy) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gas-lamp dark-mode toggle: the frame now carries an explicit #2A5C4F fill in its SVG
|
||||||
|
(DDIcons.GasLampLit), so no CSS colour override is needed here in dark. The nav rule
|
||||||
|
that previously set green-accent on the MudIconButton has been removed — it was the
|
||||||
|
only .mud-icon-button in .dd-nav-actions and is now dead. */
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
RELEASE DESCRIPTION BLURB
|
RELEASE DESCRIPTION BLURB
|
||||||
Shared block rendered just below the hero/header on every release detail page
|
Shared block rendered just below the hero/header on every release detail page
|
||||||
@@ -791,8 +971,8 @@ body:has(.deepdrft-queue-overlay) {
|
|||||||
width: min(90vw, 520px);
|
width: min(90vw, 520px);
|
||||||
height: min(90vw, 520px);
|
height: min(90vw, 520px);
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
background: var(--deepdrft-panel-ground);
|
background: var(--deepdrft-panel-surface);
|
||||||
border: 1px solid var(--deepdrft-border-light);
|
border: 1px solid var(--deepdrft-panel-border);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -803,16 +983,16 @@ body:has(.deepdrft-queue-overlay) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
border-bottom: 1px solid var(--deepdrft-border-light);
|
border-bottom: 1px solid var(--deepdrft-panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mono uppercase eyebrow — the NowPlayingCard .np-label typography, recoloured light (static). */
|
/* Mono uppercase eyebrow — the NowPlayingCard .np-label typography, theme-aware (static). */
|
||||||
.deepdrft-queue-modal-title {
|
.deepdrft-queue-modal-title {
|
||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--deepdrft-white);
|
color: var(--deepdrft-panel-text);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,12 +1020,12 @@ body:has(.deepdrft-queue-overlay) {
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.45rem 0.5rem;
|
padding: 0.45rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--deepdrft-white);
|
color: var(--deepdrft-panel-text);
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deepdrft-queue-row:hover {
|
.deepdrft-queue-row:hover {
|
||||||
background: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
|
background: var(--deepdrft-panel-row-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Current track: a subtle green wash + left accent, matching the green = active principle. */
|
/* Current track: a subtle green wash + left accent, matching the green = active principle. */
|
||||||
@@ -863,7 +1043,7 @@ body:has(.deepdrft-queue-overlay) {
|
|||||||
.deepdrft-queue-position {
|
.deepdrft-queue-position {
|
||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
opacity: 0.6;
|
color: var(--deepdrft-panel-text-muted);
|
||||||
min-width: 1.4rem;
|
min-width: 1.4rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -888,7 +1068,7 @@ body:has(.deepdrft-queue-overlay) {
|
|||||||
|
|
||||||
.deepdrft-queue-artist {
|
.deepdrft-queue-artist {
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
opacity: 0.6;
|
color: var(--deepdrft-panel-text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -950,8 +1130,8 @@ body:has(.deepdrft-privacy-overlay) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: min(90vw, 480px);
|
width: min(90vw, 480px);
|
||||||
background: var(--deepdrft-panel-ground);
|
background: var(--deepdrft-panel-surface);
|
||||||
border: 1px solid var(--deepdrft-border-light);
|
border: 1px solid var(--deepdrft-panel-border);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -962,7 +1142,7 @@ body:has(.deepdrft-privacy-overlay) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.85rem 0.85rem 0.85rem 1rem;
|
padding: 0.85rem 0.85rem 0.85rem 1rem;
|
||||||
border-bottom: 1px solid var(--deepdrft-border-light);
|
border-bottom: 1px solid var(--deepdrft-panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mono uppercase eyebrow — matches queue modal title. */
|
/* Mono uppercase eyebrow — matches queue modal title. */
|
||||||
@@ -971,27 +1151,28 @@ body:has(.deepdrft-privacy-overlay) {
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--deepdrft-white);
|
color: var(--deepdrft-panel-text);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tuck the close icon flush with the panel edge; keep it subtle. */
|
/* Tuck the close icon flush with the panel edge; keep it subtle. */
|
||||||
.deepdrft-privacy-modal-close {
|
.deepdrft-privacy-modal-close {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
color: var(--deepdrft-white) !important;
|
color: var(--deepdrft-panel-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deepdrft-privacy-modal-close:hover {
|
.deepdrft-privacy-modal-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Privacy copy: same mono treatment as the former inline paragraph, but readable on dark ground. */
|
/* Privacy copy: same mono treatment as the former inline paragraph; theme-aware text colour
|
||||||
|
so it stays legible on both the dark-glass (dark) and light-glass (light) panel surfaces. */
|
||||||
.deepdrft-privacy-modal-body {
|
.deepdrft-privacy-modal-body {
|
||||||
font-family: var(--deepdrft-font-mono);
|
font-family: var(--deepdrft-font-mono);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: var(--deepdrft-white);
|
color: var(--deepdrft-panel-text);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1rem 1rem 1.25rem;
|
padding: 1rem 1rem 1.25rem;
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ public static class DDIcons
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Charleston gas lamp with lit flame - for dark mode
|
/// Charleston gas lamp with lit flame - for dark mode.
|
||||||
|
/// Frame/body path uses an explicit darker-green fill (#2A5C4F — palette PrimaryDarken /
|
||||||
|
/// --deepdrft-green-light) instead of currentColor so it is deterministic in the nav
|
||||||
|
/// regardless of inherited colour. The flame ellipses keep their literal orange/yellow/cream
|
||||||
|
/// fills. Only rendered in dark mode (DarkLightModeButtonIcon in DeepDrftMenu.razor).
|
||||||
|
/// If the palette's PrimaryDarken changes, update #2A5C4F to match.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string GasLampLit = """
|
public const string GasLampLit = """
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M11 0h2v2h-2zM5 6l7-4 7 4v2H5zM6 8h12l-1.5 10h-9zM7.7 9l1.2 8h6.2l1.2-8zM9 19h6v1H9zM10 21h4v2h-4z"/>
|
<path fill="#2A5C4F" d="M11 0h2v2h-2zM5 6l7-4 7 4v2H5zM6 8h12l-1.5 10h-9zM7.7 9l1.2 8h6.2l1.2-8zM9 19h6v1H9zM10 21h4v2h-4z"/>
|
||||||
<ellipse cx="12" cy="13" rx="2.5" ry="3.5" fill="#FF9800"/>
|
<ellipse cx="12" cy="13" rx="2.5" ry="3.5" fill="#FF9800"/>
|
||||||
<ellipse cx="12" cy="12.5" rx="1.5" ry="2.5" fill="#FFCA28"/>
|
<ellipse cx="12" cy="12.5" rx="1.5" ry="2.5" fill="#FFCA28"/>
|
||||||
<ellipse cx="12" cy="12" rx=".7" ry="1.5" fill="#FFF8E1"/>
|
<ellipse cx="12" cy="12" rx=".7" ry="1.5" fill="#FFF8E1"/>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ public static class DeepDrftPalettes
|
|||||||
Tertiary = "#1A3C34", // Deep green - tertiary accent
|
Tertiary = "#1A3C34", // Deep green - tertiary accent
|
||||||
Background = "#0D1B2A", // Navy - the light palette's primary as the dark ground
|
Background = "#0D1B2A", // Navy - the light palette's primary as the dark ground
|
||||||
Surface = "#162437", // Navy-mid - elevated cards/panels
|
Surface = "#162437", // Navy-mid - elevated cards/panels
|
||||||
AppbarBackground = "rgba(13,27,42,0.92)", // Semi-opaque navy
|
AppbarBackground = "rgba(17,35,56,0.92)", // Semi-opaque #112338 navy — distinct appbar bar, lighter than the #0D1B2A page ground
|
||||||
AppbarText = "#FAFAF8",
|
AppbarText = "#FAFAF8",
|
||||||
DrawerBackground = "#162437", // Navy-mid
|
DrawerBackground = "#162437", // Navy-mid
|
||||||
DrawerText = "#FAFAF8",
|
DrawerText = "#FAFAF8",
|
||||||
|
|||||||
@@ -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: boolean): void {
|
||||||
|
document.body.classList.toggle('deepdrft-theme-dark', isDark);
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -28,9 +28,24 @@
|
|||||||
(Phase 15 §4/§10.5). Mild so the panel reads as modal without a blackout. Change here once. */
|
(Phase 15 §4/§10.5). Mild so the panel reads as modal without a blackout. Change here once. */
|
||||||
--deepdrft-modal-scrim-alpha: 0.15;
|
--deepdrft-modal-scrim-alpha: 0.15;
|
||||||
/* Panel ground — muted, desaturated charcoal beneath the controls panel.
|
/* Panel ground — muted, desaturated charcoal beneath the controls panel.
|
||||||
Tunable: increase blue channel (e.g. #1e2235) to recover warmth, lower (e.g. #191b20) to go darker. */
|
Tunable: increase blue channel (e.g. #1e2235) to recover warmth, lower (e.g. #191b20) to go darker.
|
||||||
|
Source token; consumed by the theme-aware --deepdrft-panel-surface dark value below. */
|
||||||
--deepdrft-panel-ground: #1a1c22;
|
--deepdrft-panel-ground: #1a1c22;
|
||||||
|
|
||||||
|
/* Glass-panel family — the bespoke overlay panels (queue / visualizer control deck / privacy).
|
||||||
|
Light values here make these panels a light translucent glass with dark text so they read
|
||||||
|
coherently against the light page; the .deepdrft-theme-dark block below reproduces today's
|
||||||
|
dark-glass charcoal exactly so dark mode is visually unchanged. Surface keeps the glassmorphic
|
||||||
|
translucency (paired with backdrop-blur in the consuming rules).
|
||||||
|
Light surface: near-page-surface white at 82% so the backdrop blur still shows through;
|
||||||
|
text/border are navy-based for legibility on the light glass. */
|
||||||
|
--deepdrft-panel-surface: rgba(250, 250, 248, 0.82);
|
||||||
|
--deepdrft-panel-text: var(--deepdrft-navy);
|
||||||
|
--deepdrft-panel-text-muted: var(--deepdrft-muted);
|
||||||
|
--deepdrft-panel-border: var(--deepdrft-border);
|
||||||
|
/* Row/hover wash on the panel surface — a navy tint on light, a white tint on dark (below). */
|
||||||
|
--deepdrft-panel-row-hover: color-mix(in srgb, var(--deepdrft-navy) 8%, transparent);
|
||||||
|
|
||||||
/* Wireframe font stack */
|
/* Wireframe font stack */
|
||||||
--deepdrft-font-display: "Cormorant Garamond", Georgia, serif;
|
--deepdrft-font-display: "Cormorant Garamond", Georgia, serif;
|
||||||
--deepdrft-font-mono: "Geist Mono", monospace;
|
--deepdrft-font-mono: "Geist Mono", monospace;
|
||||||
@@ -68,11 +83,54 @@
|
|||||||
--gradient-warm: var(--deepdrft-green);
|
--gradient-warm: var(--deepdrft-green);
|
||||||
--gradient-light: var(--deepdrft-green-accent);
|
--gradient-light: var(--deepdrft-green-accent);
|
||||||
|
|
||||||
|
/* Theme-aware page-surface family (Phase 18). The "neutral page surface" concept:
|
||||||
|
sections that were hardcoded to --deepdrft-white because the site was light-only.
|
||||||
|
Light values reproduce today's look exactly; the .deepdrft-theme-dark block below
|
||||||
|
inverts them onto the navy ground so neutral sections dissolve into one dark field. */
|
||||||
|
--deepdrft-page-surface: var(--deepdrft-white);
|
||||||
|
--deepdrft-page-text: var(--deepdrft-navy);
|
||||||
|
--deepdrft-page-text-muted: var(--deepdrft-muted);
|
||||||
|
|
||||||
|
/* Play-chip family (Phase 18). PlayStateIcon's chip is shared across release heroes,
|
||||||
|
Cut track rows, and the player bar. Light keeps the current soft-grey chip + glyph;
|
||||||
|
dark turns the chip moss-green with a navy glyph. The -soft variant is the player-bar
|
||||||
|
override (same green, much less opaque). */
|
||||||
|
--deepdrft-play-chip: var(--deepdrft-soft);
|
||||||
|
--deepdrft-play-glyph: var(--deepdrft-navy);
|
||||||
|
--deepdrft-play-chip-soft: var(--deepdrft-soft);
|
||||||
|
|
||||||
|
/* Popover surface (Phase 18). Default MudBlazor popovers (selects/menus/tooltips/share
|
||||||
|
body) bind this. Light uses a very subtle navy wash (4%) — near the page background but
|
||||||
|
just perceptibly off-white so the popover reads as an elevated surface. Dark uses a
|
||||||
|
bluer navy (colour-mix of navy-mid + green-accent at 20%), defined once in
|
||||||
|
--deepdrft-popover-surface-dark below and referenced by both the .deepdrft-theme-dark
|
||||||
|
wrapper block and the body.deepdrft-theme-dark block so portaled popover content (which
|
||||||
|
portals to <body>, outside the wrapper div) is also reached. The bespoke glass panels
|
||||||
|
(visualizer/queue/privacy) do NOT bind this — they have their own theme-aware
|
||||||
|
--deepdrft-panel-* family (dark glass in dark theme, light glass in light). */
|
||||||
|
--deepdrft-popover-surface-dark: color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%);
|
||||||
|
--deepdrft-popover-surface: color-mix(in srgb, var(--deepdrft-navy) 4%, var(--deepdrft-white));
|
||||||
|
|
||||||
|
/* Fixed-nav height — single source of truth shared by the frosted-glass nav
|
||||||
|
(DeepDrftMenu.razor.css pins .dd-nav to this) and the main-content clearance
|
||||||
|
(.dd-main-content padding-top in deepdrft-styles.css). The nav is position:fixed
|
||||||
|
so content scrolls under its backdrop blur; this keeps the clearance in lockstep
|
||||||
|
with the bar so content never overlaps. Mobile (<600px) override below. */
|
||||||
|
--deepdrft-nav-height: 88px;
|
||||||
|
|
||||||
/* Legacy font aliases retired in Phase 0.1 — all consumers now use --deepdrft-font-*.
|
/* Legacy font aliases retired in Phase 0.1 — all consumers now use --deepdrft-font-*.
|
||||||
Palette aliases (--deepdrft-primary, --theme-*, etc.) remain; they still have
|
Palette aliases (--deepdrft-primary, --theme-*, etc.) remain; they still have
|
||||||
consumers and are scheduled for retirement in Phase 0.3/0.4. */
|
consumers and are scheduled for retirement in Phase 0.3/0.4. */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile fixed-nav height — matches the <600px breakpoint in DeepDrftMenu.razor.css
|
||||||
|
(tighter horizontal padding + smaller bar). Cascades to .dd-nav and .dd-main-content. */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
:root {
|
||||||
|
--deepdrft-nav-height: 72px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark theme - wireframe palette (navy ground / green-accent / off-white).
|
/* Dark theme - wireframe palette (navy ground / green-accent / off-white).
|
||||||
Mirrors the light palette's vocabulary on a dark ground. Same alias structure
|
Mirrors the light palette's vocabulary on a dark ground. Same alias structure
|
||||||
as :root so utility classes (.deepdrft-chip-*, .deepdrft-border-*, .deepdrft-text-*)
|
as :root so utility classes (.deepdrft-chip-*, .deepdrft-border-*, .deepdrft-text-*)
|
||||||
@@ -108,4 +166,55 @@
|
|||||||
--gradient-accent: var(--deepdrft-green-accent);
|
--gradient-accent: var(--deepdrft-green-accent);
|
||||||
--gradient-warm: var(--deepdrft-green);
|
--gradient-warm: var(--deepdrft-green);
|
||||||
--gradient-light: var(--deepdrft-green-light);
|
--gradient-light: var(--deepdrft-green-light);
|
||||||
|
|
||||||
|
/* Theme-aware page-surface family (Phase 18) — inverted onto the true page ground.
|
||||||
|
Binds --mud-palette-background (#0D1B2A) so neutral sections (Home hero-left,
|
||||||
|
medium grid, footer, About light sections) dissolve into the site background as
|
||||||
|
one continuous dark field rather than reading as raised panels (#112338 navy
|
||||||
|
is card-elevation, not the page ground). */
|
||||||
|
--deepdrft-page-surface: var(--mud-palette-background);
|
||||||
|
--deepdrft-page-text: var(--deepdrft-white);
|
||||||
|
/* Lift muted text toward white so eyebrows/sub-text stay legible on the dark ground. */
|
||||||
|
--deepdrft-page-text-muted: color-mix(in srgb, var(--deepdrft-muted) 70%, var(--deepdrft-white));
|
||||||
|
|
||||||
|
/* Play-chip family (Phase 18) — moss-green chip, navy glyph (green-on-green on the
|
||||||
|
player bar; navy-on-green on solid chips). The -soft variant is the player-bar
|
||||||
|
override: same green, much less opaque (translucent wash over the navy dock). */
|
||||||
|
--deepdrft-play-chip: var(--deepdrft-green-accent);
|
||||||
|
--deepdrft-play-glyph: var(--deepdrft-navy);
|
||||||
|
--deepdrft-play-chip-soft: color-mix(in srgb, var(--deepdrft-green-accent) 30%, transparent);
|
||||||
|
|
||||||
|
/* Popover surface (Phase 18) — within .deepdrft-theme-dark wrapper this value applies to
|
||||||
|
non-portaled elements only (drawers, inline menus). Portaled MudBlazor popovers live at
|
||||||
|
<body> level; the body.deepdrft-theme-dark block below uses the same source token. */
|
||||||
|
--deepdrft-popover-surface: var(--deepdrft-popover-surface-dark);
|
||||||
|
|
||||||
|
/* Glass-panel family (dark) — reproduces today's dark-glass chrome EXACTLY. Surface is the
|
||||||
|
opaque charcoal ground the panels used directly before tokenisation; text is off-white;
|
||||||
|
border is the thin light-on-dark hairline (NowPlayingCard spirit); row hover is the prior
|
||||||
|
white 6% wash. Dark mode must look unchanged. */
|
||||||
|
--deepdrft-panel-surface: var(--deepdrft-panel-ground);
|
||||||
|
--deepdrft-panel-text: var(--deepdrft-white);
|
||||||
|
--deepdrft-panel-text-muted: color-mix(in srgb, var(--deepdrft-white) 60%, transparent);
|
||||||
|
--deepdrft-panel-border: var(--deepdrft-border-light);
|
||||||
|
--deepdrft-panel-row-hover: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal-scope dark popover surface. MudBlazor popovers (selects, menus, share body) portal
|
||||||
|
to <body>, placing them outside the .deepdrft-theme-dark wrapper div. MainLayout.razor syncs
|
||||||
|
deepdrft-theme-dark onto <body> via JS after each render, so this selector reaches portaled
|
||||||
|
content. Resolved from --deepdrft-popover-surface-dark (defined in :root above) — bluer navy
|
||||||
|
(navy-mid + 20% green-accent tint) rather than the pure charcoal #162437. */
|
||||||
|
body.deepdrft-theme-dark {
|
||||||
|
--deepdrft-popover-surface: var(--deepdrft-popover-surface-dark);
|
||||||
|
|
||||||
|
/* The bespoke glass panels (queue / visualizer / privacy) are MudOverlay panels that portal to
|
||||||
|
<body>, outside the .deepdrft-theme-dark wrapper div — same portal scope as popovers. Re-declare
|
||||||
|
the dark glass-panel family here so the panels resolve the dark (charcoal) values; without this
|
||||||
|
they would fall through to the light :root values while the page is in dark mode. */
|
||||||
|
--deepdrft-panel-surface: var(--deepdrft-panel-ground);
|
||||||
|
--deepdrft-panel-text: var(--deepdrft-white);
|
||||||
|
--deepdrft-panel-text-muted: color-mix(in srgb, var(--deepdrft-white) 60%, transparent);
|
||||||
|
--deepdrft-panel-border: var(--deepdrft-border-light);
|
||||||
|
--deepdrft-panel-row-hover: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ using Microsoft.AspNetCore.Components;
|
|||||||
namespace DeepDrftTests;
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unit tests for the play-queue orchestrator (<see cref="QueueService"/>). The queue is pure
|
/// Unit tests for the two-level deque play-queue orchestrator (<see cref="QueueService"/>). The queue
|
||||||
/// domain logic over the single-slot player, so it is exercised here against a recording fake
|
/// 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:
|
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage: PLAY-
|
||||||
/// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and
|
/// prepend (single + release), add-to-queue append, dormant-seed-from-player, ordered advance,
|
||||||
/// auto-advance on the player's <see cref="IPlayerService.TrackEnded"/> signal.
|
/// next/previous bounds, jump, clear, current-index integrity, and auto-advance / last-track-empty on
|
||||||
|
/// the player's <see cref="IPlayerService.TrackEnded"/> signal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class QueueServiceTests
|
public class QueueServiceTests
|
||||||
@@ -125,8 +126,12 @@ public class QueueServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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));
|
await _queue.PlayRelease(Tracks(3));
|
||||||
var second = new List<TrackDto>
|
var second = new List<TrackDto>
|
||||||
{
|
{
|
||||||
@@ -138,39 +143,133 @@ public class QueueServiceTests
|
|||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
Assert.That(_queue.Items, Has.Count.EqualTo(2));
|
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||||
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" }));
|
Is.EqualTo(new[] { "x-1", "x-2", "track-2", "track-3" }));
|
||||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
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]
|
[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).
|
// Current advanced to track-2 (index 1) with track-3, track-4 after it. PLAY of a new release
|
||||||
// Items returns the backing list directly; without a defensive copy, the cast
|
// drops only track-2 (the current) and keeps track-3, track-4 behind the prepend. The old
|
||||||
// "tracks as IReadOnlyList<TrackDto>" aliases _items, so _items.Clear() also clears list,
|
// back-history (track-1, before the current) is discarded — a fresh PLAY defines a new front.
|
||||||
// and _items.AddRange(list) adds nothing — wiping the queue and playing nothing.
|
await _queue.PlayRelease(Tracks(4));
|
||||||
await _queue.PlayRelease(Tracks(4)); // populate the live queue
|
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);
|
await _queue.PlayRelease(_queue.Items, 2);
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
// The queue must survive — all four tracks still present, in order.
|
// The defensive copy is intact: all four original tracks were re-prepended in order, and the
|
||||||
Assert.That(_queue.Items, Has.Count.EqualTo(4));
|
// old current's tail follows. CurrentIndex is the chosen start.
|
||||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
|
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4", "track-2", "track-3", "track-4" }));
|
||||||
// CurrentIndex must be the jumped-to slot.
|
|
||||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||||
// Current must be the right track.
|
|
||||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
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"));
|
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) ---
|
// --- Arm: prerender-safe load without streaming (release embed) ---
|
||||||
|
|
||||||
[Test]
|
[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]
|
[Test]
|
||||||
public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex()
|
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 ---
|
// --- Clear ---
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -820,16 +1055,38 @@ public class QueueServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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);
|
await _queue.PlayRelease(Tracks(2), startIndex: 1);
|
||||||
|
var raised = false;
|
||||||
|
_queue.QueueChanged += () => raised = true;
|
||||||
|
|
||||||
_player.RaiseTrackEnded();
|
_player.RaiseTrackEnded();
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
Assert.That(_queue.Items, Is.Empty);
|
||||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
|
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]
|
[Test]
|
||||||
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd()
|
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd_ThenEmptiesOnLastEnd()
|
||||||
{
|
{
|
||||||
await _queue.PlayRelease(Tracks(3));
|
await _queue.PlayRelease(Tracks(3));
|
||||||
|
|
||||||
_player.RaiseTrackEnded(); // → track-2
|
_player.RaiseTrackEnded(); // → track-2
|
||||||
_player.RaiseTrackEnded(); // → track-3
|
_player.RaiseTrackEnded(); // → track-3
|
||||||
_player.RaiseTrackEnded(); // last track: no advance
|
_player.RaiseTrackEnded(); // last track ends → queue empties (bug #2)
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
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),
|
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
|
||||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -342,44 +342,318 @@ the open-question set: `product-notes/phase-17-player-queue-view.md`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 18 — Theme / Dark-Mode Remediation (DRY token pass)
|
## Phase 19 — AuthBlocks User Management (CMS-only: admin surfaces + public self-registration)
|
||||||
|
|
||||||
A punch-list of six theming symptoms Daniel reported — five in dark mode, one in light —
|
Wire **all three** AuthBlocks account-creation paths into the `DeepDrftManager` CMS — the admin
|
||||||
that all trace to **three** root causes in how component/page CSS bypasses the theme-aware
|
user-administration surface (provision users, manage accounts, manage registration invites, manage role
|
||||||
token layer and binds *constant* source tokens instead. Resolved as one coherent token pass,
|
permissions) **and** the public-facing self-service registration form. **All three paths live on
|
||||||
not six per-component patches. Full design, architecture map, root-cause analysis, token
|
`DeepDrftManager` (the CMS app); there are NO changes to `DeepDrftPublic` in this phase.** Daniel's
|
||||||
table, and track breakdown: `product-notes/theme-dark-mode-remediation.md`.
|
framing: *"already part of the AuthBlocks library so we just wire it up."* Correct — and **further along
|
||||||
|
than it implies**: almost everything landed by side-effect of the prior startup separation. Full design,
|
||||||
|
the verified three-path model, the already-done-vs-remaining split, the SkipperHaven pattern + concrete
|
||||||
|
deltas, scope boundaries, and open questions: `product-notes/phase-19-user-management-cms.md`.
|
||||||
|
|
||||||
**Root-cause collapse (six symptoms → three causes):**
|
**The three account-creation paths (verified against AuthBlocks source 2026-06-19) — ALL CMS routes:**
|
||||||
- **Cause 1 — neutral surfaces don't invert.** Home hero-left + footer (#3) and About light
|
1. **Admin provisions directly** — `SuperRegister.razor` → `/account/superregister` → `POST
|
||||||
sections (#4) hardcode `background: var(--deepdrft-white)` / text on `--deepdrft-navy` —
|
api/auth/admin-register` (UserAdmin-gated, **working**). Creates a live account now.
|
||||||
brand *constants* that are identical in `:root` and `.deepdrft-theme-dark`, so they cannot
|
2. **Public self-service** — `Register.razor` → `/account/register` → `POST api/auth/register`
|
||||||
flip. Fix: bind a theme-aware `--deepdrft-page-surface` / `--deepdrft-page-text` alias. The
|
(**unauthenticated, no role gate, working**). A **public-facing CMS route, exactly like the CMS
|
||||||
inversion must stay **neutral to the intentionally navy/green decorative sections**
|
`/account/login` page** — invited user redeems a code (pre-filled from the invite email's deep link)
|
||||||
(`.section-dark`, `.split-left`, `.cta-banner`, hero overlays) — a classify-then-recolor job.
|
and self-registers, all on the CMS host.
|
||||||
- **Cause 2 — play chip binds a constant grey.** `PlayStateIcon.razor.css` `.icon-container`
|
3. **Admin provisions a token + triggers the invite email** — `NewRegistration(Form).razor` →
|
||||||
hardcodes `--deepdrft-soft` (#e3e7ec). One shared component drives the release-hero chip, the
|
`/useradmin/registrations/new` → `POST api/pendingregistration/create` (UserAdmin-gated). **Sends a
|
||||||
Cut track rows, *and* the player bar — so it reads "greyed-out" over dark heroes (#5) and "too
|
real email server-side** via Mailtrap (`RegistrationEmailTemplate` + `IGeneralEmailSender`, configured
|
||||||
bright" on the navy player surface (#6). Fix: theme-aware `--deepdrft-play-chip` (moss-green +
|
in DeepDrftAPI from `environment/authblocks.json`) — **not stubbed.**
|
||||||
navy glyph in dark) with a translucent `--deepdrft-play-chip-soft` override for the player bar.
|
|
||||||
- **Cause 3 — no theme-aware popover surface.** Light-mode default MudPopovers read "too dark"
|
|
||||||
(#1); there's no token for the wanted "desaturated navy." Fix: a `--deepdrft-popover-surface`
|
|
||||||
token; leave the bespoke `--deepdrft-panel-ground` panels alone.
|
|
||||||
|
|
||||||
**Sequenced as four tracks, `T1 → {T2, T3, T4}`.** T1 (additive token foundation in
|
**Host-model correction (Daniel, 2026-06-19).** A prior revision placed public registration (path 2) on
|
||||||
`deepdrft-tokens.css`) is the cold-start prerequisite; T2 (neutral-surface inversion), T3
|
`DeepDrftPublic` as a cold-start integration. **Wrong — there are NO `DeepDrftPublic` changes.** Public
|
||||||
(play-chip theming), T4 (popover token) fan out behind it and are mutually independent. Pure
|
registration is an unauthenticated route *on the CMS app*, mirroring the CMS's already-public
|
||||||
CSS-token pass — no source code, data layer, or streaming-seam changes. Prior art:
|
`/account/login`. The only genuinely stubbed surface is **Reset Password** (`Users.razor`, `// todo`; **no
|
||||||
`product-notes/track-card-theming.md` solved this exact class of theme-aware recolor once
|
backing endpoint** in `AuthRoutes`) — handled separately by Daniel in the AuthBlocks repo (see
|
||||||
already; this generalizes the fix from one component to the pattern.
|
`product-notes/authblocks-password-reset-brief.md`).
|
||||||
|
|
||||||
**Open questions for Daniel (spec §5):** (1) dark neutral surface = navy *ground* (continuous
|
**Most wiring already landed by side-effect.** The AuthBlocks startup separation
|
||||||
field — recommended for footer/hero) vs. *elevated* navy-mid (distinct panels); (2) popover
|
(`PLAN_authblocks_trackmanager.md`, 2026-05-25) + login/logout integration already put the entire surface
|
||||||
target distance from white (recommend a light `color-mix(navy ~8%, white)` wash). Exact green
|
in place on `DeepDrftManager`: `Cerebellum.AuthBlocks.Web` referenced, `ConfigureAuthServices` registers
|
||||||
opacity + muted-text mixes are tune-on-screen details, not decision gates.
|
every client + ViewModel **and** the `JwtAuthenticationStateProvider` path 2 needs, the router discovers
|
||||||
|
every page (`AdditionalAssemblies`) — **including the public `/account/register`** — and the DeepDrft
|
||||||
|
`Admin` role **inherits** `UserAdmin` (the seeded admin passes the gate with no change). The pages ship in
|
||||||
|
a published **RCL**, so the worried-about "extract pages into an RCL" fork **does not arise**.
|
||||||
|
|
||||||
|
**Two real gaps remain.** (a) **No nav** — `CmsLayout` is just an app bar + Home button, so nothing links
|
||||||
|
to `/useradmin/*` or `/account/superregister` (admin surface invisible). (b) **Wrong layout for public
|
||||||
|
pages** — `Routes.razor` uses a **static** `DefaultLayout="typeof(CmsLayout)"`, so an unauthenticated
|
||||||
|
visitor to `/account/register` (or `/account/login`) lands in the authenticated app shell instead of the
|
||||||
|
lean splash.
|
||||||
|
|
||||||
|
**SkipperHaven is the canonical pattern.** `SkipperHaven` (same AuthBlocks library) exposes login +
|
||||||
|
register as public/unauthenticated routes correctly by making `Routes.razor`'s `DefaultLayout`
|
||||||
|
**auth-state-driven** — unauthenticated → home/lean layout, authenticated → app shell (resolved in
|
||||||
|
`OnParametersSetAsync` off the cascaded `AuthenticationState`). **The concrete delta DeepDrftManager
|
||||||
|
needs is exactly one change** (spec §2c): make its `DefaultLayout` auth-state-driven, resolving
|
||||||
|
`CmsHomeLayout` (unauth) vs. `CmsLayout` (auth). Everything else SkipperHaven does — service wiring, page
|
||||||
|
discovery, both layouts — DeepDrftManager **already has** (it even already ships `CmsHomeLayout`, used by
|
||||||
|
the `/` home splash). So path 2 is **one router edit**, not a host integration.
|
||||||
|
|
||||||
|
**One host (`DeepDrftManager`), two parallel tracks** (different files), then verify + theme.
|
||||||
|
|
||||||
|
- **19.1 — CmsLayout navigation (admin-nav track; the main code wave). DECIDED nav shape: G1-b.** Add a
|
||||||
|
`MudDrawer` + toggle to `CmsLayout.razor`; mount the shipped `UserAdminMenu` fragment (self-gates to
|
||||||
|
`UserAdmin`+) alongside the existing CMS destinations (Catalogue / Releases / Upload); surface **both**
|
||||||
|
admin account paths (path 1 `SuperRegister` + path 3 via the Registrations link); do **not** surface the
|
||||||
|
redundant bare `NewUser` (OQ2 resolved). Scope: `CmsLayout.razor`. **No service, API, data, or
|
||||||
|
AuthBlocks-source change.** **Landed:** 2026-06-19 on dev.
|
||||||
|
- **19.2 — Public-route layout (public-route track; parallel to 19.1). DECIDED: G0-a.** Make
|
||||||
|
`Routes.razor`'s `DefaultLayout` auth-state-driven (mirroring SkipperHaven, spec §2c D1): cascade
|
||||||
|
`Task<AuthenticationState>`, resolve `_currentLayout = authed ? CmsLayout : CmsHomeLayout`, bind
|
||||||
|
`DefaultLayout="@_currentLayout"`. This renders `/account/register` (path 2) **and** `/account/login` in
|
||||||
|
the lean `CmsHomeLayout` for unauthenticated visitors. Scope: `Routes.razor` only. **No new layout (both
|
||||||
|
exist), no package, no service, no AuthBlocks-source change.** **Landed:** 2026-06-19 on dev.
|
||||||
|
- **19.3 — End-to-end verification (after 19.1 + 19.2).** Exercise provision-now (path 1), **invite-email
|
||||||
|
send (path 3) incl. that the invite link `{ReturnHost}` points at the CMS origin**, list/deactivate
|
||||||
|
users, permissions against a running DeepDrftAPI; confirm cross-host token + CORS, and **the full
|
||||||
|
path-3→path-2 loop on the single CMS host** (admin provisions → email arrives → invitee redeems on the
|
||||||
|
CMS `/account/register` in the lean layout). Mostly test; any break is likely a one-line config fix
|
||||||
|
(esp. Mailtrap creds + return host) or an upstream AuthBlocks issue.
|
||||||
|
- **19.4 — Theming legibility sweep (after 19.1 + 19.2, parallel-ok with 19.3).** Accept the CMS palette
|
||||||
|
for the MudBlazor-default grids and the public pages now in `CmsHomeLayout`; fix only contrast/legibility
|
||||||
|
breaks. Bespoke restyle deferred.
|
||||||
|
|
||||||
|
**Deferred (note, don't build):** admin dashboard landing (G1-c); working **Reset Password** (separate
|
||||||
|
AuthBlocks-repo effort); bespoke restyle of the AuthBlocks grids; a visible public Register nav link
|
||||||
|
(invite-only — the email deep link is the entry point); bumping `Cerebellum.AuthBlocks.Web` 10.3.33 →
|
||||||
|
10.3.35 (housekeeping).
|
||||||
|
|
||||||
|
**Explicitly not needed:** any change to `DeepDrftPublic` (corrected host model — all three paths are CMS);
|
||||||
|
extracting AuthBlocks pages into a new RCL; new DI/service wiring, role seeding, or Auth connection string
|
||||||
|
(all present); editing the AuthBlocks `Login`/`Register` pages' layout (impossible without forking the
|
||||||
|
RCL — G0-a fixes layout host-side instead).
|
||||||
|
|
||||||
|
**Open questions for Daniel (spec §6).** *Resolved:* (1) nav shape **G1-b**; (2) surface path 1 + path 3,
|
||||||
|
hide bare `NewUser`; (5) Reset Password non-functional in v1, handled separately; (6) **host model — all
|
||||||
|
three on the CMS, no `DeepDrftPublic` changes**; (7) **public-route layout G0-a** (auth-state-driven
|
||||||
|
`DefaultLayout`, reusing `CmsHomeLayout`). *Still open:* (3) admin dashboard defer (recommend defer); (4)
|
||||||
|
package bump (recommend leave); (8) a logged-in admin visiting `/account/register` sees it in the app
|
||||||
|
shell under G0-a (recommend accept). None block 19.1 or 19.2.
|
||||||
|
|
||||||
|
**Adjacency to the deferred Identity / accounts backlog item (below).** That item is about *public,
|
||||||
|
per-user* identity (favourites, listening history, playlists). This phase is *CMS* account management only
|
||||||
|
(admin surfaces + invite-based self-registration) — same AuthBlocks substrate, different surface. They are
|
||||||
|
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
|
## 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.
|
- **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.
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ if need_cred "authblocks"; then
|
|||||||
read -rp " Email host (SMTP server or API host): " EMAIL_HOST
|
read -rp " Email host (SMTP server or API host): " EMAIL_HOST
|
||||||
read -rsp " Email token (API key / SMTP password): " EMAIL_TOKEN
|
read -rsp " Email token (API key / SMTP password): " EMAIL_TOKEN
|
||||||
echo
|
echo
|
||||||
|
read -rp " Sender email address (From:, e.g. noreply@${DOMAIN_PUBLIC}): " EMAIL_FROM
|
||||||
|
|
||||||
# Admin account
|
# Admin account
|
||||||
echo
|
echo
|
||||||
@@ -201,10 +202,10 @@ if need_cred "authblocks"; then
|
|||||||
read -rp " Support email address: " SUPPORT_EMAIL
|
read -rp " Support email address: " SUPPORT_EMAIL
|
||||||
|
|
||||||
write_cred "authblocks" "$(cat <<JSON
|
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
|
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
|
unset ADMIN_USERNAME ADMIN_EMAIL ADMIN_PASSWORD SUPPORT_EMAIL
|
||||||
else
|
else
|
||||||
echo "[setup-step10-creds] authblocks.json already exists, skipping"
|
echo "[setup-step10-creds] authblocks.json already exists, skipping"
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Team Brief — Email-Backed Password Reset for AuthBlocks
|
||||||
|
|
||||||
|
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
|
||||||
|
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
|
||||||
|
consume AuthBlocks. Everything you need is in this brief or in that repo.
|
||||||
|
|
||||||
|
**Status:** scoped request, not yet started. Author: product-designer (for a downstream consumer team).
|
||||||
|
Date: 2026-06-19.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The goal in one sentence
|
||||||
|
|
||||||
|
Replace the non-functional "Reset Password" stub on the AuthBlocks user-administration **Users** page
|
||||||
|
with a real, email-backed password-reset flow — so that triggering "Reset Password" for a user sends
|
||||||
|
that user an email containing a secure, time-limited reset link, and following the link lets them set a
|
||||||
|
new password.
|
||||||
|
|
||||||
|
This is an **upstream library feature**, delivered entirely inside AuthBlocks and published as a normal
|
||||||
|
version bump. Consumers pick it up by referencing the new package version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Where the stub lives today
|
||||||
|
|
||||||
|
`AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — the user grid has a per-row **Reset
|
||||||
|
Password** `MudButton` whose handler is empty:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task ResetPassword(UserInputModel? item)
|
||||||
|
{
|
||||||
|
// todo integrate with email for secure reset
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
There is **no backing API endpoint** for this action. `AuthBlocksLib/Routes/AuthRoutes.cs` maps
|
||||||
|
`login`, `register`, `admin-register`, `refresh`, `logout`, `me`, `roles` — and nothing for password
|
||||||
|
reset. So this is a build-from-scratch flow on both the API side (new endpoints) and the Web side
|
||||||
|
(wire the button + add a public reset page), reusing AuthBlocks' existing email and token machinery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What AuthBlocks already has that you should reuse
|
||||||
|
|
||||||
|
**The pending-registration flow is your template.** AuthBlocks already does almost exactly this shape
|
||||||
|
of work for invitations — generate a secure token, email a link, validate the token when the user
|
||||||
|
returns. Read it end-to-end before designing reset; you are building a sibling flow:
|
||||||
|
|
||||||
|
- **Email sending is real and wired.** `AuthBlocksLib/AuthBlocksExtensions.cs` (~line 109) registers
|
||||||
|
`services.AddScoped<IGeneralEmailSender, MailtrapEmailSender>();`. The `IGeneralEmailSender`
|
||||||
|
abstraction and `MailtrapEmailSender` implementation come from the shared NetBlocks library
|
||||||
|
(namespace `API.Common.Email.Mailtrap`). The send signature in use is:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await emailSender.SendEmailAsync(toAddress, cc: null, subject, htmlBody);
|
||||||
|
```
|
||||||
|
|
||||||
|
See it called for real at `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs:124`.
|
||||||
|
|
||||||
|
- **Email connection config.** The host populates `AuthBlocksOptions.EmailConnection` (a NetBlocks
|
||||||
|
`EmailConnection` with `Host` + `Token`) plus `ApplicationName` and `SupportEmail` when it calls
|
||||||
|
`AddAuthBlocks(options => { ... })`. Those flow into `AuthBlocksExtensions` and are available to your
|
||||||
|
reset endpoint exactly as they are to the registration endpoint. **You do not need to invent any new
|
||||||
|
config or sender** — reuse `IGeneralEmailSender` and `AuthBlocksOptions`.
|
||||||
|
|
||||||
|
- **An HTML email template pattern.** `AuthBlocksLib/Common/RegistrationEmailTemplate.cs` is a static
|
||||||
|
`Create(token, link, applicationName, supportEmail)` returning a styled HTML string. Build a sibling
|
||||||
|
`PasswordResetEmailTemplate.Create(...)` in the same file's neighbourhood and the same house style
|
||||||
|
(the registration template is teal-branded, table-layout, support-line-collapses-when-empty — match
|
||||||
|
it). Do **not** reuse the registration template verbatim; the copy is invitation-specific.
|
||||||
|
|
||||||
|
- **A token service pattern.** `AuthBlocksLib/Services/RegistrationTokenService.cs` generates a random
|
||||||
|
token, SHA-256-hashes `{email}::{token}`, persists the hash with a 7-day expiry, and validates /
|
||||||
|
consumes it. **However — for password reset, prefer ASP.NET Identity's built-in reset token** (see
|
||||||
|
§4) rather than re-implementing this hand-rolled scheme. The registration token service is a *style*
|
||||||
|
reference for endpoint shape and email-link construction, not necessarily the token mechanism.
|
||||||
|
|
||||||
|
- **The deep-link construction idiom.** The registration flow builds its link with
|
||||||
|
`QueryHelpers.AddQueryString(returnHost, { UserEmail, RegistrationToken })` and the public register
|
||||||
|
page reads those query params and pre-fills (`Register.razor`, `[SupplyParameterFromQuery]`). Mirror
|
||||||
|
this for the reset page: link carries `email` + `resetToken`; the reset page reads them.
|
||||||
|
|
||||||
|
- **Identity is fully present.** `UserService` wraps `UserManager<ApplicationUser>` (see
|
||||||
|
`AuthBlocksData/Services/UserService.cs`). `UserManager` gives you the canonical reset primitives —
|
||||||
|
use them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recommended mechanism: ASP.NET Identity's built-in reset token
|
||||||
|
|
||||||
|
Password reset is a solved problem in ASP.NET Identity, and rolling your own token store for it is an
|
||||||
|
avoidable security surface. **Strong recommendation:** use `UserManager<ApplicationUser>`'s built-in
|
||||||
|
reset tokens rather than the hand-rolled `RegistrationTokenService` SHA-256 scheme.
|
||||||
|
|
||||||
|
- `var token = await userManager.GeneratePasswordResetTokenAsync(user);` — produces a token bound to
|
||||||
|
the user's security stamp; invalidated when the password changes or the stamp rotates.
|
||||||
|
- `var result = await userManager.ResetPasswordAsync(user, token, newPassword);` — validates and
|
||||||
|
applies in one call; enforces the configured password policy.
|
||||||
|
- Token lifetime is governed by `DataProtectionTokenProviderOptions.TokenLifespan` (default 1 day) —
|
||||||
|
confirm/configure to a sensible reset window (recommend 1–2 hours for reset, tighter than the 7-day
|
||||||
|
registration window).
|
||||||
|
|
||||||
|
This means you likely **do not** need a new DB table or migration for reset (unlike registration,
|
||||||
|
which persists pending rows). Confirm whether the default token providers are registered in the
|
||||||
|
AuthBlocks Identity setup; if `AddDefaultTokenProviders()` (or equivalent) is not already called in the
|
||||||
|
Identity configuration, add it — that is the one wiring prerequisite for `GeneratePasswordResetTokenAsync`
|
||||||
|
to work.
|
||||||
|
|
||||||
|
*Alternative considered (and not recommended):* extend `RegistrationTokenService` / `PendingRegistration`
|
||||||
|
into a generic token table that also serves reset. Rejected — it couples two unrelated flows, re-implements
|
||||||
|
what Identity already does correctly, and adds a migration for no benefit. Use it only if there is a
|
||||||
|
hard reason the Identity token provider cannot be enabled in this setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The surfaces to build
|
||||||
|
|
||||||
|
Three pieces, mirroring the registration flow's API-endpoint + email-template + web-page triad.
|
||||||
|
|
||||||
|
### 5.1 API endpoints (`AuthBlocksLib/Routes/AuthRoutes.cs`)
|
||||||
|
|
||||||
|
Add to the `api/auth` group. Two endpoints, both **unauthenticated** (a user resetting a forgotten
|
||||||
|
password is by definition not logged in — the admin "Reset Password" button triggers the *first* of
|
||||||
|
these on the user's behalf, but the endpoint itself authenticates via the token, not a bearer):
|
||||||
|
|
||||||
|
1. **`POST api/auth/forgot-password`** — body `{ email, returnHost }`. Looks up the user; if found,
|
||||||
|
generates a reset token and emails the reset link (`{returnHost}?email=&resetToken=`). **Always
|
||||||
|
return success** regardless of whether the email exists — do **not** leak account existence (a known
|
||||||
|
reset-flow security requirement; the registration flow's "user already exists" message is acceptable
|
||||||
|
for an *admin-gated* invite but a *public* forgot-password must not reveal it). On email-send failure,
|
||||||
|
log and return a generic failure.
|
||||||
|
|
||||||
|
2. **`POST api/auth/reset-password`** — body `{ email, resetToken, newPassword }`. Resolves the user,
|
||||||
|
calls `ResetPasswordAsync(user, token, newPassword)`, returns the Identity result mapped to the
|
||||||
|
AuthBlocks `Result`/`ApiResult` convention (see how `Register` maps results in `AuthRoutes.cs`).
|
||||||
|
|
||||||
|
Follow the existing `AuthRoutes` conventions exactly: `ApiResult<T>` / `ApiResultDto<T>` wrapping,
|
||||||
|
`ILogger<AuthLogger>` for logging, `Results.Ok` / `Results.BadRequest` / `Results.Json(..., 500)` shapes.
|
||||||
|
|
||||||
|
### 5.2 Email template (`AuthBlocksLib/Common/PasswordResetEmailTemplate.cs`)
|
||||||
|
|
||||||
|
New static `Create(resetLink, applicationName, supportEmail)` in the visual style of
|
||||||
|
`RegistrationEmailTemplate`. Reset copy: a clear "you (or an admin) requested a password reset," the CTA
|
||||||
|
button to the reset link, an expiry notice matching the token lifespan, and "ignore this email if you
|
||||||
|
didn't request it." No registration code box — reset uses an opaque token in the link, not a
|
||||||
|
user-typed code (recommended; do not show the Identity token as a copy-paste code — it is long and
|
||||||
|
URL-encoded).
|
||||||
|
|
||||||
|
### 5.3 Web surfaces (`AuthBlocksWeb`)
|
||||||
|
|
||||||
|
- **Wire the admin button.** In `Users.razor`, replace the empty `ResetPassword` handler with a call to
|
||||||
|
an `IAuthApiClient` (or the appropriate existing client) method that hits `POST api/auth/forgot-password`
|
||||||
|
for `item.Email`, and show a confirmation (a `StatusMessage` / dialog: "Reset email sent to {email}").
|
||||||
|
This is the admin-initiated trigger.
|
||||||
|
- **Add a public reset page.** New `AuthBlocksWeb/Components/Pages/Account/ResetPassword.razor`,
|
||||||
|
`@page "/account/reset-password"`, `@rendermode InteractiveServer`, **no role gate** (a forgotten-password
|
||||||
|
user is unauthenticated). Read `email` + `resetToken` from query params (mirror `Register.razor`'s
|
||||||
|
`[SupplyParameterFromQuery]` pre-fill), present new-password + confirm fields, submit to
|
||||||
|
`POST api/auth/reset-password`, and on success route to `/account/login` with a success message. Match
|
||||||
|
`Register.razor`'s form structure and validation idiom.
|
||||||
|
- **Optional: a public "forgot password?" entry.** Consider a `/account/forgot-password` page (link from
|
||||||
|
`Login.razor`) where a user enters their email to self-initiate reset — same `forgot-password` endpoint.
|
||||||
|
Decide whether this is in scope or whether reset is admin-initiated only (see open questions).
|
||||||
|
|
||||||
|
### 5.4 Client method
|
||||||
|
|
||||||
|
Add the `forgot-password` / `reset-password` calls to whichever API client the Web project uses for auth
|
||||||
|
(the registration/login flows go through `JwtAuthenticationStateProvider` / `IAuthApiClient` — follow the
|
||||||
|
same pattern; do not introduce a new HTTP client).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Constraints
|
||||||
|
|
||||||
|
- **No account-existence leak** on the public `forgot-password` path (§5.1).
|
||||||
|
- **Reuse, don't reinvent:** `IGeneralEmailSender` for sending, `AuthBlocksOptions` for config, Identity's
|
||||||
|
token provider for tokens, the existing `Result`/`ApiResult` conventions for endpoint returns, and the
|
||||||
|
`RegistrationEmailTemplate` house style for the email.
|
||||||
|
- **Match the existing route + result conventions** in `AuthRoutes.cs` precisely — this is a library;
|
||||||
|
consumers rely on the shape staying idiomatic.
|
||||||
|
- **Versioning:** this lands as a normal AuthBlocks version bump (packed/pushed by `pack.ps1` like the
|
||||||
|
other packages). Note the new version so consumers can pin to it.
|
||||||
|
- **Token lifespan** for reset should be short (recommend 1–2 hours), distinct from the 7-day
|
||||||
|
registration token.
|
||||||
|
- **Password policy** is enforced by `ResetPasswordAsync` automatically — do not duplicate validation,
|
||||||
|
but surface the Identity error messages back through the result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Acceptance criteria
|
||||||
|
|
||||||
|
1. Clicking "Reset Password" for a user on the Users admin page sends that user a styled email with a
|
||||||
|
working reset link, and shows the admin a confirmation. No unhandled exception, no silent no-op.
|
||||||
|
2. Following the reset link lands on `/account/reset-password` with the email pre-filled; setting a new
|
||||||
|
password that meets policy succeeds and the user can immediately log in with the new password.
|
||||||
|
3. An expired or tampered token is rejected with a clear, non-leaky error.
|
||||||
|
4. The public `forgot-password` endpoint returns the same response whether or not the email maps to a
|
||||||
|
real account (no existence leak).
|
||||||
|
5. Email send is exercised through the real `IGeneralEmailSender` (Mailtrap in the configured
|
||||||
|
environment) — verify an email actually arrives.
|
||||||
|
6. No new required config beyond what `AddAuthBlocks` already accepts (reset reuses the existing email
|
||||||
|
connection + application-name + support-email options). If a token-provider registration was missing,
|
||||||
|
it is added and documented.
|
||||||
|
7. Published as a version bump; the new version is recorded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open questions for the implementing team / its sponsor
|
||||||
|
|
||||||
|
1. **Admin-initiated only, or also public self-serve?** Is the only entry point the admin "Reset
|
||||||
|
Password" button (§5.3 first bullet), or do you also want a public "forgot password?" link from the
|
||||||
|
login page (§5.3 last bullet)? The endpoints support both; the question is which Web surfaces to build.
|
||||||
|
*Recommendation: build both endpoints, ship the admin button now, and add the public forgot-password
|
||||||
|
page in the same pass since it is nearly free once the endpoint exists.*
|
||||||
|
2. **Token mechanism — confirm Identity's built-in is acceptable** (§4 recommendation) vs. a hard
|
||||||
|
requirement to use the hand-rolled hashed-token scheme. *Recommendation: Identity built-in.*
|
||||||
|
3. **Reset token lifespan** — confirm the window (recommend 1–2 hours).
|
||||||
|
4. **Return host / link base** — the registration flow has the *caller* pass `returnHost`. Confirm the
|
||||||
|
reset flow does the same (the consumer supplies the base URL of its public reset page), vs. AuthBlocks
|
||||||
|
configuring a reset base URL in options. *Recommendation: pass `returnHost` per-call, mirroring
|
||||||
|
registration, so AuthBlocks stays host-agnostic.*
|
||||||
|
5. **Does the public reset page (`/account/reset-password`) need to render in a consumer's own layout?**
|
||||||
|
The page ships in the AuthBlocks RCL with no `@layout`, so it inherits whatever the consuming host sets
|
||||||
|
as default — same as `Register.razor`. Confirm this is acceptable (it should be; it is how registration
|
||||||
|
already behaves).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Suggested reading order in the repo
|
||||||
|
|
||||||
|
1. `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs` — the email-sending endpoint to mirror.
|
||||||
|
2. `AuthBlocksLib/Routes/AuthRoutes.cs` — where your endpoints go; the result/logging conventions.
|
||||||
|
3. `AuthBlocksLib/Common/RegistrationEmailTemplate.cs` — the email house style.
|
||||||
|
4. `AuthBlocksWeb/Components/Pages/Account/Register.razor` — the public-page + query-param-prefill pattern
|
||||||
|
for your reset page.
|
||||||
|
5. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — the stub to replace.
|
||||||
|
6. `AuthBlocksLib/AuthBlocksExtensions.cs` + `AuthBlocksOptions.cs` — the email sender + options wiring you
|
||||||
|
reuse (and where to add a token-provider registration if one is missing).
|
||||||
|
7. `AuthBlocksData/Services/UserService.cs` — the `UserManager<ApplicationUser>` access point for the
|
||||||
|
Identity reset primitives.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Index — `EditModalSaveContextHolder` Missing DI Registration (BlazorBlocks / AuthBlocks)
|
||||||
|
|
||||||
|
**Status:** ✅ RESOLVED — shipped in `Cerebellum.BlazorBlocks.Web` 10.3.33 + `Cerebellum.AuthBlocks.Web` 10.3.36 (2026-06-20). ~~scoped, not yet started. Confirmed against `Cerebellum.BlazorBlocks.Web` 10.3.32 / `Cerebellum.AuthBlocks.Web` 10.3.33. Author: product-designer. Date: 2026-06-19.~~
|
||||||
|
|
||||||
|
> **Resolution (2026-06-20):** `AddBlazorBlocksWeb()` landed in `Cerebellum.BlazorBlocks.Web` 10.3.33 and `ConfigureAuthServices` calls it in `Cerebellum.AuthBlocks.Web` 10.3.36; DeepDrftManager picked up 10.3.36 and removed its local `EditModalSaveContextHolder` stopgap.
|
||||||
|
> This brief is retained as historical record — no further action required.
|
||||||
|
|
||||||
|
## The defect
|
||||||
|
|
||||||
|
BlazorBlocks' `ModelView` / `EditModelModal` components have a `required [Inject]` dependency on
|
||||||
|
`Web.Maintenance.Entities.EditModalSaveContextHolder` (a per-circuit save bridge), but the BlazorBlocks
|
||||||
|
`Web` package ships no registration extension for it and AuthBlocks' `ConfigureAuthServices` never registers
|
||||||
|
it either. Any consumer of a `ModelView`-based page (e.g. AuthBlocks' `Users.razor` /
|
||||||
|
`Registrations.razor`) crashes the Blazor circuit on navigation with an unregistered-service
|
||||||
|
`InvalidOperationException`. Two independent downstream products have each hand-registered the internal
|
||||||
|
service as a stopgap — the tell that this is a leaked library registration.
|
||||||
|
|
||||||
|
## The two-team layered fix (ordered — do not reorder)
|
||||||
|
|
||||||
|
1. **BlazorBlocks ships first.** Add a Web-side `AddBlazorBlocksWeb()` extension that registers the holder
|
||||||
|
via `TryAddScoped` (scoped is required). Bump `Cerebellum.BlazorBlocks.Web` from 10.3.32; report the new
|
||||||
|
version.
|
||||||
|
2. **AuthBlocks ships second.** Bump its `Cerebellum.BlazorBlocks.Web` reference to that new version, call
|
||||||
|
`AddBlazorBlocksWeb()` from `ConfigureAuthServices`, bump `Cerebellum.AuthBlocks.Web` from 10.3.33.
|
||||||
|
|
||||||
|
AuthBlocks is blocked until BlazorBlocks' new version is published. Registration lives with its owner
|
||||||
|
(BlazorBlocks); AuthBlocks stays self-contained by composing it. MudBlazor (`AddMudServices`) stays a
|
||||||
|
caller-owned prerequisite throughout.
|
||||||
|
|
||||||
|
## The detail lives in the two team briefs
|
||||||
|
|
||||||
|
Each is fully self-contained for an orchestrator working in only that one repo:
|
||||||
|
|
||||||
|
- **BlazorBlocks team** → [`team-brief-blazorblocks-modelview-di.md`](./team-brief-blazorblocks-modelview-di.md)
|
||||||
|
(root cause, `[Inject]` audit, lifetime rationale, the new extension, version bump, acceptance criteria).
|
||||||
|
- **AuthBlocks team** → [`team-brief-authblocks-modelview-di.md`](./team-brief-authblocks-modelview-di.md)
|
||||||
|
(the blocking BlazorBlocks prerequisite, the `ConfigureAuthServices` call, version bump, acceptance
|
||||||
|
criteria).
|
||||||
@@ -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,464 @@
|
|||||||
|
# Phase 19 — AuthBlocks User Management in the CMS
|
||||||
|
|
||||||
|
Status: proposed (rev. 3 — host model corrected by Daniel 2026-06-19). Author: product-designer.
|
||||||
|
Date: 2026-06-19. Implementer: TBD (separate delegation).
|
||||||
|
|
||||||
|
Wire **all three** AuthBlocks account-creation paths into the `DeepDrftManager` CMS so an admin can run
|
||||||
|
account management from inside the same CMS they already use, **and** so an invited user can redeem a
|
||||||
|
registration code and create their own account — **all on `DeepDrftManager` (the CMS app,
|
||||||
|
demoapp.deepdrft.com)**. There are **no changes to `DeepDrftPublic` in this phase.**
|
||||||
|
|
||||||
|
Daniel's framing: *"this is already part of the AuthBlocks library so we just need to wire it up
|
||||||
|
properly."* **That framing is correct, and the wiring is further along than it implies.** Almost the
|
||||||
|
entire integration already landed as a side-effect of the prior AuthBlocks startup separation
|
||||||
|
(`PLAN_authblocks_trackmanager.md`, landed 2026-05-25) and the login/logout integration; what remains
|
||||||
|
is a thin **navigation + public-route-exposure + verification + polish** slice, not an integration
|
||||||
|
project.
|
||||||
|
|
||||||
|
**Host-model correction (Daniel, 2026-06-19 — the crux of this revision).** Rev. 2 placed public
|
||||||
|
self-service registration (path 2) on `DeepDrftPublic` as a cold-start integration. **That was wrong.**
|
||||||
|
Public registration belongs on the **CMS app**, exactly where login already lives: the CMS app
|
||||||
|
*already hosts a public-facing, unauthenticated `/account/login` page* (reachable without being signed
|
||||||
|
in). The registration redemption page is public-facing **in exactly the same way** — an unauthenticated
|
||||||
|
route on the CMS app itself. An invited user clicks the email link, lands on the CMS app's public
|
||||||
|
registration route (`/account/register`), redeems their code, sets a password. **No second host, no
|
||||||
|
`DeepDrftPublic` involvement.** The entire rev-2 "public-site track" (wave 19.4) and its open questions
|
||||||
|
(OQ6–OQ9) are **deleted** — they were artifacts of the wrong host assumption.
|
||||||
|
|
||||||
|
So all three paths live on `DeepDrftManager`. The real remaining questions for path 2 are narrow: it is
|
||||||
|
likely already *route-reachable* (the CMS router discovers `/account/register` via `AdditionalAssemblies`,
|
||||||
|
same as it discovers `/account/login`), so the work is (a) confirming it is correctly **unauthenticated**
|
||||||
|
(no role gate — verified below, it has none), and (b) giving an unauthenticated visitor the **right
|
||||||
|
layout** (the lean splash chrome, not the authenticated app shell), mirroring how login should render.
|
||||||
|
SkipperHaven — another app on the **same AuthBlocks library** — already implements this dual public
|
||||||
|
login/register pattern, and is the canonical reference (§2c).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The three account-creation paths (verified against AuthBlocks source) — ALL on the CMS
|
||||||
|
|
||||||
|
Verified against `C:\Development\AuthBlocks` source. Daniel's three-path understanding is **correct and
|
||||||
|
complete**, and all three are CMS routes:
|
||||||
|
|
||||||
|
| # | Path | Component(s) | Route | Gate | Backed by | Email? |
|
||||||
|
|---|------|--------------|-------|------|-----------|--------|
|
||||||
|
| 1 | **Admin provisions a user directly** (bypasses email/code loop) | `SuperRegister.razor` | `/account/superregister` | UserAdmin | `POST api/auth/admin-register` — **working** | No |
|
||||||
|
| 2 | **Public self-service** — invited user redeems a code and self-registers | `Register.razor` | `/account/register` | **none (public)** | `POST api/auth/register` — **working** | No (consumes code) |
|
||||||
|
| 3 | **Admin provisions a registration token + triggers the invite email** | `NewRegistration.razor` → `NewRegistrationForm.razor` | `/useradmin/registrations/new` | UserAdmin | `POST api/pendingregistration/create` — **working, sends email server-side** | **Yes — real, not stubbed** |
|
||||||
|
|
||||||
|
All three are **CMS routes on `DeepDrftManager`.** Paths 1 and 3 are admin-gated (UserAdmin). Path 2 is
|
||||||
|
**public-facing**, reachable by an unauthenticated visitor — exactly like the CMS `/account/login` page,
|
||||||
|
which is also unauthenticated and on the same host.
|
||||||
|
|
||||||
|
**Path 2 has no role gate (verified).** `Register.razor` declares `@page "/account/register"` +
|
||||||
|
`@rendermode InteractiveServer` and **no** `[HierarchicalRoleAuthorize]` attribute — identical in this
|
||||||
|
respect to `Login.razor` (`@page "/account/login"`, no gate). It reads `UserEmail` + `RegistrationToken`
|
||||||
|
from the query string and pre-fills, so the invite email's deep link lands ready to submit; it calls
|
||||||
|
`AuthStateProvider.RegisterAsync` → `POST api/auth/register`. It is meant to be reached by an
|
||||||
|
unauthenticated visitor.
|
||||||
|
|
||||||
|
**Path 3's email is real.** `PendingRegistrationRoutes.Create`
|
||||||
|
(`AuthBlocksLib/Routes/PendingRegistrationRoutes.cs:62`) generates a token, persists the pending
|
||||||
|
registration, builds the invite link (`{ReturnHost}?UserEmail=&RegistrationToken=`), renders
|
||||||
|
`RegistrationEmailTemplate.Create(...)`, and **sends it via `IGeneralEmailSender.SendEmailAsync`** —
|
||||||
|
a Mailtrap-backed `MailtrapEmailSender` registered in `AuthBlocksExtensions` (line 109) and configured
|
||||||
|
in **DeepDrftAPI** from `environment/authblocks.json` (`AuthBlocks:Email:Host` / `:Token`,
|
||||||
|
`Program.cs:106–109`; `ApplicationName="DeepDrft"`, `SupportEmail` from config). On email-send failure
|
||||||
|
the route **rolls back** the pending-registration row and returns 500. The full invite→email→redeem
|
||||||
|
loop is functional end-to-end across paths 2 and 3, **entirely within the CMS host**: an admin
|
||||||
|
provisions (path 3, CMS) → the prospective user receives an email with a code + link → they land on the
|
||||||
|
CMS app's public `/account/register` (path 2, CMS) with email + token pre-filled → they set a password
|
||||||
|
and the account is created.
|
||||||
|
|
||||||
|
> **Note on `{ReturnHost}`.** The invite email's deep link is built from a configured return host. For
|
||||||
|
> the loop to land on the CMS app, that host must point at the CMS origin (demoapp.deepdrft.com), not the
|
||||||
|
> public site. Verify this config value in 19.3 (it is the one place the wrong-host assumption could be
|
||||||
|
> baked into a *config* rather than code).
|
||||||
|
|
||||||
|
**The one genuinely stubbed surface is Reset Password** — `Users.razor:55` (`// todo integrate with
|
||||||
|
email for secure reset`) has an empty handler and **no backing API endpoint exists** (`AuthRoutes`
|
||||||
|
maps login/register/admin-register/refresh/logout/me/roles — no reset route). That is the subject of
|
||||||
|
the separate `authblocks-password-reset-brief.md`; it must **not** be filed as a DeepDrft bug.
|
||||||
|
|
||||||
|
**Two distinct admin "create" verbs — both stay, they are not duplicates.** `SuperRegister` (path 1)
|
||||||
|
creates a *live account immediately* with a password the admin sets. The registration-token form (path
|
||||||
|
3) creates a *pending invite* — no account yet — and lets the user set their own password via email. They
|
||||||
|
serve different needs (provision-now vs. invite-by-email); both belong in the CMS nav. (The older
|
||||||
|
`NewUser` `ModelView` create form at `/useradmin/users/new` still exists as a third bare admin create
|
||||||
|
path, but it is **not** one of Daniel's three; treat it as redundant with `SuperRegister` and do not
|
||||||
|
surface it in nav. See OQ2.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What AuthBlocks ships, and how it is packaged
|
||||||
|
|
||||||
|
Read from local source at `C:\Development\AuthBlocks`. The key question — *is the user-admin surface
|
||||||
|
consumable or host-bound?* — resolves cleanly: **it is consumable.**
|
||||||
|
|
||||||
|
### The user-admin surface is a published RCL, despite the "Web" name
|
||||||
|
|
||||||
|
`AuthBlocksWeb` is an `Microsoft.NET.Sdk.Razor` project (not `Sdk.Web`) with **no `Program.cs`** — it
|
||||||
|
is a Razor Class Library, not a runnable host. `pack.ps1` packs it as **`Cerebellum.AuthBlocks.Web`**
|
||||||
|
and pushes it to nuget.org. So the user-admin Razor components are distributed as a normal RCL and
|
||||||
|
consumed by reference. **No extraction fork is needed** — the pages are already in the RCL.
|
||||||
|
|
||||||
|
### What's in the package (the consumable surface)
|
||||||
|
|
||||||
|
Components under `AuthBlocksWeb/Components/`:
|
||||||
|
|
||||||
|
- **Account pages** (`Pages/Account/`):
|
||||||
|
- `Login.razor` → `/account/login` (**public — no gate, no `@layout`**; `@rendermode InteractiveServer`).
|
||||||
|
- `Register.razor` → `/account/register` (**path 2** — public self-service via invite code; `@rendermode
|
||||||
|
InteractiveServer`; **no role gate, no `@layout`**; reads `UserEmail` + `RegistrationToken` from the
|
||||||
|
query string and pre-fills; calls `AuthStateProvider.RegisterAsync` → `POST api/auth/register`).
|
||||||
|
- `Logout`, `AccessDenied`.
|
||||||
|
- `SuperRegister.razor` → `/account/superregister` (**path 1** — admin creates a live account
|
||||||
|
immediately, role multiselect; gated `[HierarchicalRoleAuthorize(UserAdmin)]`; calls
|
||||||
|
`IAuthApiClient.AdminRegisterAsync` → `POST api/auth/admin-register`).
|
||||||
|
- **User admin pages** (`Pages/UserAdmin/`), each `@page`-routed and gated
|
||||||
|
`[HierarchicalRoleAuthorize(SystemRoleConstants.UserAdmin)]`:
|
||||||
|
- `Users/Users.razor` → `/useradmin/users` — searchable user grid; per-row Reset Password
|
||||||
|
(**stubbed — no backing endpoint**), Deactivate/Reactivate, edit modal.
|
||||||
|
- `Users/NewUser.razor` → `/useradmin/users/new` — bare create-user form (redundant with `SuperRegister`;
|
||||||
|
not one of Daniel's three paths — do not surface in nav).
|
||||||
|
- `Registrations/Registrations.razor` → `/useradmin/registrations` — pending-invite grid, with
|
||||||
|
`NewRegistration.razor` → `/useradmin/registrations/new` (**path 3** — `NewRegistrationForm` posts to
|
||||||
|
`PendingRegistrationClient.CreatePendingRegistration` → `POST api/pendingregistration/create`, which
|
||||||
|
mints the token **and sends the invite email**) and the edit-registration modal.
|
||||||
|
- `Permissions/Permissions.razor` → `/useradmin/permissions` — user↔role assignment.
|
||||||
|
- **Menu fragments** (`Components/Layout/`): `AccountNavMenu`, `UserAdminMenu` (a `MudNavGroup`
|
||||||
|
with the three user-admin `MudNavLink`s, itself wrapped in a `HierarchicalRoleAuthorizeView` so it
|
||||||
|
only renders for `UserAdmin`+).
|
||||||
|
- **Shared** (`Components/Shared/`): `LogoutButton`, `StatusMessage`.
|
||||||
|
- **DI entry point** (`Startup.cs`): `ConfigureAuthServices(IServiceCollection, string apiBaseUrl)`
|
||||||
|
registers the cascading auth state, the JWT client stack, **and every user-admin client + ViewModel**,
|
||||||
|
all pointed at `apiBaseUrl`.
|
||||||
|
|
||||||
|
The pages lean on `Cerebellum.BlazorBlocks.Web` for grid scaffolding and MudBlazor for chrome — both
|
||||||
|
already present in the CMS.
|
||||||
|
|
||||||
|
### The API side is already hosted
|
||||||
|
|
||||||
|
The clients those ViewModels use call the AuthBlocks **API** surface, which `DeepDrftAPI` already
|
||||||
|
mounts via `app.MapAuthBlocks()` (`Program.cs:184`): `api/auth/*` (incl. `admin-register`, `register`,
|
||||||
|
`roles`), `api/users/*`, `api/roles/*`, `api/user-roles/*`, `api/pendingregistration/*`. `AddAuthBlocks`
|
||||||
|
+ `UseAuthBlocksStartupAsync` (migrate + seed) are wired, and the Auth DB + secrets live in
|
||||||
|
`DeepDrftAPI/environment/`. This all landed with the startup separation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What is ALREADY wired in DeepDrftManager (do not redo)
|
||||||
|
|
||||||
|
Verified against the current `DeepDrftManager` source. These are the integration steps a naive plan
|
||||||
|
would propose — and they are **already done**:
|
||||||
|
|
||||||
|
1. **Package reference.** `DeepDrftManager.csproj:11` references `Cerebellum.AuthBlocks.Web` (10.3.33),
|
||||||
|
which transitively brings `AuthBlocksWeb.Client`, `AuthBlocksLib`, `AuthBlocksModels`.
|
||||||
|
2. **Service wiring.** `Program.cs:35` calls
|
||||||
|
`AuthBlocksWeb.Startup.ConfigureAuthServices(builder.Services, contentApiUrl)` — the user-admin
|
||||||
|
clients and ViewModels are **already in the container**, already pointed at DeepDrftAPI. **This same
|
||||||
|
wiring also registers the `JwtAuthenticationStateProvider` that `Register.razor` (path 2) depends on**
|
||||||
|
— so path 2's service dependency is already satisfied (it is the same provider login uses).
|
||||||
|
3. **Page discovery.** `Routes.razor:2` sets
|
||||||
|
`AdditionalAssemblies="new[] { typeof(AuthBlocksWeb._Imports).Assembly }"` and `Program.cs:131`
|
||||||
|
mirrors it for endpoint mapping. **The Blazor router already discovers every AuthBlocksWeb page**,
|
||||||
|
including `/account/login`, `/account/register`, `/account/superregister`, and the `/useradmin/*`
|
||||||
|
pages. They are route-reachable *today* by typing the URL — **including the public `/account/register`.**
|
||||||
|
4. **Default layout.** `Routes.razor:6` sets `DefaultLayout="typeof(Layout.CmsLayout)"`. Since the
|
||||||
|
AuthBlocks pages declare no `@layout`, they **render inside CmsLayout chrome** — the authenticated app
|
||||||
|
shell. **This is the one wrong thing for the public pages** (login, register): an unauthenticated
|
||||||
|
visitor sees the full authenticated CMS shell rather than the lean splash. See §2b.
|
||||||
|
5. **Role gating already satisfied.** The admin pages gate on `SystemRoleConstants.UserAdmin`. The
|
||||||
|
DeepDrft admin is seeded in role **`Admin`**, parent of `UserAdmin` — hierarchical authorize means
|
||||||
|
**the existing admin already passes the `UserAdmin` gate** with no role change, no new seed, no DB edit.
|
||||||
|
6. **Auth-state + redirect plumbing.** `AuthorizeRouteView` with `RedirectToLogin` /
|
||||||
|
`RedirectToAccessDenied` (`Routes.razor`) already protects the gated surface coherently, and the
|
||||||
|
public pages (no gate) pass straight through it.
|
||||||
|
|
||||||
|
**Net:** an authenticated DeepDrft admin can navigate to `/useradmin/users` today and the page should
|
||||||
|
render and call DeepDrftAPI; and an unauthenticated visitor can reach `/account/register` today. The
|
||||||
|
reasons it *feels* unbuilt: (a) **nothing in the CMS UI links to the admin pages** — `CmsLayout` has no
|
||||||
|
nav drawer at all, so the admin surface is invisible (§G1); and (b) **the public pages render in the
|
||||||
|
wrong (authenticated-shell) layout** for an unauthenticated visitor (§G0/§2b).
|
||||||
|
|
||||||
|
This is the crux: the CMS work is not *integration*, it is *exposure + layout-fix + verification +
|
||||||
|
fit-and-finish*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2b. The public-route layout gap (path 2 + login) — the one real public-facing fix
|
||||||
|
|
||||||
|
The public pages — `/account/login` and `/account/register` — are route-reachable and unauthenticated,
|
||||||
|
but DeepDrftManager's router uses a **static** `DefaultLayout="typeof(Layout.CmsLayout)"`. Because the
|
||||||
|
AuthBlocks public pages declare no `@layout`, an **unauthenticated visitor** lands inside the
|
||||||
|
**authenticated app shell** (`CmsLayout` — the dense admin app bar with a Catalogue/Home button, and
|
||||||
|
soon a nav drawer linking to gated admin surfaces). That is the wrong frame: a visitor who is not
|
||||||
|
signed in should see the lean splash chrome the site already uses for its `/` home splash
|
||||||
|
(`CmsHomeLayout`), not the admin shell.
|
||||||
|
|
||||||
|
DeepDrftManager **already has both layouts**:
|
||||||
|
- `CmsLayout` — the authenticated app shell (`MudThemeProvider` + app bar + main content; gains the nav
|
||||||
|
drawer in 19.1).
|
||||||
|
- `CmsHomeLayout` — the lean splash (`MudThemeProvider` + minimal app bar, centered narrow container),
|
||||||
|
already used by `Home.razor` (`@layout Layout.CmsHomeLayout`) for the unauthenticated `/` splash.
|
||||||
|
|
||||||
|
The fix is to render the **public auth pages in the lean layout** for unauthenticated visitors, and the
|
||||||
|
**gated pages in the app shell** — exactly the SkipperHaven pattern (§2c). The two clean shapes:
|
||||||
|
|
||||||
|
- **G0-a — Auth-state-driven `DefaultLayout` in `Routes.razor` (the SkipperHaven pattern; recommended).**
|
||||||
|
Make the router's `DefaultLayout` a function of auth state: unauthenticated → `CmsHomeLayout`,
|
||||||
|
authenticated → `CmsLayout`. This is exactly what SkipperHaven does (its `Routes.razor` swaps
|
||||||
|
`AuthenticatedLayout`/`UnauthenticatedLayout` in `OnParametersSetAsync` off the cascaded
|
||||||
|
`AuthenticationState`). **DeepDrftManager already has both target layouts**, so this is a small
|
||||||
|
router change, no new layout to author. *Cost:* the gated admin pages also resolve their layout via
|
||||||
|
this switch — but an unauthenticated visitor to a gated page is redirected to login by `NotAuthorized`
|
||||||
|
before layout matters, and an authenticated admin gets `CmsLayout`, so it composes correctly.
|
||||||
|
*Caveat:* a logged-in admin who visits `/account/register` would see it in `CmsLayout` (the app shell)
|
||||||
|
— acceptable, and arguably correct (an admin poking at the public form is in an admin session).
|
||||||
|
- **G0-b — Per-page `@layout` on the public pages.** Add `@layout CmsHomeLayout` to the AuthBlocks
|
||||||
|
public pages. **Rejected — not possible without forking the RCL:** `Login.razor`/`Register.razor` ship
|
||||||
|
inside `Cerebellum.AuthBlocks.Web`; we cannot edit them, and there is no host-side override for an RCL
|
||||||
|
page's `@layout`. G0-a is the only no-fork path.
|
||||||
|
|
||||||
|
**DECIDED direction: G0-a** (auth-state-driven `DefaultLayout`), mirroring SkipperHaven. It is the
|
||||||
|
supported, no-fork way to give the public auth pages the lean layout, it reuses the two layouts
|
||||||
|
DeepDrftManager already has, and it fixes login's layout at the same time as registration's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2c. SkipperHaven — the canonical pattern, and the concrete DeepDrftManager deltas
|
||||||
|
|
||||||
|
`SkipperHaven` (`C:\Development\skipper\SkipperHaven\SkipperHaven`) consumes the **same AuthBlocks
|
||||||
|
library** and already exposes login + register as public/unauthenticated routes with the right layout.
|
||||||
|
The load-bearing piece is its `Components/Routes.razor`:
|
||||||
|
|
||||||
|
- It declares **`[Parameter] AuthenticatedLayout`** (`MainApplicationLayout`) and **`[Parameter]
|
||||||
|
UnauthenticatedLayout`** (`MainHomeLayout`), takes the **cascaded `Task<AuthenticationState>`**, and in
|
||||||
|
`OnParametersSetAsync` sets `_currentLayout` to the authenticated layout iff
|
||||||
|
`authState.User.Identity?.IsAuthenticated == true`, else the unauthenticated layout.
|
||||||
|
- `AuthorizeRouteView` uses **`DefaultLayout="@_currentLayout"`** (the resolved switch), **not** a static
|
||||||
|
type. So the AuthBlocks public pages (login, register — both layout-less) render in `MainHomeLayout`
|
||||||
|
for a signed-out visitor and the app shell once signed in.
|
||||||
|
- Its `NotAuthorized` renders `RedirectToLogin` for unauthenticated and an inline "not authorized" for
|
||||||
|
authenticated-but-unprivileged.
|
||||||
|
- Wiring is otherwise identical to DeepDrftManager: `AuthBlocksWeb.Startup.ConfigureAuthServices(...,
|
||||||
|
apiBaseUrl)` in `Program.cs`, and `AddAdditionalAssemblies(typeof(AuthBlocksWeb._Imports).Assembly)` on
|
||||||
|
the mapped components. (Skipper also adds `AuthBlocksWeb.Client` assemblies because it uses the
|
||||||
|
client-rendered auth surface; **DeepDrftManager is server-rendered `InteractiveServer` and does not need
|
||||||
|
the `.Client` assembly** — its single `AuthBlocksWeb._Imports` entry is sufficient.)
|
||||||
|
|
||||||
|
**Concrete deltas DeepDrftManager needs to match the pattern (this is the whole public-route slice):**
|
||||||
|
|
||||||
|
| # | SkipperHaven | DeepDrftManager today | Delta |
|
||||||
|
|---|--------------|-----------------------|-------|
|
||||||
|
| D1 | `Routes.razor` resolves `DefaultLayout` from auth state (`AuthenticatedLayout` / `UnauthenticatedLayout`, switched in `OnParametersSetAsync` off the cascaded `AuthenticationState`) | `Routes.razor` uses a **static** `DefaultLayout="typeof(Layout.CmsLayout)"` | **Make `DefaultLayout` auth-state-driven**: cascade `Task<AuthenticationState>`, resolve `_currentLayout` = authed ? `CmsLayout` : `CmsHomeLayout`, bind `DefaultLayout="@_currentLayout"`. (G0-a.) **The only required public-route code change.** |
|
||||||
|
| D2 | Two layouts exist (`MainApplicationLayout`, `MainHomeLayout`) | **Already has both** (`CmsLayout`, `CmsHomeLayout`) | **None** — no new layout to author. DeepDrftManager is ahead of Skipper here. |
|
||||||
|
| D3 | `ConfigureAuthServices(..., apiBaseUrl)` in `Program.cs` (registers `JwtAuthenticationStateProvider` etc.) | **Already wired** (`Program.cs:35`) | **None.** |
|
||||||
|
| D4 | AuthBlocks `_Imports` in router `AdditionalAssemblies` + mapped components | **Already wired** (`Routes.razor:2`, `Program.cs:131`) | **None** — `/account/register` is already route-reachable. |
|
||||||
|
| D5 | `NotAuthorized` → `RedirectToLogin` (unauth) / inline message (authed) | `NotAuthorized` → `RedirectToLogin` (unauth) / `RedirectToAccessDenied` (authed) | **None functionally** — DeepDrftManager's existing handling is equivalent (it redirects rather than inlines; fine). |
|
||||||
|
|
||||||
|
**So the public-registration "track" reduces to a single change: D1 (auth-state-driven `DefaultLayout`).**
|
||||||
|
Everything else SkipperHaven does is already present in DeepDrftManager. This is why path 2 is no longer
|
||||||
|
its own host track — it is one router edit, parallelizable with (and smaller than) the admin-nav work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The genuine remaining work
|
||||||
|
|
||||||
|
### G0 — Public-route layout (the path-2 + login fix) — see §2b/§2c
|
||||||
|
|
||||||
|
Make `Routes.razor`'s `DefaultLayout` auth-state-driven (G0-a), so the public `/account/login` and
|
||||||
|
`/account/register` pages render in `CmsHomeLayout` for unauthenticated visitors. Single router change;
|
||||||
|
no new layout (both already exist); no AuthBlocks-source change. This is **independent** of the admin-nav
|
||||||
|
work below and can run in parallel.
|
||||||
|
|
||||||
|
### G1 — Navigation: there is no way to reach the admin surface from the UI *(the real admin gap)*
|
||||||
|
|
||||||
|
`CmsLayout.razor` is an app bar + a single Home `MudIconButton` — **no `MudDrawer`, no nav menu.** The
|
||||||
|
catalogue, releases, upload, and user-admin surfaces are all reachable only by typed URL or in-page
|
||||||
|
buttons. Mounting `UserAdminMenu` requires a navigation container to mount it *into*.
|
||||||
|
|
||||||
|
Three shapes were considered (diverge-before-converge): G1-a app-bar overflow menu (doesn't scale);
|
||||||
|
**G1-b a real `MudDrawer` nav** mounting the existing CMS destinations + the shipped `UserAdminMenu`
|
||||||
|
fragment; G1-c a maximal dedicated Administration section with its own dashboard (scope creep for v1).
|
||||||
|
|
||||||
|
**DECIDED: G1-b (Daniel, 2026-06-19).** A real `MudDrawer` nav in `CmsLayout` (toggle in the app bar)
|
||||||
|
holding the existing primary destinations (Catalogue `/catalogue`, Releases `/releases`, Upload
|
||||||
|
`/tracks/upload`) **and** the shipped `UserAdminMenu` fragment (self-gates to `UserAdmin`+, so it only
|
||||||
|
shows for admins). Surface **both** admin account paths: path 1 (`SuperRegister`,
|
||||||
|
`/account/superregister`) and path 3 (via the `UserAdminMenu` Registrations link → its New button). Do
|
||||||
|
**not** surface the redundant bare `NewUser` (OQ2). It solves the actual gap (no nav) with the least
|
||||||
|
bespoke code, reuses AuthBlocks' own `MudNavGroup` component verbatim, and gives the CMS the navigation
|
||||||
|
spine it's missing. G1-c's admin dashboard remains deferred; G1-a is the rejected stopgap.
|
||||||
|
|
||||||
|
> **Borrowed precedent:** the standard MudBlazor admin-template layout (persistent left `MudDrawer` +
|
||||||
|
> `MudNavMenu`/`MudNavGroup`), which `UserAdminMenu` is already authored against. SkipperHaven's
|
||||||
|
> `MainApplicationLayout`/`NavMenu` is the same shape on the same library — a second confirmation this is
|
||||||
|
> the idiom, not an invention.
|
||||||
|
|
||||||
|
### G2 — Verification pass (the surface is wired but unproven end-to-end)
|
||||||
|
|
||||||
|
Because nothing exercised these pages in the CMS, treat first-light as verification, not assumption.
|
||||||
|
Confirm against a running DeepDrftAPI + Auth DB:
|
||||||
|
|
||||||
|
- `/account/register` (**path 2**) renders for an **unauthenticated** visitor in the **lean
|
||||||
|
`CmsHomeLayout`** (post-G0), pre-fills `UserEmail` + `RegistrationToken` from the query string, and
|
||||||
|
creates the account (consuming the `pending_registration` row) on submit.
|
||||||
|
- `/account/login` likewise renders in the lean layout for an unauthenticated visitor (G0 fixes login's
|
||||||
|
layout as a side benefit).
|
||||||
|
- `/useradmin/users` lists users (the `UsersClient` → `api/users/*` round-trip works cross-host with the
|
||||||
|
bearer token the CMS holds).
|
||||||
|
- `/account/superregister` (**path 1**) creates a live account immediately — `admin-register` is
|
||||||
|
`UserAdmin`-gated server-side; the admin's token must carry the role claim end-to-end.
|
||||||
|
- `/useradmin/registrations/new` (**path 3**) provisions a token **and sends the invite email** — verify
|
||||||
|
the email arrives (Mailtrap), the link/code are correct, the rollback fires on send failure, and
|
||||||
|
**critically that the invite link's `{ReturnHost}` points at the CMS origin** so the deep link lands on
|
||||||
|
the CMS `/account/register` (the place the wrong-host assumption could hide as config — §0 note). This
|
||||||
|
is the surface most likely to surface a *config* gap (`AuthBlocks:Email:Host`/`:Token` + the return
|
||||||
|
host in DeepDrftAPI's `environment/authblocks.json`).
|
||||||
|
- **The full path-3→path-2 loop on one host:** admin provisions in the CMS → email arrives → invited user
|
||||||
|
opens the deep link → lands on the CMS `/account/register` (lean layout) → redeems → account created.
|
||||||
|
- `/useradmin/registrations` lists invites; `/useradmin/permissions` reads + assigns roles.
|
||||||
|
- **CORS / token presentation:** the prior plan widened DeepDrftAPI CORS for the Manager origin for
|
||||||
|
login; confirm the *same* allowance covers `api/users/*` / `api/pendingregistration/*` / `api/auth/register`
|
||||||
|
(it should — same origin, same policy).
|
||||||
|
|
||||||
|
This pass is where any *latent* break surfaces (a client config typo, a missing role claim, a wrong
|
||||||
|
return host, a package-version mismatch). Real work even though no code may change if it all passes.
|
||||||
|
|
||||||
|
### G3 — Theming / fit-and-finish
|
||||||
|
|
||||||
|
The AuthBlocks pages are MudBlazor-default-styled, authored against AuthBlocks' own theme, not the
|
||||||
|
DeepDrft CMS palette (`DeepDrftPalettes.Cms`). Both `CmsLayout` and `CmsHomeLayout` mount a
|
||||||
|
`MudThemeProvider` with that palette, so the pages inherit it for free. Scope for v1: **accept
|
||||||
|
MudBlazor-default styling inside the CMS palette** and only fix outright legibility/contrast breaks
|
||||||
|
(especially the public `/account/register` + `/account/login` now rendering in `CmsHomeLayout`). A deeper
|
||||||
|
bespoke restyle of the AuthBlocks grids is explicitly **out of v1** — deferred polish.
|
||||||
|
|
||||||
|
### G4 — Package version alignment *(housekeeping, flag don't gate)*
|
||||||
|
|
||||||
|
DeepDrftManager references `Cerebellum.AuthBlocks.Web` **10.3.33**; AuthBlocks source is at **10.3.35**.
|
||||||
|
Minor lag. Bumping is low-risk but **not required** for this phase. Note it; Daniel's call on timing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Scope boundaries
|
||||||
|
|
||||||
|
**In for v1 (one host — `DeepDrftManager` — two parallel tracks):**
|
||||||
|
|
||||||
|
*Admin-nav track:*
|
||||||
|
- G1-b: a `MudDrawer` nav in `CmsLayout` mounting `UserAdminMenu` (+ the existing CMS destinations).
|
||||||
|
- All three account paths reachable in the CMS: path 1 (`SuperRegister`, provision-now) and path 3
|
||||||
|
(`/useradmin/registrations/new`, invite-by-email) via nav, plus the users/permissions grids; path 2
|
||||||
|
(`/account/register`) via the public-route track below.
|
||||||
|
|
||||||
|
*Public-route track (the corrected, much smaller path-2 work):*
|
||||||
|
- G0-a: auth-state-driven `DefaultLayout` in `Routes.razor` so `/account/register` (path 2) **and**
|
||||||
|
`/account/login` render in the lean `CmsHomeLayout` for unauthenticated visitors. **One router edit**
|
||||||
|
(§2c D1); both target layouts already exist. The invite email's deep link is the entry point.
|
||||||
|
|
||||||
|
*Shared:*
|
||||||
|
- G2: end-to-end verification of list/create/deactivate users, registrations (incl. the **real invite
|
||||||
|
email** send + correct return host), permissions, **and the path-3→path-2 loop on one host**.
|
||||||
|
- G3: accept-the-palette theming; fix only legibility breaks (incl. the public pages in `CmsHomeLayout`).
|
||||||
|
|
||||||
|
**Deferred (note, don't build):**
|
||||||
|
|
||||||
|
- **Admin dashboard (G1-c)** — a user-admin landing summarizing counts / pending invites. Good later;
|
||||||
|
not a v1 gate.
|
||||||
|
- **Reset Password** — the AuthBlocks `Users` page stubs it; **no backing endpoint exists** in
|
||||||
|
`AuthRoutes`. An *upstream AuthBlocks* gap, not a DeepDrft wiring task. Daniel is handling it as a
|
||||||
|
**separate AuthBlocks-repo effort** — see the standalone `product-notes/authblocks-password-reset-brief.md`.
|
||||||
|
**Do not implement password reset inside DeepDrftHome.**
|
||||||
|
- **Bespoke restyle** of the AuthBlocks grids to the editorial DeepDrft aesthetic.
|
||||||
|
- A visible public "Register" nav link. Registration is invite-only (the email deep link is the entry
|
||||||
|
point); a visible Register link with no self-serve code issuance invites confusion/abuse.
|
||||||
|
**Recommend: no nav link; deep link only.** (Carried over from the dropped OQ9 — still the right call,
|
||||||
|
now trivially so since the form lives on the CMS host the admin already knows.)
|
||||||
|
- **G4 version bump** — housekeeping, Daniel's call on timing.
|
||||||
|
|
||||||
|
**Explicitly not needed:**
|
||||||
|
|
||||||
|
- **Any change to `DeepDrftPublic`.** The corrected host model puts all three paths on the CMS. The public
|
||||||
|
site is untouched. (This deletes the entire rev-2 cold-start track.)
|
||||||
|
- Extracting AuthBlocks pages into a new RCL. They ship in `Cerebellum.AuthBlocks.Web`.
|
||||||
|
- New DI/service wiring, new role seeding, new Auth connection string. All present.
|
||||||
|
- Editing the AuthBlocks `Login`/`Register` pages to set their layout — impossible without forking the
|
||||||
|
RCL, and unnecessary (G0-a handles layout host-side).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phased breakdown (for clean dispatch)
|
||||||
|
|
||||||
|
**One host (`DeepDrftManager`), two parallel tracks.** The admin-nav track (19.1) exposes the gated
|
||||||
|
admin surfaces; the public-route track (19.2) fixes the public auth pages' layout. They touch different
|
||||||
|
files (`CmsLayout.razor` vs. `Routes.razor`) and are independent — kick both off together. Verification
|
||||||
|
(19.3) follows both; theming (19.4) follows and is parallel-ok with verification.
|
||||||
|
|
||||||
|
- **19.1 — CmsLayout navigation (admin-nav track; the main CMS code wave). DECIDED nav shape: G1-b.**
|
||||||
|
Add a `MudDrawer` + toggle to `CmsLayout.razor`; mount the shipped `UserAdminMenu` fragment
|
||||||
|
(self-gates to `UserAdmin`+) and the existing CMS destinations (Catalogue `/catalogue`, Releases
|
||||||
|
`/releases`, Upload `/tracks/upload`). Surface **both** admin account paths: path 1 (`SuperRegister`,
|
||||||
|
`/account/superregister`) and path 3 (`/useradmin/registrations/new`, via the `UserAdminMenu`
|
||||||
|
Registrations link → its New button). Do **not** surface the redundant bare `NewUser` (OQ2). Scope:
|
||||||
|
`CmsLayout.razor` (+ a small `.razor.css` if the drawer needs sizing). **No service, API, data, or
|
||||||
|
AuthBlocks-source change.**
|
||||||
|
- Acceptance: an authenticated `Admin` sees a nav drawer; the User Administration group appears and
|
||||||
|
links to Users / Registrations / Permissions; a "Create user" affordance reaches `SuperRegister`; a
|
||||||
|
non-`UserAdmin` user does not see the group; existing CMS destinations are reachable from the drawer.
|
||||||
|
- **19.2 — Public-route layout (public-route track; parallel to 19.1). DECIDED: G0-a.** Make
|
||||||
|
`Routes.razor`'s `DefaultLayout` auth-state-driven, mirroring SkipperHaven (§2c D1): cascade
|
||||||
|
`Task<AuthenticationState>`, resolve `_currentLayout = authed ? CmsLayout : CmsHomeLayout`, bind
|
||||||
|
`DefaultLayout="@_currentLayout"`. Scope: `DeepDrftManager/Components/Routes.razor` only. **No new
|
||||||
|
layout (both exist), no package, no service, no AuthBlocks-source change.**
|
||||||
|
- Acceptance: an **unauthenticated** visitor to `/account/register` sees the form in the lean
|
||||||
|
`CmsHomeLayout` (not the admin app shell), can pre-fill from the deep link, and self-registers;
|
||||||
|
`/account/login` likewise renders in the lean layout for an unauthenticated visitor; an authenticated
|
||||||
|
admin still gets `CmsLayout` for the gated pages.
|
||||||
|
- **19.3 — End-to-end verification (after 19.1 + 19.2).** Exercise G2 against a running DeepDrftAPI.
|
||||||
|
Confirm list/create/deactivate users, **invite-email send (path 3) + correct `{ReturnHost}` → CMS
|
||||||
|
origin**, permission round-trips, cross-host token + CORS, and the **full path-3→path-2 loop on the
|
||||||
|
single CMS host**. File any latent break as a follow-up (likely a one-line config fix — esp. the
|
||||||
|
Mailtrap creds + return host — or an upstream AuthBlocks issue). **Mostly test, not code.**
|
||||||
|
- **19.4 — Theming legibility sweep (after 19.1 + 19.2, parallel-ok with 19.3).** Walk each user-admin
|
||||||
|
page in the CMS palette, plus the public `/account/register` + `/account/login` in `CmsHomeLayout`; fix
|
||||||
|
only contrast/legibility breaks. Defer bespoke restyle.
|
||||||
|
|
||||||
|
**Dependency shape:** `{19.1, 19.2} → 19.3`; `19.4` follows `{19.1, 19.2}` and is parallel-ok with
|
||||||
|
`19.3`. 19.1 and 19.2 are mutually independent (different files) and should kick off together. The
|
||||||
|
path-3→path-2 acceptance in 19.3 needs 19.1 (to generate an invite) and 19.2 (to land the redeem in the
|
||||||
|
lean layout); a token minted directly via the API can verify path 2 ahead of 19.1 if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Open questions for Daniel
|
||||||
|
|
||||||
|
**Resolved (Daniel, 2026-06-19):**
|
||||||
|
|
||||||
|
1. **Nav shape (G1) — DECIDED G1-b.** Real `MudDrawer` nav mounting `UserAdminMenu` + existing CMS
|
||||||
|
destinations.
|
||||||
|
2. **Admin create paths — DECIDED: surface path 1 (`SuperRegister`) + path 3 (registration-token form);
|
||||||
|
do NOT surface the bare `NewUser`.** Both admin paths stay (provision-now vs. invite-by-email — not
|
||||||
|
duplicates); `NewUser` is redundant with `SuperRegister` and hidden from nav.
|
||||||
|
5. **Reset Password — DECIDED: non-functional in v1, handled separately** as an upstream AuthBlocks-repo
|
||||||
|
effort (see `authblocks-password-reset-brief.md`). The 19.3 verification pass must not file it.
|
||||||
|
6. **Host model — DECIDED (this revision): all three paths on `DeepDrftManager`; NO `DeepDrftPublic`
|
||||||
|
changes.** Public registration is a public/unauthenticated CMS route exactly like the CMS login. The
|
||||||
|
public-route work reduces to one router edit (G0-a, §2c D1).
|
||||||
|
7. **Public-route layout — DECIDED G0-a:** auth-state-driven `DefaultLayout` in `Routes.razor`, mirroring
|
||||||
|
SkipperHaven; reuses the existing `CmsHomeLayout`. (G0-b — per-page `@layout` — rejected: requires
|
||||||
|
forking the RCL.)
|
||||||
|
|
||||||
|
**Still open:**
|
||||||
|
|
||||||
|
3. **Admin dashboard (G1-c) — defer or include?** **Recommend defer.** Net-new surface beyond what
|
||||||
|
AuthBlocks ships; v1 should expose the working pages, not build a new one.
|
||||||
|
4. **Package bump (G4) — now or separate?** Bump `Cerebellum.AuthBlocks.Web` 10.3.33 → 10.3.35 in this
|
||||||
|
pass, or leave it? **Recommend leave it** unless 19.3 surfaces a fix that needs it.
|
||||||
|
8. **Logged-in admin visiting `/account/register`.** Under G0-a, an authenticated admin who navigates to
|
||||||
|
the public register page sees it in `CmsLayout` (the app shell) rather than the lean layout. **Recommend
|
||||||
|
accept** — it is coherent (an admin in a session sees the admin shell) and the page still works; the
|
||||||
|
primary audience (unauthenticated invitees) gets the lean layout correctly. Flag only if Daniel wants
|
||||||
|
the register page forced lean regardless of session.
|
||||||
|
|
||||||
|
None block 19.1 or 19.2.
|
||||||
@@ -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,205 @@
|
|||||||
|
# Team Brief — AuthBlocks: Register `ModelView`'s Missing Dependency via `ConfigureAuthServices`
|
||||||
|
|
||||||
|
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
|
||||||
|
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
|
||||||
|
consume AuthBlocks. Everything you need is in this brief or in that one repo (plus a single new BlazorBlocks
|
||||||
|
package version — see §2, the blocking prerequisite).
|
||||||
|
|
||||||
|
**Status:** ✅ RESOLVED — shipped in `Cerebellum.BlazorBlocks.Web` 10.3.33 + `Cerebellum.AuthBlocks.Web` 10.3.36 (2026-06-20). ~~scoped request, blocked on a BlazorBlocks publish (see §2). Confirmed at runtime against `Cerebellum.AuthBlocks.Web` 10.3.33 / `Cerebellum.BlazorBlocks.Web` 10.3.32. Author: product-designer (for a downstream consumer team). Date: 2026-06-19.~~
|
||||||
|
|
||||||
|
> **Resolution (2026-06-20):** `AddBlazorBlocksWeb()` landed in `Cerebellum.BlazorBlocks.Web` 10.3.33 and `ConfigureAuthServices` calls it in `Cerebellum.AuthBlocks.Web` 10.3.36; DeepDrftManager picked up 10.3.36 and removed its local `EditModalSaveContextHolder` stopgap.
|
||||||
|
> This brief is retained as historical record — no further action required.
|
||||||
|
|
||||||
|
**This is one half of a two-team, ordered fix. AuthBlocks ships second — see §2 and §9.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The defect in one sentence
|
||||||
|
|
||||||
|
`AuthBlocksWeb` ships `Users.razor` and `Registrations.razor`, both of which render BlazorBlocks'
|
||||||
|
`<ModelView>` component — but `ConfigureAuthServices` (the single DI entry point consumers call to light up
|
||||||
|
the AuthBlocks Web surface) does **not** ensure `ModelView`'s required service
|
||||||
|
`Web.Maintenance.Entities.EditModalSaveContextHolder` is registered. So a consumer that wires up AuthBlocks
|
||||||
|
the normal way gets an unhandled `InvalidOperationException` that **terminates the Blazor circuit on
|
||||||
|
navigation** to either page, unless that consumer manually hand-registers an internal BlazorBlocks service
|
||||||
|
in its own `Program.cs`.
|
||||||
|
|
||||||
|
The fix: have `ConfigureAuthServices` call BlazorBlocks' new `AddBlazorBlocksWeb()` extension (which
|
||||||
|
registers the holder), so AuthBlocks stays self-contained for *its* consumers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Blocking prerequisite — BlazorBlocks must ship first
|
||||||
|
|
||||||
|
This fix **cannot land until BlazorBlocks publishes a new `Cerebellum.BlazorBlocks.Web` version that
|
||||||
|
exposes a Web-side registration extension** (working name `AddBlazorBlocksWeb()`). That extension is what
|
||||||
|
actually registers `EditModalSaveContextHolder`; AuthBlocks' job is only to *call* it.
|
||||||
|
|
||||||
|
- AuthBlocks is currently on `Cerebellum.BlazorBlocks.Web` **10.3.32**, which has **no** such extension.
|
||||||
|
- The BlazorBlocks team is shipping the extension and bumping the package as the first half of this fix.
|
||||||
|
- **Reference the specific new version BlazorBlocks publishes for this fix** — fill in the exact version
|
||||||
|
number once the BlazorBlocks team reports it. Do not proceed against 10.3.32; the method will not exist.
|
||||||
|
|
||||||
|
If you reach this work before the BlazorBlocks version is available, stop and wait for the published version
|
||||||
|
number. The AuthBlocks change is small once the prerequisite is in hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The confirmed failure
|
||||||
|
|
||||||
|
### Stack trace (captured from a consuming app on navigation to the Users admin page)
|
||||||
|
|
||||||
|
```
|
||||||
|
System.InvalidOperationException: Cannot provide a value for property 'SaveContextHolder' on type
|
||||||
|
'Web.Maintenance.Entities.ModelView`5[[AuthBlocksModels.InputModels.UserInputModel, ...],
|
||||||
|
[AuthBlocksModels.Models.UserModel, ...],[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UserEditModal, ...],
|
||||||
|
[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UsersViewModel, ...],
|
||||||
|
[AuthBlocksModels.Converters.UserModelToInputConverter, ...]]'.
|
||||||
|
There is no registered service of type 'Web.Maintenance.Entities.EditModalSaveContextHolder'.
|
||||||
|
at Microsoft.AspNetCore.Components.ComponentFactory...CreatePropertyInjector...
|
||||||
|
```
|
||||||
|
|
||||||
|
`ModelView` declares `SaveContextHolder` as `required` with `[Inject]`, so Blazor's component factory
|
||||||
|
throws during component activation. There is no try/catch around component instantiation in the render path,
|
||||||
|
so the exception propagates and tears down the circuit. The user sees a dead page / "An unhandled error has
|
||||||
|
occurred" and must reload.
|
||||||
|
|
||||||
|
### Which AuthBlocks pages trigger it
|
||||||
|
|
||||||
|
`AuthBlocksWeb` ships two user-admin pages that render `<ModelView>`:
|
||||||
|
|
||||||
|
- `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor`
|
||||||
|
- `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/Registrations.razor`
|
||||||
|
|
||||||
|
Any consumer that routes to either page hits the defect on first navigation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Why this is AuthBlocks' gap to close (not the consumer's)
|
||||||
|
|
||||||
|
`AuthBlocksWeb/Startup.cs` exposes `ConfigureAuthServices(IServiceCollection, string apiBaseUrl)` — the
|
||||||
|
**single DI entry point** consumers call to light up the AuthBlocks Web surface. It already registers all
|
||||||
|
the user-admin viewmodels and clients (`UsersViewModel`, `RegistrationsViewModel`, `PermissionsViewModel`,
|
||||||
|
their clients, auth state, hierarchical authorization, etc.). It does **not** register
|
||||||
|
`EditModalSaveContextHolder` and does **not** call any BlazorBlocks Web extension.
|
||||||
|
|
||||||
|
So AuthBlocks ships pages that depend on `ModelView` while leaving one of `ModelView`'s required services
|
||||||
|
unregistered. The consumer is silently expected to fill the gap — and that is exactly what happened:
|
||||||
|
**two independent downstream products** each had to hand-register the internal BlazorBlocks service
|
||||||
|
(`AddScoped<Web.Maintenance.Entities.EditModalSaveContextHolder>()`) in their own `Program.cs` to make
|
||||||
|
AuthBlocks' shipped pages work. The whole value of a single `ConfigureAuthServices` entry point is that a
|
||||||
|
consumer calling it (plus the already-required `AddMudServices`) gets a working surface with zero manual
|
||||||
|
registrations. Today they don't. Closing this gap inside `ConfigureAuthServices` restores that promise.
|
||||||
|
|
||||||
|
Note: `IDialogService` / `ISnackbar` (also injected by `ModelView`) come from MudBlazor's
|
||||||
|
`AddMudServices()`, which every AuthBlocks Web consumer already calls as a documented prerequisite — those
|
||||||
|
are not the gap. The single library-owned gap is `EditModalSaveContextHolder`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The fix
|
||||||
|
|
||||||
|
### 5.1 Bump the BlazorBlocks reference
|
||||||
|
|
||||||
|
Update the `Cerebellum.BlazorBlocks.Web` package reference (currently 10.3.32) to the new version
|
||||||
|
BlazorBlocks publishes for this fix (see §2 — fill in the exact version). This is what makes
|
||||||
|
`AddBlazorBlocksWeb()` available.
|
||||||
|
|
||||||
|
### 5.2 Call the extension from `ConfigureAuthServices`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// AuthBlocksWeb/Startup.cs, inside ConfigureAuthServices(...)
|
||||||
|
services.AddBlazorBlocksWeb(); // registers EditModalSaveContextHolder for the ModelView-based pages
|
||||||
|
```
|
||||||
|
|
||||||
|
Order does not matter for this scoped service; place it near the other registrations. The
|
||||||
|
`AddBlazorBlocksWeb()` extension registers `EditModalSaveContextHolder` as scoped per circuit (its correct
|
||||||
|
lifetime — the holder is per-circuit mutable state that `ModelView` writes and `EditModelModal` reads).
|
||||||
|
|
||||||
|
### 5.3 Why this belongs in `ConfigureAuthServices`, not pushed onto consumers
|
||||||
|
|
||||||
|
- `ConfigureAuthServices` is AuthBlocks' **single DI entry point**. A consumer calling only
|
||||||
|
`AddMudServices()` + `ConfigureAuthServices(...)` should get a fully working user-admin surface. Folding
|
||||||
|
the BlazorBlocks call into the existing entry point keeps that promise; introducing a second method the
|
||||||
|
consumer must remember to call (or expecting them to call `AddBlazorBlocksWeb()` themselves) just relocates
|
||||||
|
the leaked registration one layer up.
|
||||||
|
- AuthBlocks must **not** register `EditModalSaveContextHolder` directly (reaching into BlazorBlocks'
|
||||||
|
internal `Web.Maintenance.Entities` namespace) — that is the same smell the two consumers exhibited,
|
||||||
|
merely relocated. Compose BlazorBlocks' own `Add*` extension instead; the registration lives with its
|
||||||
|
owner, and AuthBlocks stays self-contained by calling it. This is the standard ASP.NET Core layering:
|
||||||
|
each library exposes an `Add*` for its own services, and a higher-level library's `Add*` calls the lower
|
||||||
|
one's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Constraints
|
||||||
|
|
||||||
|
- **Do not register `EditModalSaveContextHolder` directly** in AuthBlocks. Call `AddBlazorBlocksWeb()`;
|
||||||
|
let BlazorBlocks own its type (§5.3).
|
||||||
|
- **Keep `ConfigureAuthServices` the single AuthBlocks entry point.** Do not introduce a second method
|
||||||
|
consumers must remember to call; fold the BlazorBlocks call into the existing one.
|
||||||
|
- **MudBlazor remains a caller-owned prerequisite.** AuthBlocks already relies on consumers calling
|
||||||
|
`AddMudServices()` for all its MudBlazor-based pages; do not absorb it into `ConfigureAuthServices`.
|
||||||
|
- **Versioning:** AuthBlocks Web packs/pushes via its `pack.ps1` / packaging script. `AuthBlocksWeb` is
|
||||||
|
currently `Cerebellum.AuthBlocks.Web` **10.3.33**; bump to the next version after referencing the new
|
||||||
|
BlazorBlocks version and adding the `AddBlazorBlocksWeb()` call. **Record the new version** so consumers
|
||||||
|
can pin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Acceptance criteria
|
||||||
|
|
||||||
|
1. The `Cerebellum.BlazorBlocks.Web` package reference is bumped to the new version BlazorBlocks published
|
||||||
|
for this fix, and `ConfigureAuthServices` calls `AddBlazorBlocksWeb()`.
|
||||||
|
2. A fresh consumer that calls **only** `AddMudServices()` and
|
||||||
|
`AuthBlocksWeb.Startup.ConfigureAuthServices(...)` — and **registers nothing else by hand** — can
|
||||||
|
navigate to the Users admin page and the Registrations admin page with **no `InvalidOperationException`**
|
||||||
|
and **no circuit teardown**.
|
||||||
|
3. Working behavior means not just page load: opening the edit dialog on a user, saving a valid change,
|
||||||
|
**succeeds** — i.e. the save bridge actually works end-to-end through AuthBlocks' pages.
|
||||||
|
4. No AuthBlocks consumer needs to touch `Web.Maintenance.Entities` directly; no manual registrations are
|
||||||
|
required beyond the documented `AddMudServices`.
|
||||||
|
5. `AuthBlocksWeb` is published as a version bump from 10.3.33, and the new version number is recorded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open questions for the implementing team / its sponsor
|
||||||
|
|
||||||
|
1. **Exact BlazorBlocks version to reference.** Pending the BlazorBlocks team's publish (§2). Confirm the
|
||||||
|
published version number and the exact extension method name (proposed `AddBlazorBlocksWeb()`) before
|
||||||
|
landing the call.
|
||||||
|
2. **Placement within `ConfigureAuthServices`.** Anywhere in the method works (scoped service, order-
|
||||||
|
independent). Confirm there is no existing convention in `Startup.cs` for grouping third-party `Add*`
|
||||||
|
calls that this should follow.
|
||||||
|
3. **Any other AuthBlocks pages built on `ModelView`/`NewModelView`?** This brief identified Users and
|
||||||
|
Registrations. If the team expects to add more maintenance pages, note that calling `AddBlazorBlocksWeb()`
|
||||||
|
once covers them all (it is the single home for the maintenance-component deps).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Suggested reading order in the repo
|
||||||
|
|
||||||
|
1. `AuthBlocksWeb/Startup.cs` — `ConfigureAuthServices`, the single entry point; add the
|
||||||
|
`AddBlazorBlocksWeb()` call here.
|
||||||
|
2. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — renders `<ModelView>`; triggers the
|
||||||
|
defect on navigation.
|
||||||
|
3. `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/Registrations.razor` — the second page that
|
||||||
|
renders `<ModelView>`.
|
||||||
|
4. The AuthBlocks Web `.csproj` — the `Cerebellum.BlazorBlocks.Web` package reference to bump.
|
||||||
|
5. The AuthBlocks `pack.ps1` / packaging script — to bump and publish `AuthBlocksWeb` after referencing the
|
||||||
|
new BlazorBlocks version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Cross-team ordering (important — you ship second)
|
||||||
|
|
||||||
|
This fix is layered across two repos and **must land in order**:
|
||||||
|
|
||||||
|
1. **BlazorBlocks ships first.** It adds `AddBlazorBlocksWeb()`, bumps `Cerebellum.BlazorBlocks.Web` from
|
||||||
|
10.3.32, packs/pushes, and reports the new version number.
|
||||||
|
2. **Then AuthBlocks (this team)** bumps its `Cerebellum.BlazorBlocks.Web` reference to that published
|
||||||
|
version, calls `AddBlazorBlocksWeb()` from `ConfigureAuthServices`, bumps `Cerebellum.AuthBlocks.Web` from
|
||||||
|
10.3.33, and publishes.
|
||||||
|
|
||||||
|
This team **cannot complete its part until the BlazorBlocks version is published** (§2). Confirm that
|
||||||
|
version number is in hand before starting.
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Team Brief — AuthBlocks: Normalize the Account-Creation Pages (NewUser vs. Registration vs. SuperRegister)
|
||||||
|
|
||||||
|
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
|
||||||
|
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
|
||||||
|
consume AuthBlocks. Everything you need is in this brief or in that one repo.
|
||||||
|
|
||||||
|
**Status:** scoped request, **decisions approved 2026-06-20** — ready for implementation. Author: product-designer (for a downstream consumer team).
|
||||||
|
Date: 2026-06-20.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The problem in one sentence
|
||||||
|
|
||||||
|
AuthBlocks ships **three** account-creation pages whose identities have drifted: the **New User** page
|
||||||
|
(`/useradmin/users/new`) is broken and has become a half-built *duplicate of the invite/registration flow*
|
||||||
|
instead of the **direct admin-provisioning** path it is named for — while a separate page,
|
||||||
|
**SuperRegister** (`/account/superregister`), is the page that actually performs direct admin provisioning.
|
||||||
|
The two "create an account now" identities live in different places, one of them is broken, and the labels
|
||||||
|
lie about what each does. This brief normalizes the three paths into distinct, correctly-named flows.
|
||||||
|
|
||||||
|
The intended end state (per the consuming team):
|
||||||
|
|
||||||
|
1. **Direct provision** — admin creates a *live* account immediately (username + email + password + roles),
|
||||||
|
bypassing email entirely. This is what **New User** should be.
|
||||||
|
2. **Admin invite-by-email** — admin sends a registration code + link to an email; the recipient redeems it
|
||||||
|
to create their own account. This is the **Registration** flow.
|
||||||
|
3. **Public self-service redeem** — the recipient lands on the public **Register** page and completes
|
||||||
|
account creation with the code. (Already correct; included for completeness.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Current-state analysis (read this before designing — it is the crux)
|
||||||
|
|
||||||
|
### 2.1 New User — `/useradmin/users/new` (BROKEN + mislabeled + duplicate)
|
||||||
|
|
||||||
|
- **Page:** `AuthBlocksWeb/Components/Pages/UserAdmin/Users/NewUser.razor` — just renders `<NewUserForm/>`.
|
||||||
|
- **Form markup:** `.../Users/NewUserForm.razor`. The card header reads **"Activate New User"**. The body
|
||||||
|
text reads:
|
||||||
|
> *"Create a new user account, bypassing email registration. The password must be provided now."*
|
||||||
|
This message describes **direct provisioning** — and it is the **wrong message for what the page actually
|
||||||
|
does**, because:
|
||||||
|
- The form has **only an Email field**. There is **no username field, no password field, and no role
|
||||||
|
selector** — despite the copy promising "the password must be provided now."
|
||||||
|
- The submit button is labeled **"Send Registration Code"** — i.e. invite-flow language, contradicting the
|
||||||
|
"bypassing email registration" body copy directly above it.
|
||||||
|
- **What it actually does:** **nothing — it throws.** The form's `OnValidSubmit` is wired to a stub:
|
||||||
|
```csharp
|
||||||
|
// NewUserForm.razor.cs
|
||||||
|
private void X()
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The code-behind also carries a **commented-out body** that, if enabled, would call
|
||||||
|
`Client.CreatePendingRegistration(...)` and pop a `UserSubmittedModal` — i.e. it would make NewUser
|
||||||
|
**identical to the invite/Registration flow**. So the page is mid-migration: someone started turning the
|
||||||
|
direct-provision page into a second copy of the invite page, didn't finish, and left a throwing stub.
|
||||||
|
- **Backing model is the invite model, not the provision model:** `NewUserForm` binds
|
||||||
|
`PendingRegistrationInputModel Input` and injects `PendingRegistrationClient` — the *registration* model
|
||||||
|
and client, **not** `AdminRegisterRequest` / the admin-register path. This is the concrete duplication:
|
||||||
|
NewUser is plumbed for invites, not for direct provisioning.
|
||||||
|
|
||||||
|
**In short:** NewUser is named for direct provisioning, *says* it does direct provisioning ("bypassing email
|
||||||
|
registration… password must be provided now"), is *wired* for invites (PendingRegistration model/client +
|
||||||
|
commented-out invite call), is *labeled* for invites ("Send Registration Code"), and **actually throws
|
||||||
|
`NotImplementedException`**. Every layer disagrees with every other layer.
|
||||||
|
|
||||||
|
### 2.2 SuperRegister — `/account/superregister` (the REAL direct-provision page, working)
|
||||||
|
|
||||||
|
- **Page:** `AuthBlocksWeb/Components/Pages/Account/SuperRegister.razor`. `[HierarchicalRoleAuthorize(UserAdmin)]`,
|
||||||
|
`@rendermode InteractiveServer`. Title "Admin Register"; header "Create a new account."
|
||||||
|
- **This is the genuine direct-provision UI.** Full form: Username, Email, Password, Confirm Password, and a
|
||||||
|
**multi-select Roles** dropdown populated from `AuthApiClient.GetRolesAsync`.
|
||||||
|
- **Backing model + call:** binds `AdminRegisterRequest` (UserName, Email, Password, ConfirmPassword,
|
||||||
|
RoleIds) and calls `AuthApiClient.AdminRegisterAsync(Input, token)` → `POST api/auth/admin-register`.
|
||||||
|
- **What that endpoint does** (`AuthBlocksLib/Routes/AuthRoutes.cs`, `AdminRegister`, role-gated to
|
||||||
|
`UserAdmin`): rejects a duplicate email; resolves each `RoleId` to a role name up front (fail-fast);
|
||||||
|
creates the user via `userService.Add(user, request.Password)` with `EmailConfirmed = true`; assigns
|
||||||
|
roles (deleting the half-created user if a role assignment fails); returns an `AuthResponse`. **No email,
|
||||||
|
no token, no pending row — a live account immediately.** This is exactly the behavior the consumer wants
|
||||||
|
*New User* to have.
|
||||||
|
|
||||||
|
### 2.3 New Registration — `/useradmin/registrations/new` (the invite flow, working)
|
||||||
|
|
||||||
|
- **Page:** `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/NewRegistration.razor` → `<NewRegistrationForm/>`.
|
||||||
|
- **Form:** `.../Registrations/NewRegistrationForm.razor`. Header **"Provision New User"** (note: this label
|
||||||
|
is *also* slightly off — it performs an *invite*, not a provision; see decisions §5). Body: *"A registration
|
||||||
|
code and link will be sent to the email address provided."* Fields: Email + multi-select Roles. Button:
|
||||||
|
"Send Registration Code".
|
||||||
|
- **Backing model + call:** binds `PendingRegistrationInputModel`, injects `PendingRegistrationClient`, and
|
||||||
|
on submit calls `Client.CreatePendingRegistration(email, roles, returnHost=.../account/register)`, then
|
||||||
|
shows `RegistrationSubmittedModal` (which reports "An email has been sent to: …").
|
||||||
|
- **What that hits:** `POST api/pendingregistration/create` (`AuthBlocksLib/Routes/PendingRegistrationRoutes.cs`,
|
||||||
|
`Create`, group role-gated to `UserAdmin`): rejects existing user / existing pending registration;
|
||||||
|
generates a registration token; persists a `PendingRegistration` row with the token **hash** and an expiry;
|
||||||
|
emails a code + a deep link (`returnHost?UserEmail=&RegistrationToken=`) via `IGeneralEmailSender`. The
|
||||||
|
recipient later redeems at the public **Register** page (`POST api/auth/register`, which validates the
|
||||||
|
code, creates the user, consumes the token).
|
||||||
|
|
||||||
|
### 2.4 The duplication, stated precisely
|
||||||
|
|
||||||
|
`NewUserForm` and `NewRegistrationForm` **bind the same model (`PendingRegistrationInputModel`) and the same
|
||||||
|
client (`PendingRegistrationClient`), and NewUser's commented-out handler is a near-copy of
|
||||||
|
NewRegistration's `CreatePendingRegistration` handler.** NewUser is, in its half-built state, a strictly
|
||||||
|
worse duplicate of NewRegistration (no roles field, throwing stub) — while the page that *should* own
|
||||||
|
NewUser's intended behavior (direct provision) is the separate `SuperRegister`. The system has:
|
||||||
|
|
||||||
|
- **two pages aimed at the invite flow** (Registration = working; NewUser = broken duplicate), and
|
||||||
|
- **one page doing direct provision under a different name/route** (SuperRegister),
|
||||||
|
- **zero working pages at the New User route doing what "New User" implies.**
|
||||||
|
|
||||||
|
### 2.5 The API is already correct — this is a Web-layer normalization
|
||||||
|
|
||||||
|
Both endpoints exist and work today: `admin-register` (direct provision, role-gated) and
|
||||||
|
`pendingregistration/create` (invite). **No new API endpoints are required.** This task is about pointing
|
||||||
|
the right *page* at the right *existing* endpoint, with honest labels and routes. (Contrast the
|
||||||
|
password-reset brief, which was build-from-scratch on both tiers.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The normalization design
|
||||||
|
|
||||||
|
End state: **three crisp paths, each at one page, each correctly labeled, each on the right endpoint.**
|
||||||
|
|
||||||
|
| Path | Page (canonical) | Route | Endpoint | Result |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Direct provision | **New User** | `/useradmin/users/new` | `POST api/auth/admin-register` | live account now |
|
||||||
|
| Admin invite | **New Registration** | `/useradmin/registrations/new` | `POST api/pendingregistration/create` | emailed code + link |
|
||||||
|
| Public redeem | **Register** | `/account/register` | `POST api/auth/register` | recipient self-creates |
|
||||||
|
|
||||||
|
The core move: **make `NewUser` the canonical direct-provision page by absorbing SuperRegister's behavior**,
|
||||||
|
fix its copy, and resolve the now-redundant SuperRegister. Registration stays as-is (modulo a label tidy).
|
||||||
|
|
||||||
|
### 3.1 Recommended approach — "Absorb into NewUser, retire SuperRegister"
|
||||||
|
|
||||||
|
1. **Rebuild `NewUserForm` as the direct-provision form.** Re-bind it from `PendingRegistrationInputModel` /
|
||||||
|
`PendingRegistrationClient` to **`AdminRegisterRequest`** and the **auth client** that calls
|
||||||
|
`AdminRegisterAsync` (the `IAuthApiClient` + `IAuthSession` token pattern SuperRegister already uses).
|
||||||
|
Bring across SuperRegister's full field set: Username, Email, Password, Confirm Password, and the
|
||||||
|
role multi-select sourced from `GetRolesAsync`. Delete the throwing `X()` stub and the commented-out
|
||||||
|
invite handler. Keep the existing card/`MudContainer` chrome so it matches the other UserAdmin pages
|
||||||
|
(NewUser/NewRegistration share a card layout that the standalone SuperRegister does not).
|
||||||
|
2. **Fix the copy.** Header → e.g. **"New User — Direct Provision"** (or "Activate New User", kept, now that
|
||||||
|
the page genuinely activates one). Body → keep the accurate *"Create a live account now, bypassing email
|
||||||
|
registration. Set the password directly."* Button → **"Create Account"** (retire the misleading
|
||||||
|
"Send Registration Code" on this page). On success, show a confirmation and route back to
|
||||||
|
`/useradmin/users` (force-reload so the grid refreshes — mirror NewRegistration's post-submit nav).
|
||||||
|
3. **Retire `SuperRegister`.** Once NewUser owns direct provision, SuperRegister is a duplicate. Preferred:
|
||||||
|
**delete the page and redirect `/account/superregister` → `/useradmin/users/new`** so any existing
|
||||||
|
bookmark or consumer nav link doesn't 404 during the consumer's catch-up window (see §7). Keep the
|
||||||
|
redirect lightweight (a `NavigationManager.NavigateTo` in a thin page, or a server redirect). Do **not**
|
||||||
|
leave two separate-but-identical "create now" pages alive.
|
||||||
|
4. **Tidy Registration's label** (small, optional but recommended for the normalization to be coherent):
|
||||||
|
the invite page header currently says **"Provision New User"**, which collides with the direct-provision
|
||||||
|
concept now owned by NewUser. Rename it to **"Invite New User"** / **"New Registration"** so "provision"
|
||||||
|
unambiguously means *direct* and "invite/registration" means *emailed code*. No behavior change.
|
||||||
|
|
||||||
|
**Why this approach:** NewUser's route (`/useradmin/users/new`) is where an admin looking at the Users grid
|
||||||
|
expects to click "add a user," and it lives in the UserAdmin/Users area beside the grid — the natural home
|
||||||
|
for the canonical create-now action. SuperRegister sits oddly under `/account/*` (the *public* auth area,
|
||||||
|
alongside Login/Register) despite being an admin-only action; folding it into UserAdmin/Users fixes that
|
||||||
|
mis-placement as a side effect. The API already supports it, so this is low-risk re-pointing, not new
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
### 3.2 Alternatives considered
|
||||||
|
|
||||||
|
- **B — Keep SuperRegister canonical; make NewUser a redirect to it.** Inverse of the recommendation:
|
||||||
|
delete `NewUserForm`'s logic, point `/useradmin/users/new` → `/account/superregister`. Cheaper (no form
|
||||||
|
rebuild), but it **enshrines the mis-placement** (admin-only page under `/account/*`) and leaves the
|
||||||
|
visual inconsistency (SuperRegister doesn't use the UserAdmin card chrome). Rejected: it normalizes the
|
||||||
|
*names* but not the *information architecture*. Choose this only if rebuilding the form is deemed
|
||||||
|
out-of-budget for now — and even then, treat it as interim.
|
||||||
|
- **C — Keep both pages, share one form component.** Extract a single `DirectProvisionForm` component and
|
||||||
|
render it from both `NewUser.razor` and `SuperRegister.razor`. Eliminates code duplication but **leaves
|
||||||
|
two routes for one action** — exactly the "two create-now pages" the consumer is asking to remove. Rejected
|
||||||
|
for the explicit goal; the duplication the consumer dislikes is at the *page/route* level, not just code.
|
||||||
|
- **D — Make NewUser a *chooser*** (two buttons: "Create now" vs. "Invite by email").** A single entry point
|
||||||
|
that branches to the two real flows. Genuinely nice UX and worth noting as a *future* enhancement, but it
|
||||||
|
is scope-creep on a "normalize what exists" request and introduces a fourth surface. Defer.
|
||||||
|
|
||||||
|
**Recommendation: A.** It produces the cleanest end state (correct names, correct routes, correct IA, no
|
||||||
|
redundant page) at the cost of one form rebuild that is mostly a copy of SuperRegister's already-working
|
||||||
|
form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Constraints
|
||||||
|
|
||||||
|
- **No new API endpoints.** `admin-register` and `pendingregistration/create` already exist and are correct
|
||||||
|
(§2.5). If you find yourself adding an endpoint, stop — you've taken a wrong turn.
|
||||||
|
- **Preserve role-gating.** Direct provision must stay `[HierarchicalRoleAuthorize(UserAdmin)]` (SuperRegister
|
||||||
|
has it; ensure rebuilt NewUser keeps it — NewUser currently inherits whatever the UserAdmin pages set, so
|
||||||
|
verify the attribute is present on the page).
|
||||||
|
- **Reuse the existing client + session pattern.** Direct provision uses `IAuthApiClient.AdminRegisterAsync`
|
||||||
|
+ `IAuthSession.GetValidTokenAsync` (as SuperRegister does). Do not introduce a new client; do not route
|
||||||
|
direct provision through `PendingRegistrationClient`.
|
||||||
|
- **Match the UserAdmin page conventions.** NewUser/NewRegistration use a `MudContainer` + `MudCard` +
|
||||||
|
back-button layout; keep the rebuilt NewUser in that house style rather than transplanting SuperRegister's
|
||||||
|
bare `MudGrid` layout verbatim.
|
||||||
|
- **No 404s for retired routes.** If SuperRegister is removed, `/account/superregister` must redirect, not
|
||||||
|
break (§3.1.3, §7).
|
||||||
|
- **Versioning:** lands as a normal AuthBlocks Web version bump, packed/pushed via `pack.ps1`. `AuthBlocksWeb`
|
||||||
|
is currently `Cerebellum.AuthBlocks.Web` **10.3.36**; bump to **10.3.37** (or the next free patch if
|
||||||
|
another bump has landed since this brief). **Record the published version** so the consumer can pin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Decisions for the sponsor (Daniel) — resolved 2026-06-20
|
||||||
|
|
||||||
|
1. **Which page is canonical for direct provision?** **DECISION (2026-06-20): New User** (`/useradmin/users/new`)
|
||||||
|
is the canonical direct-provision page, absorbing SuperRegister (§3.1). ✅ approved.
|
||||||
|
2. **What happens to SuperRegister?** **DECISION (2026-06-20): delete + redirect** `/account/superregister` →
|
||||||
|
`/useradmin/users/new`. ✅ approved.
|
||||||
|
3. **Route naming.** **DECISION (2026-06-20):** keep `/useradmin/users/new` as the canonical direct-provision
|
||||||
|
route. No `/account/*` route retained. ✅ approved.
|
||||||
|
4. **Copy/wording on NewUser.** **DECISION (2026-06-20):** go with the recommended strings in §3.1.2 — header
|
||||||
|
"New User — Direct Provision" or "Activate New User"; button "Create Account"; accurate direct-provision body
|
||||||
|
copy. Implementer discretion within that intent. ✅ approved.
|
||||||
|
5. **Tidy the Registration label** ("Provision New User" → "Invite New User"/"New Registration")?
|
||||||
|
**DECISION (2026-06-20):** yes, in scope. ✅ approved.
|
||||||
|
6. **Future chooser page (Alternative D)** — **DECISION (2026-06-20):** deferred; captured as a possible later
|
||||||
|
enhancement. ✅ approved (defer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Acceptance criteria
|
||||||
|
|
||||||
|
1. Navigating to `/useradmin/users/new` shows a **direct-provision form** with Username, Email, Password,
|
||||||
|
Confirm Password, and a Roles multi-select — **no "Send Registration Code" language**, no throwing stub.
|
||||||
|
2. Submitting that form with a valid username/email/password (and optional roles) calls
|
||||||
|
`POST api/auth/admin-register`, creates a **live account immediately** (no email sent, no pending-
|
||||||
|
registration row), assigns the selected roles, shows a success confirmation, and returns to
|
||||||
|
`/useradmin/users` with the new user visible in the grid.
|
||||||
|
3. The page's copy accurately describes direct provisioning; the button reads "Create Account" (or the
|
||||||
|
sponsor-approved string).
|
||||||
|
4. `NewUserForm` no longer binds `PendingRegistrationInputModel` / `PendingRegistrationClient`, no longer
|
||||||
|
contains the `X()` stub or the commented-out invite handler.
|
||||||
|
5. SuperRegister is resolved per the sponsor's decision: if retired, `/account/superregister` **redirects**
|
||||||
|
to `/useradmin/users/new` (no 404, no second working create-now page); if kept as alias, it is explicitly
|
||||||
|
an alias, not an independent duplicate.
|
||||||
|
6. The invite flow at `/useradmin/registrations/new` still works unchanged (emails a code + link); if its
|
||||||
|
label was tidied, the change is cosmetic only.
|
||||||
|
7. Direct provision remains `UserAdmin`-role-gated; an unauthorized user cannot reach it.
|
||||||
|
8. Published as a version bump from 10.3.36 (expected **10.3.37**); the new version number is recorded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Downstream consequence (for the consumer to handle later — NOT this team's work)
|
||||||
|
|
||||||
|
The consuming product (DeepDrftManager) currently surfaces **SuperRegister (`/account/superregister`)** in
|
||||||
|
its CMS navigation as the **"Provision User"** entry, alongside a **Registrations** link. If this
|
||||||
|
normalization changes which page is canonical or its route — specifically if `/account/superregister` is
|
||||||
|
retired in favor of `/useradmin/users/new` — the consumer's nav link will need a small follow-up update
|
||||||
|
**after this AuthBlocks change ships and the consumer bumps its package reference**. The recommended
|
||||||
|
delete-**and-redirect** (§3.1.3, decision §5.2) is precisely to keep that consumer working in the interval
|
||||||
|
between this ship and the consumer's catch-up. **This is noted only so the implementing team understands why
|
||||||
|
the redirect matters; updating the consumer's nav is out of scope for AuthBlocks.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Suggested reading order in the repo
|
||||||
|
|
||||||
|
1. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/NewUserForm.razor` + `.razor.cs` — the broken page; the
|
||||||
|
wrong message, the missing fields, the `X()` stub, the commented-out invite handler. **Start here.**
|
||||||
|
2. `AuthBlocksWeb/Components/Pages/Account/SuperRegister.razor` — the working direct-provision form to
|
||||||
|
absorb (fields, role multi-select, `AdminRegisterAsync` call, `IAuthSession` token pattern).
|
||||||
|
3. `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/NewRegistrationForm.razor` + `.razor.cs` — the
|
||||||
|
invite flow NewUser was wrongly duplicating; the post-submit modal + force-reload nav pattern to mirror.
|
||||||
|
4. `AuthBlocksLib/Routes/AuthRoutes.cs` — the `AdminRegister` endpoint (direct provision; what NewUser must
|
||||||
|
call) and `Register` (public redeem); the `ApiResult`/result conventions.
|
||||||
|
5. `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs` — the `Create` endpoint (invite); confirms the two
|
||||||
|
endpoints are already distinct and correct (no API work needed).
|
||||||
|
6. `AuthBlocksModels/ApiModels/AuthModels.cs` — `AdminRegisterRequest` (UserName, Email, Password,
|
||||||
|
ConfirmPassword, RoleIds) is the model NewUser should bind.
|
||||||
|
7. `AuthBlocksWeb/ApiClients/IAuthApiClient.cs` / `AuthApiClient.cs` — `AdminRegisterAsync` + `GetRolesAsync`.
|
||||||
|
8. `AuthBlocksWeb/AuthBlocksWeb.csproj` — `<Version>10.3.36</Version>` to bump.
|
||||||
|
9. `pack.ps1` — pack/push after the bump; record the published version.
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# Team Brief — BlazorBlocks: Register the Missing `EditModalSaveContextHolder` DI Dependency
|
||||||
|
|
||||||
|
**Audience:** an orchestrator (and its implementers) working **only** in the BlazorBlocks repository at
|
||||||
|
`C:\Development\BlazorBlocks`. You do not need, and should not assume, any knowledge of AuthBlocks or of
|
||||||
|
any product that consumes BlazorBlocks. Everything you need is in this brief or in that one repo.
|
||||||
|
|
||||||
|
**Status:** ✅ RESOLVED — shipped in `Cerebellum.BlazorBlocks.Web` 10.3.33 + `Cerebellum.AuthBlocks.Web` 10.3.36 (2026-06-20). ~~scoped request, not yet started. Confirmed at runtime against `Cerebellum.BlazorBlocks.Web` 10.3.32. Author: product-designer (for a downstream consumer team). Date: 2026-06-19.~~
|
||||||
|
|
||||||
|
> **Resolution (2026-06-20):** `AddBlazorBlocksWeb()` landed in `Cerebellum.BlazorBlocks.Web` 10.3.33 and `ConfigureAuthServices` calls it in `Cerebellum.AuthBlocks.Web` 10.3.36; DeepDrftManager picked up 10.3.36 and removed its local `EditModalSaveContextHolder` stopgap.
|
||||||
|
> This brief is retained as historical record — no further action required.
|
||||||
|
|
||||||
|
**This is one half of a two-team, ordered fix. BlazorBlocks ships first — see §9.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The defect in one sentence
|
||||||
|
|
||||||
|
The BlazorBlocks `ModelView` component (and the `EditModelModal` it drives) has a `required [Inject]`
|
||||||
|
dependency on `Web.Maintenance.Entities.EditModalSaveContextHolder`, but **the `Web` package ships no
|
||||||
|
`IServiceCollection` registration extension at all** — so the dependency is never registered by the
|
||||||
|
library, and any consumer that surfaces a page built on `ModelView` gets an unhandled
|
||||||
|
`InvalidOperationException` that **terminates the Blazor circuit on navigation**, unless that consumer
|
||||||
|
manually hand-registers the internal service in its own `Program.cs`.
|
||||||
|
|
||||||
|
The fix is a BlazorBlocks library fix delivered as a version bump: add a Web-side `Add*` extension that
|
||||||
|
registers the holder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The confirmed failure (downstream symptom — evidence, not your concern to chase)
|
||||||
|
|
||||||
|
The defect was discovered by a consumer navigating to an admin page built on `ModelView`. The captured
|
||||||
|
stack trace is included here as confirming evidence of the unregistered-service failure mode; the
|
||||||
|
consumer's identity and its pages are out of scope for this team — your job is to register the dependency
|
||||||
|
the library requires.
|
||||||
|
|
||||||
|
```
|
||||||
|
System.InvalidOperationException: Cannot provide a value for property 'SaveContextHolder' on type
|
||||||
|
'Web.Maintenance.Entities.ModelView`5[[AuthBlocksModels.InputModels.UserInputModel, ...],
|
||||||
|
[AuthBlocksModels.Models.UserModel, ...],[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UserEditModal, ...],
|
||||||
|
[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UsersViewModel, ...],
|
||||||
|
[AuthBlocksModels.Converters.UserModelToInputConverter, ...]]'.
|
||||||
|
There is no registered service of type 'Web.Maintenance.Entities.EditModalSaveContextHolder'.
|
||||||
|
at Microsoft.AspNetCore.Components.ComponentFactory...CreatePropertyInjector...
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `SaveContextHolder` is declared `required` with `[Inject]`, Blazor's component factory throws
|
||||||
|
during component activation. There is no try/catch around component instantiation in the render path, so
|
||||||
|
the exception propagates and tears down the circuit. The end user sees a dead page / "An unhandled error
|
||||||
|
has occurred" and must reload.
|
||||||
|
|
||||||
|
The tell that this is a library gap and not a consumer mistake: **two independent downstream products**
|
||||||
|
have each had to hand-register the *same* internal BlazorBlocks service
|
||||||
|
(`AddScoped<Web.Maintenance.Entities.EditModalSaveContextHolder>()`) in their own `Program.cs` to make
|
||||||
|
`ModelView`-based pages work. When two unrelated consumers independently discover they must reach into a
|
||||||
|
library's internal namespace (`Web.Maintenance.Entities`) and register a type the library never documented
|
||||||
|
as a consumer responsibility, that is a leaked registration. The correct owner of the registration is the
|
||||||
|
library — this team.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Root-cause findings (from reading the source)
|
||||||
|
|
||||||
|
### 3.1 The service and who needs it
|
||||||
|
|
||||||
|
`EditModalSaveContextHolder` (`C:\Development\BlazorBlocks\Web\Maintenance\Entities\EditModalSaveContextHolder.cs`)
|
||||||
|
is a tiny per-circuit slot:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class EditModalSaveContextHolder
|
||||||
|
{
|
||||||
|
public IEditModalSaveContext? Current { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is injected as `required` in **two** library components:
|
||||||
|
|
||||||
|
- `Web/Maintenance/Entities/ModelView.razor.cs:42` — sets `SaveContextHolder.Current` before opening the
|
||||||
|
edit dialog and clears it in a `finally` (see `EditItem`, lines 137–172).
|
||||||
|
- `Web/Maintenance/Entities/EditModelModal.razor:40` — reads `SaveContextHolder.Current` to obtain the
|
||||||
|
typed save callback (`SaveContext`).
|
||||||
|
|
||||||
|
The holder is deliberately the **per-circuit bridge** between these two components — it threads a save
|
||||||
|
closure from the page-side `ModelView` into the generic `EditModelModal` without forcing a parameter
|
||||||
|
through every per-page modal wrapper. So **both** components fail without it; the bug surfaces at
|
||||||
|
`ModelView` activation simply because that component is constructed first.
|
||||||
|
|
||||||
|
### 3.2 Is there already a Web-side registration extension to fix?
|
||||||
|
|
||||||
|
**No Web-side registration extension exists.** A full scan of BlazorBlocks for `IServiceCollection`
|
||||||
|
extension methods finds only:
|
||||||
|
|
||||||
|
- `Data.Postgres/ServiceCollectionExtensions.cs` → `AddBlazorBlocksPostgres()` (registers
|
||||||
|
`IDbExceptionClassifier`; a data-layer concern, unrelated to the Web components).
|
||||||
|
- `API/Errors/...AddResultMessagePolymorphism(...)` (a JSON resolver helper, not DI).
|
||||||
|
|
||||||
|
There is **no** `AddBlazorBlocks()` / `AddBlazorBlocksWeb()` / `AddMaintenance()` method in the `Web`
|
||||||
|
project (`Cerebellum.BlazorBlocks.Web`). So `EditModalSaveContextHolder` is not "missing from an existing
|
||||||
|
extension" — there is no Web-side extension at all. The library ships components with a hard DI dependency
|
||||||
|
and provides no entry point to register that dependency.
|
||||||
|
|
||||||
|
### 3.3 Is `EditModalSaveContextHolder` the *only* missing dependency?
|
||||||
|
|
||||||
|
**It is the only library-owned missing registration.** Every `[Inject]` across the BlazorBlocks
|
||||||
|
`Web/Maintenance` tree was enumerated:
|
||||||
|
|
||||||
|
| Component | Injected types |
|
||||||
|
|---|---|
|
||||||
|
| `ModelView<...>` | `TViewModel` (consumer), `NavigationManager` (framework), `IDialogService` (MudBlazor), `ISnackbar` (MudBlazor), **`EditModalSaveContextHolder` (BlazorBlocks — UNREGISTERED)** |
|
||||||
|
| `EditModelModal<TModel>` | `ISnackbar` (MudBlazor), **`EditModalSaveContextHolder` (BlazorBlocks — UNREGISTERED)** |
|
||||||
|
| `NewModelView<...>` | `TClient` (consumer), `NavigationManager` (framework), `IDialogService` (MudBlazor), `ISnackbar` (MudBlazor) |
|
||||||
|
|
||||||
|
Everything else resolves through services consumers already register:
|
||||||
|
|
||||||
|
- `NavigationManager` — Blazor framework.
|
||||||
|
- `IDialogService`, `ISnackbar` — MudBlazor, registered by the consumer's `AddMudServices()` (a documented
|
||||||
|
MudBlazor prerequisite).
|
||||||
|
- `TViewModel` / `TClient` — the per-entity viewmodel/client, registered by the consumer (or by a
|
||||||
|
higher-level library) for its own entities.
|
||||||
|
|
||||||
|
So `EditModalSaveContextHolder` is the single library-owned gap. Registering the one holder closes the
|
||||||
|
whole set, provided the consumer has already called `AddMudServices()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The fix
|
||||||
|
|
||||||
|
Add a new `IServiceCollection` extension in the `Web` project that registers the maintenance components'
|
||||||
|
library-owned dependencies. This is where `EditModalSaveContextHolder` belongs — it is BlazorBlocks'
|
||||||
|
internal bridge, and any product using `ModelView` needs it.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// C:\Development\BlazorBlocks\Web\ServiceCollectionExtensions.cs (new)
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions; // for TryAddScoped
|
||||||
|
using Web.Maintenance.Entities;
|
||||||
|
|
||||||
|
namespace Web;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the services required by the BlazorBlocks maintenance UI
|
||||||
|
/// components (ModelView / EditModelModal / NewModelView). Call this in
|
||||||
|
/// your application's DI setup when using those components.
|
||||||
|
/// Note: MudBlazor (AddMudServices) is a separate, caller-owned prerequisite
|
||||||
|
/// and is intentionally NOT registered here.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddBlazorBlocksWeb(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
// Per-circuit slot bridging ModelView's save callback into EditModelModal.
|
||||||
|
// Scoped per circuit; see lifetime rationale. TryAddScoped => idempotent.
|
||||||
|
services.TryAddScoped<EditModalSaveContextHolder>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The method should be the single place the maintenance components' library-owned deps are registered, so
|
||||||
|
any future `ModelView` dependency is added here once and every consumer picks it up on the next bump.
|
||||||
|
|
||||||
|
### Lifetime rationale — scoped, not singleton, not transient
|
||||||
|
|
||||||
|
The extension must register the holder as **scoped** (`AddScoped` / `TryAddScoped`):
|
||||||
|
|
||||||
|
- **Why not singleton:** the holder is per-circuit mutable state — `ModelView.EditItem` writes `Current`
|
||||||
|
immediately before opening the dialog and nulls it in `finally`; `EditModelModal` reads it. A singleton
|
||||||
|
would share one slot across all users/circuits and cross-contaminate concurrent edits — a
|
||||||
|
correctness/security bug.
|
||||||
|
- **Why not transient:** a transient would hand `ModelView` and `EditModelModal` *different* instances, so
|
||||||
|
the modal would never see the context the view set. The bridge would silently no-op and edits would fall
|
||||||
|
back to the legacy "close with model" path (the `SaveContext is null` branch in `EditModelModal.Submit`).
|
||||||
|
This is the dangerous failure mode: the page loads fine, but saves quietly take the wrong path.
|
||||||
|
- The type's own XML doc states it is "Scoped per circuit." `AddScoped` in Blazor Server = one instance
|
||||||
|
per circuit, which is exactly the intended semantics.
|
||||||
|
|
||||||
|
Document the scoped requirement in the extension's summary so it is not "tidied" to singleton later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Constraints
|
||||||
|
|
||||||
|
- **Use scoped** for `EditModalSaveContextHolder` — not singleton, not transient (§4).
|
||||||
|
- **Do not change `ModelView` / `EditModelModal` to drop the `required`/`[Inject]`** — the holder is the
|
||||||
|
intentional design (the bridge described in §3.1, and in the type's own doc comment). The fix is to
|
||||||
|
*register* the dependency, not to remove it.
|
||||||
|
- **MudBlazor remains a caller-owned prerequisite.** `AddBlazorBlocksWeb` must **not** call
|
||||||
|
`AddMudServices()` — consumers configure MudBlazor (theme, snackbar options) themselves, and
|
||||||
|
double-registration would clobber their config. Document `AddMudServices` as a prerequisite in the
|
||||||
|
extension's summary; do not absorb it.
|
||||||
|
- **Versioning:** the `Web` package packs/pushes via `C:\Development\BlazorBlocks\pack.ps1`. `Web` is
|
||||||
|
currently `Cerebellum.BlazorBlocks.Web` **10.3.32**; bump to the next version and pack/push. **Record the
|
||||||
|
new version number** — the AuthBlocks team needs it to reference (see §9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Acceptance criteria
|
||||||
|
|
||||||
|
1. The `Web` project exposes a new `IServiceCollection` extension (`AddBlazorBlocksWeb()` or the confirmed
|
||||||
|
house name) that registers `EditModalSaveContextHolder` as **scoped**, with a summary documenting the
|
||||||
|
scoped requirement and the `AddMudServices` prerequisite.
|
||||||
|
2. A product that uses BlazorBlocks `ModelView` (with or without any higher-level library) can register
|
||||||
|
the maintenance deps via a single call to the new extension and gets working behavior.
|
||||||
|
3. Working behavior means not just page load: opening an edit dialog and **saving a valid change
|
||||||
|
succeeds** — i.e. the save bridge actually works (the holder is scoped correctly so `EditModelModal`
|
||||||
|
sees the context `ModelView` set). A page that loads but silently no-ops the save (the transient-lifetime
|
||||||
|
trap in §4) does **not** pass.
|
||||||
|
4. The `Web` package is published as a version bump from 10.3.32, and the new version number is recorded
|
||||||
|
for the AuthBlocks team.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open questions for the implementing team / its sponsor
|
||||||
|
|
||||||
|
1. **Extension method name.** `AddBlazorBlocksWeb()` is proposed for consistency with the existing
|
||||||
|
`AddBlazorBlocksPostgres()`. Confirm, or pick the preferred house name (e.g.
|
||||||
|
`AddBlazorBlocksMaintenance()` if the team expects to split Web concerns further). *Recommendation:
|
||||||
|
`AddBlazorBlocksWeb()`.*
|
||||||
|
2. **Idempotency.** Use `TryAddScoped` (recommended) so a consumer that calls the extension directly *and*
|
||||||
|
via a higher-level library's setup gets a no-op on the second call rather than a duplicate registration.
|
||||||
|
3. **Scope of the extension.** Register only `EditModalSaveContextHolder` now (the only current gap), or
|
||||||
|
pre-emptively make `AddBlazorBlocksWeb()` the home for *all* future maintenance-component deps?
|
||||||
|
*Recommendation: ship it with just the holder now, but frame it (name + doc) as the general home so
|
||||||
|
future deps land in one place — no speculative registrations.*
|
||||||
|
4. **Other unregistered library-owned `[Inject]` deps elsewhere?** This brief scoped the audit to the
|
||||||
|
`Web/Maintenance` tree. If the team wants a clean bill of health, grep the whole `Web` project for
|
||||||
|
`[Inject]` of BlazorBlocks-owned types and fold any others into the same extension. *Recommendation: do
|
||||||
|
the quick full-project grep while you are in here; cheap insurance.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Suggested reading order in the repo
|
||||||
|
|
||||||
|
1. `Web/Maintenance/Entities/EditModalSaveContextHolder.cs` — the unregistered service (and its scoped doc).
|
||||||
|
2. `Web/Maintenance/Entities/ModelView.razor.cs` — `[Inject]` at line 42; `EditItem` (137–172) shows the
|
||||||
|
holder being written/cleared around the dialog.
|
||||||
|
3. `Web/Maintenance/Entities/EditModelModal.razor` — `[Inject]` at line 40; `Submit` shows the holder being
|
||||||
|
read (and the `SaveContext is null` fallback that masks the bug into a wrong-behavior path if the
|
||||||
|
lifetime is botched).
|
||||||
|
4. `Data.Postgres/ServiceCollectionExtensions.cs` — the existing `Add*` convention to mirror.
|
||||||
|
5. `Web/Web.csproj` — package id `Cerebellum.BlazorBlocks.Web`, current version (10.3.32), where to add the
|
||||||
|
new `ServiceCollectionExtensions.cs`.
|
||||||
|
6. `pack.ps1` — the pack/push flow for the version bump.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Cross-team ordering (important — you ship first)
|
||||||
|
|
||||||
|
This fix is layered across two repos and **must land in order**:
|
||||||
|
|
||||||
|
1. **BlazorBlocks (this team) ships first.** Add `AddBlazorBlocksWeb()`, bump `Cerebellum.BlazorBlocks.Web`
|
||||||
|
from 10.3.32, pack/push, and **report the new version number**.
|
||||||
|
2. **Then AuthBlocks** bumps its `Cerebellum.BlazorBlocks.Web` reference to the version this team just
|
||||||
|
published and calls `AddBlazorBlocksWeb()` from its own setup entry point.
|
||||||
|
|
||||||
|
The AuthBlocks team **cannot complete its part until this team's new version is published**. This team is
|
||||||
|
not blocked by anyone — just ship the extension, bump the version, and hand off the new version number.
|
||||||
|
You do not need to touch or know anything about AuthBlocks.
|
||||||
Reference in New Issue
Block a user