Compare commits
121 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 | |||
| 5298cab9b1 | |||
| e05d93a67b | |||
| fd4fdd2624 | |||
| 639f4741e6 | |||
| d7071fdbc2 | |||
| 37cf19c405 | |||
| 37bbfb947f |
+4
-2
@@ -317,5 +317,7 @@ Database/Vaults/*
|
||||
!DeepDrftPublic.Client/wwwroot/js/*.js
|
||||
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest;
|
||||
# gitignored TS output is absent when manifest is generated, so absent from publish output.
|
||||
!DeepDrftShared.Client/wwwroot/js/parallax/
|
||||
!DeepDrftShared.Client/wwwroot/js/knob/
|
||||
# Re-include the whole RCL js/ tree so every compiled module (parallax, knob, theme, and
|
||||
# any added later) ships, rather than maintaining a per-module allowlist.
|
||||
!DeepDrftShared.Client/wwwroot/js/
|
||||
!DeepDrftShared.Client/wwwroot/js/**
|
||||
@@ -9,8 +9,8 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
|
||||
### Core Projects
|
||||
|
||||
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
|
||||
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. Consumed by the public site.
|
||||
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. 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.
|
||||
- **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`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release.
|
||||
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
|
||||
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
|
||||
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles (512-bucket seeker + per-track high-res visualizer datum in the `track-waveforms` vault), release-track join operations, `POST api/track/duration/backfill` (ApiKey-gated one-time backfill of `DurationSeconds` for existing rows from vault audio). Stats endpoints: `GET api/stats/home` (unauthenticated; returns `HomeStatsDto` with cut track count, per-`ReleaseType` cut release counts, mix release count, and total mix runtime seconds). Release endpoints: paged list with medium filter, single read, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
|
||||
@@ -76,17 +76,20 @@ Keep this seam clean — it is the most architecturally load-bearing part of the
|
||||
|
||||
### 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.
|
||||
- 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).
|
||||
- **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`.
|
||||
|
||||
### 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).
|
||||
|
||||
**`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
|
||||
|
||||
@@ -126,7 +129,7 @@ All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitign
|
||||
|
||||
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
|
||||
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 1200 — budget for server-side persist after the body is fully sent).
|
||||
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
|
||||
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Non-secret upload tunable: `Upload:StagingPath` (default empty → a `staging` subdirectory under the FileDatabase vault path) — the data-disk directory where large audio bodies are staged during upload/replace-audio, kept off the system temp mount (`/tmp` is a small tmpfs on the Linux host); `Startup` also points the framework's multipart buffer here via `ASPNETCORE_TEMP`. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
|
||||
|
||||
## Folder-Level Guidance
|
||||
|
||||
|
||||
+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.
|
||||
- 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 `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.
|
||||
|
||||
@@ -79,7 +79,7 @@ Concretely, from reading the library source:
|
||||
|
||||
- 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`).
|
||||
- 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`).
|
||||
- **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)
|
||||
|
||||
**Landed:** 2026-06-19 on dev.
|
||||
|
||||
@@ -16,7 +16,7 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), release
|
||||
- `Controllers/TrackController.cs`: Track endpoints (see below).
|
||||
- `Controllers/ReleaseController.cs`: Release endpoints (see below).
|
||||
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic (for track endpoints only).
|
||||
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
|
||||
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`, `UploadSettings`, `UploadStagingDirectory`). No domain code.
|
||||
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
|
||||
- `environment/apikey.json`: API key for track endpoints (loaded via CredentialTools, not in repo, must be created locally or at deployment).
|
||||
- `environment/connections.json`: SQL and Auth connection strings (loaded via CredentialTools, not in repo, format: `{ "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } }`).
|
||||
@@ -168,8 +168,8 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
|
||||
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized.
|
||||
- `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
|
||||
- `releaseId` (long?, optional): the SQL release ID to attach this track to. Omit (null) on the first row of a submit — this is the **CREATE path**, which mints a new release and blocks a pre-existing (title, artist) with 409. Set to the release id returned by row 1 for rows 2..N of a within-batch multi-track Cut — this is the **ATTACH path**, which skips the (title, artist) pre-existing check and attaches directly to the already-created release after validating the id matches the natural key. The upload form is create-only; appending to a pre-existing release must go through the edit tools.
|
||||
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- The upload stream is copied to a staging file under the **upload staging directory** (resolved from `Upload:StagingPath`, defaulting to a `staging` subdirectory under the FileDatabase vault path — on the data disk, **never** `Path.GetTempPath()`) with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The staging file is always deleted in a `finally` block — success or failure. The framework's own multipart file-section buffer is relocated off the system temp mount too: `Startup.ConfigureDomainServices` sets the `ASPNETCORE_TEMP` env var to the same staging directory, so neither on-disk copy of a large body lands on `/tmp` (a small RAM-backed tmpfs on the Linux host).
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the staging file, not buffered in memory.
|
||||
- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching.
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 for two distinct domain conditions: a pre-existing (title, artist) duplicate on the CREATE path (`DUPLICATE_RELEASE:` marker → 409 Conflict), or a track-number conflict within the release (`CARDINALITY_VIOLATION:` marker → 409 Conflict). Returns 500 if processing fails.
|
||||
|
||||
@@ -189,7 +189,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL track ID.
|
||||
- **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a temp file (correct extension preserved for the audio processor), always deleted in a `finally` block.
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a staging file under the upload staging directory (the same off-`/tmp` data-disk location as the upload path; correct extension preserved for the audio processor), always deleted in a `finally` block.
|
||||
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
|
||||
- Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails.
|
||||
|
||||
@@ -379,6 +379,7 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
|
||||
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
|
||||
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
|
||||
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
|
||||
6a. **Upload staging directory** — resolve and create the on-disk staging directory (read `Upload:StagingPath`; if empty, default to a `staging` subdirectory under the FileDatabase vault path via `Startup.ResolveStagingPath`). Set the `ASPNETCORE_TEMP` env var to this directory before any request is served, relocating the framework's multipart file-section buffer (Layer 1) off the system temp mount. Register `UploadStagingDirectory` as a singleton so both `UploadTrack` and `ReplaceAudio` in `TrackController` stage to the same data-disk location (Layer 2) and never write to `/tmp` (a small RAM-backed tmpfs on the Linux host).
|
||||
|
||||
**In `Program.cs`** (SQL + AuthBlocks + wiring):
|
||||
|
||||
@@ -401,8 +402,9 @@ Mapped in `Development` only. Swagger UI at `/swagger` for testing endpoints loc
|
||||
|
||||
## Configuration files
|
||||
|
||||
- `appsettings.json`: Logging, hosting, CORS, and AuthBlocks config. **Does not contain secrets.**
|
||||
- `appsettings.json`: Logging, hosting, CORS, AuthBlocks, and non-secret upload config. **Does not contain secrets.**
|
||||
- `Logging`: standard ASP.NET structure.
|
||||
- `Upload:StagingPath`: non-secret string. Empty default → a `staging` subdirectory under the FileDatabase vault path (on the data disk). Override to an absolute path when the vault default is not suitable. Consumed by `Startup.ResolveStagingPath`.
|
||||
- `CorsSettings.AllowedOrigins`: array of origin URLs allowed to call the API (required; throws on startup if missing).
|
||||
- `AuthBlocks:Jwt:Issuer`, `AuthBlocks:Jwt:Audience`: JWT validation settings (loaded from `environment/authblocks.json`).
|
||||
- `environment/filedatabase.json` (required, loaded via CredentialTools, not in repo):
|
||||
|
||||
@@ -20,6 +20,7 @@ public class TrackController : ControllerBase
|
||||
private readonly UnifiedTrackService _unifiedService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly UploadStagingDirectory _stagingDirectory;
|
||||
private readonly ILogger<TrackController> _logger;
|
||||
|
||||
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
|
||||
@@ -34,6 +35,7 @@ public class TrackController : ControllerBase
|
||||
UnifiedTrackService unifiedService,
|
||||
ITrackService sqlTrackService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
UploadStagingDirectory stagingDirectory,
|
||||
ILogger<TrackController> logger)
|
||||
{
|
||||
_trackContentService = trackContentService;
|
||||
@@ -41,9 +43,48 @@ public class TrackController : ControllerBase
|
||||
_unifiedService = unifiedService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_stagingDirectory = stagingDirectory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Builds a unique staging file path on the data disk with the validated extension. The caller MUST
|
||||
// assign this to the local that its finally block guards BEFORE calling StageUploadAsync — that
|
||||
// way a mid-copy abort (OperationCanceledException, IO error) still triggers deletion of the
|
||||
// partially-written file. Staging lives under UploadStagingDirectory, never Path.GetTempPath() —
|
||||
// on the Linux host /tmp is a small tmpfs that cannot hold a large WAV.
|
||||
private string BuildStagingPath(string uploadExtension) =>
|
||||
Path.Combine(_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);
|
||||
|
||||
// Streams an uploaded audio body to the pre-allocated staging path. The caller owns the path and
|
||||
// must delete it in a finally block; separating path generation from the copy ensures the finally
|
||||
// guard fires even when CopyToAsync throws before returning.
|
||||
private async Task StageUploadAsync(
|
||||
IFormFile audioFile, string stagingPath, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stagingStream = new FileStream(
|
||||
stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true);
|
||||
await using var uploadStream = audioFile.OpenReadStream();
|
||||
await uploadStream.CopyToAsync(stagingStream, cancellationToken);
|
||||
}
|
||||
|
||||
// Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
|
||||
// disk-hygiene concern, not a request failure.
|
||||
private void DeleteStagingFile(string stagingPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(stagingPath))
|
||||
{
|
||||
System.IO.File.Delete(stagingPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Literal-segment routes first ---
|
||||
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
|
||||
// resolution never treats "page", "upload", or "meta" as a trackId.
|
||||
@@ -319,23 +360,15 @@ public class TrackController : ControllerBase
|
||||
|
||||
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
|
||||
|
||||
// The processor router selects by extension and reads from disk, so the temp file must carry
|
||||
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
|
||||
// generate our own path preserving the validated .wav/.mp3/.flac extension.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
|
||||
|
||||
// Build the staging path before the copy so the finally block can delete the partial file
|
||||
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
|
||||
var stagingPath = BuildStagingPath(uploadExtension);
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
await using (var uploadStream = audioFile.OpenReadStream())
|
||||
{
|
||||
await uploadStream.CopyToAsync(tempStream, cancellationToken);
|
||||
}
|
||||
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
|
||||
|
||||
var result = await _unifiedService.UploadAsync(
|
||||
tempPath,
|
||||
stagingPath,
|
||||
trackName,
|
||||
artist,
|
||||
string.IsNullOrWhiteSpace(album) ? null : album,
|
||||
@@ -381,17 +414,7 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath))
|
||||
{
|
||||
System.IO.File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
|
||||
}
|
||||
DeleteStagingFile(stagingPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,21 +590,14 @@ public class TrackController : ControllerBase
|
||||
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
|
||||
}
|
||||
|
||||
// The processor router selects by extension and reads from disk, so the temp file must carry
|
||||
// the upload's real extension. Mirrors UploadTrack — Path.GetTempFileName() yields .tmp.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
|
||||
|
||||
// Build the staging path before the copy so the finally block can delete the partial file
|
||||
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
|
||||
var stagingPath = BuildStagingPath(uploadExtension);
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
await using (var uploadStream = audioFile.OpenReadStream())
|
||||
{
|
||||
await uploadStream.CopyToAsync(tempStream, cancellationToken);
|
||||
}
|
||||
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
|
||||
|
||||
var result = await _unifiedService.ReplaceAudioAsync(id, tempPath, cancellationToken);
|
||||
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
|
||||
@@ -604,17 +620,7 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath))
|
||||
{
|
||||
System.IO.File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ReplaceAudio: failed to delete temp file {TempPath}", tempPath);
|
||||
}
|
||||
DeleteStagingFile(stagingPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
|
||||
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
|
||||
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" />
|
||||
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.39" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace DeepDrftAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Non-secret upload tunables. <see cref="StagingPath"/> is the directory used to stage the raw
|
||||
/// audio body during upload/replace-audio. It must live on the data disk, never the system temp
|
||||
/// mount (on the Linux host <c>/tmp</c> is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB
|
||||
/// WAV). When null/empty it defaults to a "staging" subdirectory under the FileDatabase vault path.
|
||||
/// </summary>
|
||||
public class UploadSettings
|
||||
{
|
||||
public string? StagingPath { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DeepDrftAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolved, on-disk staging directory for upload/replace-audio bodies. Resolved once at
|
||||
/// startup from <see cref="UploadSettings"/> (or the vault path default) and guaranteed to exist.
|
||||
/// Injected into <c>TrackController</c> so the upload path never stages on the system temp mount.
|
||||
/// A typed wrapper rather than a bare string so DI resolves it unambiguously.
|
||||
/// </summary>
|
||||
public sealed record UploadStagingDirectory(string Path);
|
||||
}
|
||||
@@ -107,6 +107,9 @@ builder.Services.AddAuthBlocks(options =>
|
||||
?? throw new InvalidOperationException("AuthBlocks:Email:Host is required");
|
||||
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
|
||||
?? 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
|
||||
{
|
||||
|
||||
@@ -47,9 +47,41 @@ namespace DeepDrftAPI
|
||||
return db;
|
||||
});
|
||||
|
||||
// Upload staging directory. Large audio bodies (multi-hundred-MB WAVs) must never stage on
|
||||
// the system temp mount — on the Linux host /tmp is a small RAM-backed tmpfs. We move BOTH
|
||||
// on-disk copies of an upload off /tmp onto the data disk:
|
||||
// Layer 1 — the framework's multipart file-section buffer (FileBufferingReadStream), which
|
||||
// reads its directory from the ASPNETCORE_TEMP env var (falling back to
|
||||
// Path.GetTempPath()). Setting the var here, before the host runs, relocates it.
|
||||
// Layer 2 — the controller's own staging file, via the injected UploadStagingDirectory.
|
||||
// Default location is a "staging" subdirectory beside the vaults; override with
|
||||
// Upload:StagingPath in appsettings.json.
|
||||
var uploadSettings = builder.Configuration.GetSection("Upload").Get<UploadSettings>();
|
||||
var stagingPath = ResolveStagingPath(uploadSettings?.StagingPath, vaultPath);
|
||||
Directory.CreateDirectory(stagingPath);
|
||||
|
||||
// AspNetCoreTempDirectory caches this value on first read and throws if the directory is
|
||||
// absent, so set it (and create the dir) before any request is served.
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath);
|
||||
builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the absolute upload-staging directory. An explicit <paramref name="configuredPath"/>
|
||||
/// (from <c>Upload:StagingPath</c>) wins; otherwise it defaults to a <c>staging</c> subdirectory
|
||||
/// under <paramref name="vaultPath"/> — on the data disk, never the system temp mount. Pure so
|
||||
/// the "never <c>/tmp</c>" invariant is unit-testable without standing up the host.
|
||||
/// </summary>
|
||||
public static string ResolveStagingPath(string? configuredPath, string vaultPath)
|
||||
{
|
||||
var path = string.IsNullOrWhiteSpace(configuredPath)
|
||||
? Path.Combine(vaultPath, "staging")
|
||||
: configuredPath;
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.Tracks))
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Upload": {
|
||||
"StagingPath": ""
|
||||
},
|
||||
"CorsSettings": {
|
||||
"AllowedOrigins": [
|
||||
"https://localhost:12778",
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
},
|
||||
"Email": {
|
||||
"Host": "smtp.your-provider.com",
|
||||
"Token": "your-email-token-here"
|
||||
"Token": "your-email-token-here",
|
||||
"From": "noreply@yourdomain.com",
|
||||
"TestInbox": "<sandbox-id>"
|
||||
},
|
||||
"Admin": {
|
||||
"UserName": "admin",
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
</PackageReference>
|
||||
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.30" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.30" />
|
||||
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.35" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.35" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,9 +6,23 @@
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
|
||||
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
|
||||
Deep Drft — Admin
|
||||
</MudText>
|
||||
<a href="/" class="mx-2">
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
<MudImage Src="img/deepdrft-logo-l.webp"
|
||||
Alt="Deep Drft Ornamental Logo Left"
|
||||
Width="24"
|
||||
Height="24 "
|
||||
Style="filter: invert(1);"/>
|
||||
|
||||
<MudText Typo="Typo.button" Style="color: var(--deepdrft-white);">Deep DRFT Management</MudText>
|
||||
|
||||
<MudImage Src="img/deepdrft-logo-r.webp"
|
||||
Alt="Deep Drft Ornamental Logo Right"
|
||||
Width="24"
|
||||
Height="24"
|
||||
Style="filter: invert(1);"/>
|
||||
</MudStack>
|
||||
</a>
|
||||
</MudAppBar>
|
||||
<MudMainContent>
|
||||
<MudContainer MaxWidth="MaxWidth.Small"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using DeepDrftShared.Client.Common
|
||||
@using AuthBlocksWeb.Components.Layout
|
||||
|
||||
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
|
||||
<MudPopoverProvider />
|
||||
@@ -8,9 +9,27 @@
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
|
||||
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
|
||||
Deep Drft — Admin
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu"
|
||||
Color="Color.Inherit"
|
||||
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 />
|
||||
<MudTooltip Text="Catalogue">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Home"
|
||||
@@ -18,6 +37,20 @@
|
||||
Color="Color.Inherit" />
|
||||
</MudTooltip>
|
||||
</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">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
||||
@Body
|
||||
@@ -25,6 +58,12 @@
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
|
||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||
}
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@page "/404"
|
||||
|
||||
<PageTitle>SkipperHaven - Page Not Found</PageTitle>
|
||||
<PageTitle>Deep DRFT Management - Page Not Found</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h1" Color="Color.Primary">
|
||||
404 - Resource Not Found
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@page "/"
|
||||
@layout Layout.CmsHomeLayout
|
||||
|
||||
<PageTitle>Deep Drft — Admin</PageTitle>
|
||||
<PageTitle>Deep DRFT Management</PageTitle>
|
||||
|
||||
<HierarchicalRoleAuthorizeView>
|
||||
<Authorized>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject ILogger<Index> Logger
|
||||
|
||||
<PageTitle>DeepDrft CMS</PageTitle>
|
||||
<PageTitle>Deep DRFT Management - Catalogue</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
@inject IDialogService DialogService
|
||||
@inject ILogger<BatchEdit> Logger
|
||||
|
||||
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
|
||||
<PageTitle>Edit Release — Deep DRFT Management</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<BatchUpload> Logger
|
||||
|
||||
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
|
||||
<PageTitle>Upload Release — Deep DRFT Management</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
|
||||
@@ -129,6 +129,11 @@
|
||||
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
|
||||
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 _artist = 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
|
||||
// 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.
|
||||
@@ -275,13 +293,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
if (_createdByUserId is not long createdByUserId)
|
||||
{
|
||||
// The page is gated by [Authorize] under the Admin role, so a missing or
|
||||
// unparseable id here is a configuration bug, not normal client state.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
// _createdByUserId is set at component initialization from the authenticated principal.
|
||||
// A null here means the id was unavailable even at load — a genuine configuration bug,
|
||||
// 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.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
|
||||
<PageTitle>Mixes — Deep DRFT Management</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
|
||||
<PageTitle>Sessions — Deep DRFT Management</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Releases — DeepDrft CMS</PageTitle>
|
||||
<PageTitle>Releases — Deep DRFT Management</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
NotFoundPage="typeof(NotFound)">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData"
|
||||
DefaultLayout="typeof(Layout.CmsLayout)">
|
||||
DefaultLayout="@_currentLayout">
|
||||
<NotAuthorized Context="authState">
|
||||
@if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
@@ -18,3 +18,20 @@
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
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>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.30" />
|
||||
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
|
||||
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.35" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -10,17 +10,18 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
|
||||
## Actual structure
|
||||
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`** — `PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `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).
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"` with `.dd-detail-fill` so the ambient visualizer reads full-screen and the footer is pushed below the fold; **does not compose `ReleaseDetailScaffold`** — `PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; **Phase 20:** top action row carries `<TheaterModeToggle Available="ShowTheaterToggle" />` immediately left of the lava-lamp popover in a `.dd-detail-top-actions` cluster — the toggle only appears when this page's release is the one currently playing (`ShowTheaterToggle` from `ReleaseDetailBase` folds in the subsystem gate + release-playing check); hero overlay and `<ReleaseDescription>` are wrapped in a `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` pair that gets `.dd-theater-collapsed` when `IsContentHidden` is true — eased collapse via `grid-template-rows: 1fr → 0fr` + `opacity` + `visibility` (no hard `@if` pop); renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; the foreground container carries `.dd-detail-fill`; renders `<ReleaseDescription>` below the hero for the release's description blurb; **Phase 20:** `TopRightAction` slot holds `<TheaterModeToggle Available="ShowTheaterToggle" />` + lava-lamp popover in a `.dd-detail-top-actions` cluster — toggle only appears when this Mix is the playing release; hero overlay and description are wrapped in `.dd-theater-collapsible` / `.dd-theater-collapsed` eased collapse driven by `IsContentHidden`), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; the scaffold is wrapped in a `.dd-detail-fill` div; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`; **Phase 20:** `TopRightAction` slot holds `<TheaterModeToggle Available="ShowTheaterToggle" />` + lava-lamp popover in a `.dd-detail-top-actions` cluster — toggle only appears when this Cut is the playing release; header and track-list body are each wrapped in a `.dd-theater-collapsible` / `.dd-theater-collapsed` eased collapse driven by `IsContentHidden`, replacing the prior hard `@if`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list), `DeepDrftFooter.razor` (site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal via `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) carrying the anonymous-listener privacy note; trigger-button styling in the co-located `DeepDrftFooter.razor.css`, overlay chrome in the global `deepdrft-styles.css`; follows the `QueueOverlay`/`WaveformVisualizerControlPopover` `MudOverlay` idiom — scrim-click closes, panel stops propagation).
|
||||
- `Controls/`: Reusable components.
|
||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
|
||||
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
|
||||
- `AppNavLink.razor`: Nav link with active-page highlight.
|
||||
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
|
||||
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored).
|
||||
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming — routes through `IQueueService.PlayTrack` (deque PLAY semantics) when the queue cascade is present, falls back to `IStreamingPlayerService.SelectTrackStreaming` when absent. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored). **Phase 20:** injects `WaveformVisualizerControlState` and subscribes to `Changed` (added alongside the existing `IPlayerService.StateChanged` subscription — same reference-guard + dispose pattern); mounts `<NowShowingPanel Release="CurrentTrack.Release" />` above the transport controls when `CurrentTrack?.Release is not null` — the panel is kept **always mounted** whenever a release is playing and wrapped in the shared `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` pair; it gets `.dd-theater-collapsed` when Theater Mode is OFF, so the bar grows/shrinks via the same eased collapse that the detail-page content regions use rather than popping via `@if` (Phase 20 Wave 2).
|
||||
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
|
||||
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
|
||||
- `AudioPlayerBar/NowShowingPanel.razor`: Phase 20 "now showing" presentational band rendered by `AudioPlayerBar` **only when** `VisualizerControlState.TheaterMode && CurrentTrack?.Release is not null`. Carries the release identity the hidden detail page would otherwise show: cover art thumbnail (`deepdrft-track-detail-cover-art` / `deepdrft-gradient-soft-secondary` placeholder), release title linked via `ReleaseRoutes.DetailHref(Release)`, and a release-mode `SharePopover` (`ReleaseEntryKey` + `ReleaseMedium`) wrapped in `.dd-accent-icon`. `[Parameter, EditorRequired] ReleaseDto Release` — non-null by the bar's mount gate. Purely presentational: owns no player logic, no Theater state, and no data fetch. Layout CSS lives in `AudioPlayerBar.razor.css` (`.now-showing` / `.now-showing-cover` / `.now-showing-cover-art` / `.now-showing-cover-placeholder` / `.now-showing-title-link` / `.now-showing-title` / `.now-showing-share`); all surface/text binds `--deepdrft-page-*` theme-aware aliases — no new dark overrides.
|
||||
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
|
||||
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0–100%), with fixed three-zone gradient (green 0–60%, yellow 60–85%, orange 85–100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
|
||||
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
|
||||
@@ -29,6 +30,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `WaveformVisualizer.razor`: The single WebGL2 lava-lamp visualizer engine. Hosts the waveform of whatever track is currently playing/selected. Three hosting modes: mode A (Mix detail — full-bleed centerpiece), mode B (Cut/Session detail — ambient layer behind hero+content via `ReleaseDetailScaffold`'s `Ambient` slot), mode C (NowPlaying hero panel — full-bleed background for the home hero's right side, mounted by `NowPlaying.razor` inside `.np-visualizer-bg`). `[Parameter] bool Fill` switches from fixed-viewport positioning to container-relative sizing (CSS-only; the renderer is identical in both modes). The bridge resolves the current track's `EntryKey` and re-fetches the high-res datum on track change. Subscribes to `WaveformVisualizerControlState.Changed` and pushes each updated dial to the WebGL module via JS interop. Follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`).
|
||||
- `WaveformVisualizerControls.razor`: The waveform visualizer control panel (content hosted by `WaveformVisualizerControlPopover`). Phase 15 re-layout: a deterministic **three-row sectioned layout** encoding the visualizer's two subsystems. Row 1 (MODE, always visible): two iconographic lamp toggles (lava on/off, waveform on/off) left-aligned + collisions knob (conditional — only when both subsystems on) + color knob pinned far-right. Row 2 (LAVA, visible only when `LavaEnabled`): "LAVA:" section label + Gravity / Heat / FluidAmount / FluidViscosity knobs. Row 3 (WAVE, visible only when `WaveformEnabled`): "WAVE:" section label + scroll-speed `MudSlider` (not a knob) + width knob pinned far-right. Total: two lamp toggles, seven `RadialKnob`s, one `MudSlider`. Colour principle: lamp toggles / knob arcs / slider are green (`Color.Primary` — interactive); section labels / knob caption icons are light (static). Each control has a playful `MudTooltip`. `[Parameter] bool PanelChrome` scopes panel chrome (NowPlayingCard look — square corners, lighter-navy, thin border) to the popover mount; chrome classes live in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). `[Parameter] bool Visible` gates the rows via `@if` while the container holds reserved min-height. Owns no JS interop: mutates the injected `WaveformVisualizerControlState` and raises `Changed`. No control is a seek surface (read-only contract).
|
||||
- `WaveformVisualizerControlPopover.razor`: Pairs the lava-lamp icon button with `WaveformVisualizerControls` as a **screen-centered tinted modal** (Phase 15). The primitive is `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) — **not** `MudPopover`; `AnchorOrigin`/`TransformOrigin` parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: `RadialKnob`'s `position:fixed` capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. `[Parameter] Size IconSize` controls the trigger-icon size (default `Large`). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in `.np-visualizer-controls` at the panel's top-right corner, not inside `NowPlayingCard`).
|
||||
- `TheaterModeToggle.razor`: Phase 20 Theater-Mode toggle button. Visible only when `Available && (State.LavaEnabled || State.WaveformEnabled)` — no visualizer subsystem active → no theater to enter; `Available` is false when this page's release is not the one currently playing (Phase 20 Wave 2). Disabled until interactive (`!RendererInfo.IsInteractive`), same guard as Play and the lava-lamp trigger. On click: flips `WaveformVisualizerControlState.TheaterMode` and calls `NotifyChanged()`. Shows an on/off `aria-pressed` active state. Glyph: Material `Theaters`. `.dd-accent-icon` container gives the green-accent glyph in both themes with zero new CSS — same treatment as `WaveformVisualizerControlPopover`. Subscribes to `State.Changed` in `OnInitialized` and unsubscribes on `Dispose` to re-render when another observer (e.g. `CoerceTheaterMode()`) flips the state. `[Parameter] Size IconSize` (default `Large`) matches the adjacent lava-lamp trigger. `[Parameter] bool Available` (default `true`) — the page passes its `ShowTheaterToggle` predicate here so the toggle is scoped to the playing release; surfaces with no release-scoping pass the default `true`. Placed **immediately left** of the lava-lamp popover on all three detail pages inside a `.dd-detail-top-actions` cluster.
|
||||
- `WaveformZoomMapping.cs`: Maps the `WaveformVisualizerControlState.Resolution` fraction to an integer zoom level for the WebGL renderer.
|
||||
- `NowPlayingCard.razor`: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded `IStreamingPlayerService`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to `NowPlaying.razor`.
|
||||
- `NowPlayingStats.razor`: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-`ReleaseType` Cut release breakdown), Mixes (`MixReleaseCount` labelled "Sets" + `hh:mm` total mix runtime via `RuntimeFormat`), and Plays (live `TotalPlays` odometer in `.hero-stat-odometer` + `UniqueListeners` "N listeners" secondary line via `.hero-stat-sub` — Phase 16 wave 16.5). All three cards read from the same `HomeStatsDto` round-trip; no extra fetch path. Fetches via `IStatsDataService` on init; bridges the prerender fetch across the WASM seam with `PersistentComponentState` (persists only on a successful load, matching the medium-browse bridge pattern). Implements `IDisposable` to release the `PersistingComponentStateSubscription`.
|
||||
@@ -36,8 +38,9 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `QueueList.razor`: Shared presentational queue-list component (Phase 17 wave 17.1). Renders `Items` as an ordered list with the current track marked; `Editable` flag gates drag-reorder handles (drag handle icon + `MudDropContainer`/`MudDropZone` for reorder) and per-row remove controls. The remove (×) control is suppressed on the currently-playing row (`Editable && !isCurrent`) — the current track cannot be removed via the UI (wave 17.2; reorder of the current row is still permitted). When not editable, renders a plain `<div>` — the read-only state for the embed's fixed-order shared queue. Reorder, remove, and row-jump are surfaced to the parent as `EventCallback<(int FromIndex, int ToIndex)> OnReorder`, `EventCallback<int> OnRemove`, and `EventCallback<int> OnJump`; the component calls no `IQueueService` method itself (purely presentational, no data fetch, no player wiring). Both view modes (docked overlay 17.2, embedded panel 17.3) consume this single component differing only in hosting context and the `Editable` flag. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs).
|
||||
- `QueueOverlay.razor`: Screen-centered tinted modal hosting the docked-player editable queue (Phase 17 wave 17.2). Borrows the `WaveformVisualizerControlPopover` `MudOverlay` idiom (`DarkBackground="true"`, `Modal="true"`): the panel stops click propagation; scrim-click closes the overlay; drag-safe (the panel's capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes when a removal empties the queue. Hosts `QueueList` in `Editable="true"` mode. Opened/closed by the Queue toggle button in `PlayerTransportZone` (shown only when `!Fixed && Items.Count > 0`; `QueueMusic` glyph, active state when open).
|
||||
- `AddToQueueButton.razor`: Append-only Add-to-Queue button shared across detail-page play sites (Phase 17 wave 17.4). Two modes: track mode (calls `IQueueService.Enqueue` with a single `TrackDto`) and release mode (calls `IQueueService.EnqueueRange` with an ordered track list). Material `PlaylistAdd` glyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at: `CutDetail` header (release mode, `TrackNumber`-ordered list), `CutDetail` track rows (track mode), `SessionDetail` hero play (track mode), `MixDetail` hero play (track mode). Excluded from `StreamNowButton` (OQ9) and `ReleaseGallery` cards (OQ10, deferred).
|
||||
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold.
|
||||
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold. The scaffold's default masthead PLAY (`PlayTrack`) routes through `IQueueService.PlayTrack` (deque PLAY semantics — prepends the track to the queue front) when the queue cascade is present, falling back to `IStreamingPlayerService.SelectTrackStreaming` when absent; toggle-pause is handled directly via `IStreamingPlayerService.TogglePlayPause` when this track is already active.
|
||||
- `SharePopover.razor`: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. **Track mode** (`EntryKey` set): copies the track's canonical URL and offers an iframe embed snippet pointing at `FramePlayer?TrackEntryKey=…`. **Release mode** (`ReleaseEntryKey` + `ReleaseMedium` set): copies the release's canonical detail URL (via `ReleaseRoutes.DetailHref`) and offers an iframe embed snippet pointing at `FramePlayer?ReleaseEntryKey=…`, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by `EmbedSnippetBuilder`. A transient "Copied!" confirmation resets after a short delay.
|
||||
- `SeoHead.razor`: Purely presentational SEO head emitter (Phase 22). Renders a `<PageTitle>` + `<HeadContent>` block from a single `SeoModel` parameter — standard meta (description, robots), canonical, Open Graph, Twitter Card, and schema.org JSON-LD. Owns no data fetch; each page wires it in one line and supplies the model from its already-bridged ViewModel state. Wired on Home, About, Cut/Session/Mix detail (incl. not-found branches → `noindex`), browse views, and the 404 page.
|
||||
- `Helpers/`: Utilities and mapper functions.
|
||||
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
|
||||
- `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure.
|
||||
@@ -48,13 +51,13 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 16–64 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
|
||||
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
|
||||
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
|
||||
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
|
||||
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions, **two subsystem on/off toggles** (Phase 15), and one **Theater-Mode flag** (Phase 20): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`), `TheaterMode` (bool, default `false` — `DefaultTheaterMode`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and `AudioPlayerBar`) subscribe to react to `TheaterMode`. **`CoerceTheaterMode()`**: enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` **before** `NotifyChanged()` so all observers see a consistent, coerced state in the same `Changed` cycle. `TheaterMode` is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. **Phase 20 Wave 2 — playing-release predicates** live in `ReleaseDetailBase` / `CutDetailBase` (not in this state holder): `IsThisReleasePlaying` (`PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). Both base classes also subscribe to `IStreamingPlayerService.StateChanged` (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
|
||||
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
|
||||
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
|
||||
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
|
||||
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1). Serializes the play classification and fires it via `BeaconInterop` to `api/event/play`. Synchronous (`EmitPlay` cannot await — it is called from the player close path and the page-unload handler). **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
|
||||
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/telemetry/anonid.ts` (mints GUID on first visit, returns null without throwing when storage is unavailable).
|
||||
- `IQueueService` / `QueueService`: Ordered playback orchestrator above the single-slot player. `PlayRelease(tracks, startIndex)` replaces the queue and starts streaming; `Next`/`Previous` advance or step back; `Enqueue`/`EnqueueRange` append without interrupting the current track; `Clear` empties the queue. **Armed-idle state** added to support prerender-safe release embeds: `Arm(tracks)` loads the track list at index 0 with no JS interop (safe during prerender); `IsArmed` signals the armed-but-not-streaming state; `Start()` begins streaming the current track and clears `IsArmed`, leaving the list and position intact so auto-advance carries on. `AudioPlayerBar` reads `IsArmed` to route the first play gesture through `Start()` instead of streaming the staged track alone. `QueueChanged` event fires on all list/position changes; cascaded via `AudioPlayerProvider`. **Wave 17.1 additions:** `Move(int fromIndex, int toIndex)` reorders `Items` in-place, adjusting `CurrentIndex` so the same track stays current across the move — never re-streams or interrupts playback; `RemoveAt(int index)` removes an item and adjusts `CurrentIndex` (removing the current track does not stop playback; removing the last remaining item leaves the queue empty and dormant). Both are interop-free state mutations that re-emit `QueueChanged`. **Dormant-`Enqueue` coherence (OQ8):** `Enqueue`/`EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) set `CurrentIndex` to 0 so a subsequent play/skip is correct — but do not auto-play. **Wave 17.2 additions:** `ClearUpcoming()` removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0` and re-emitting `QueueChanged` — touches no playback (OQ5: Clear does not stop or remove the current track). `PlayRelease` now always materializes a defensive copy of its input (`tracks.ToList()`) so it can never alias the service's own `Items` list — fixes a row-jump bug where `PlayRelease(Items, index)` could mutate the live list mid-operation.
|
||||
- `IQueueService` / `QueueService`: **Two-level deque** orchestrator above the single-slot player. The deque has two entry ends. **PLAY (manual)** enters the FRONT: `PlayTrack(track)` and `PlayRelease(tracks, startIndex)` prepend the played track/release in order, **remove the previously-current track**, make the new front current, start streaming it, and leave whatever sat after the old current intact behind the prepend (a whole release prepends in order in one op). The detail pages (Cut header/row, Session/Mix hero) and `StreamNowButton` route their PLAY through these. **Add-to-queue** enters the BACK: `Enqueue`/`EnqueueRange` append to the end without interrupting the current track (`AddToQueueButton`). `Next`/`Previous` advance or step back, walking `CurrentIndex` and leaving played tracks behind so `Previous` can reach them; `JumpTo(index)` moves the pointer to a queued row and streams it once (the playlist panel's row-jump — it does NOT prepend or stream the intervening rows). **End-of-track:** auto-advance (`TrackEnded`) advances when there is a next track; when the **last** track ends naturally the queue **empties** and goes dormant (bug #2) rather than stranding the finished track. `Clear` empties the queue. **Bug #3 (dormant-seed):** the first `Enqueue`/`EnqueueRange` into a dormant queue while a track is already playing externally (via the attached player, not through the queue) seeds the head with that now-playing track and then appends — yielding `[now-playing, added]` (even when adding the same track). The queue learns the externally-playing track through the existing `Attach(player)` seam (`_player.CurrentTrack`) — no new dependency, no `IServiceProvider`. **Armed-idle state** (prerender-safe release embeds): `Arm(tracks)` replaces the queue at index 0 with no JS interop; `IsArmed` signals armed-but-not-streaming; `Start()` streams the current track and clears `IsArmed`. `AudioPlayerBar` reads `IsArmed` to route the embed's first play gesture through `Start()`. `QueueChanged` fires on all list/position changes; cascaded via `AudioPlayerProvider`. `Move`/`RemoveAt` are interop-free reorder/remove mutations that adjust `CurrentIndex` and never re-stream. `ClearUpcoming()` keeps the current track and drops the up-next. **Bug #4 (reactivity):** `AudioPlayerBar.QueueItems` caches `QueueService.Items` as a `_queueItemsCache` snapshot (the service exposes its backing list by reference); the cache is invalidated and set to `null` in `OnQueueChanged`, so every real mutation hands `QueueList` a new list reference while frequent progress-tick re-renders reuse the cached one without allocating. `QueueList.OnParametersSet` calls `_dropContainer?.Refresh()` so the `MudDropContainer` re-reads the new list and the open panel re-flows immediately. **Bug #1 (label):** the docked `QueueOverlay` panel header reads **"Playlist"** (the current track stays listed). `PlayRelease` materializes `tracks.ToList()` before mutating so it can never alias the service's own `Items` list.
|
||||
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
|
||||
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
|
||||
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)` → `Stream` with optional Range header support for seek-beyond-buffer.
|
||||
@@ -68,6 +71,11 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `Common/`: Shared utilities.
|
||||
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
|
||||
- `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B).
|
||||
- `SeoModel.cs`: Typed per-page SEO input (Phase 22). Named factories: `ForRelease` (medium-dispatched — Cut → `MusicAlbum`, Session → `MusicAlbum`/`LiveAlbum`, Mix → `MusicRecording`), `ForHome`, `ForAbout`, `ForBrowse`, `ForNotFound`. Encodes the medium→schema.org mapping in one place. `SeoModel.Robots` overrides the environment-default (see `SeoEnvironment`).
|
||||
- `SeoJsonLd.cs`: Typed schema.org JSON-LD builders (Phase 22). Types: `MusicGroup` (home/about, with `sameAs: ["https://instagram.com/deepdrft.music"]`), `MusicAlbum`/`LiveAlbum` (cuts/sessions, with ordered `MusicRecording` track list and per-release `byArtist`), `MusicRecording` (mixes, with ISO-8601 `duration`), `CollectionPage` (browse). All serialized output is inline-safe-escaped (`<`/`>`/`&` → `\uXXXX`) to prevent script-breakout from CMS-authored text.
|
||||
- `SeoOptions.cs`: Site-wide SEO config (Phase 22). `BaseUrl` (`https://deepdrft.com`), title suffix (`Deep DRFT`, middot separator), default OG image seam (uses `ImageProxyController` route), IG handle in `sameAs`, no Twitter handle. Registered via the static `Startup` seam (both server and WASM `Program.cs`). `BaseUrl` is config, not `window.location` — no `window` at server prerender, and the origin cannot be derived reliably behind the nginx proxy.
|
||||
- `SeoUrls.cs`: URL helpers for canonical and `og:image` construction from `SeoOptions.BaseUrl` (Phase 22).
|
||||
- `SeoEnvironment.cs`: Scoped `[PersistentState]` bridge for the server environment flag (Phase 22). Seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — mirrors the `DarkModeSettings` bridge pattern. Default robots is `index,follow` only in Production; `noindex,nofollow` in every non-production environment so the beta/staging site stays uncrawled. Explicit per-page `SeoModel.Robots` overrides this default. Fail-safe default is `noindex`.
|
||||
- `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`.
|
||||
- `_Imports.razor`: Global using statements and component imports.
|
||||
|
||||
@@ -127,6 +135,8 @@ New modules in `DeepDrftPublic/Interop/audio/`:
|
||||
|
||||
The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie.
|
||||
|
||||
**`SeoEnvironment` follows the same `[PersistentState]` bridge pattern** (Phase 22). It is seeded server-side in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` and bridged to the WASM client. Consumers (`SeoHead`) read `SeoEnvironment.IsProduction` to gate the default robots directive (`index,follow` in Production, `noindex,nofollow` elsewhere). The pattern is identical to `DarkModeSettings` — one server-side seed, one `PersistentComponentState` round-trip, one scoped client read.
|
||||
|
||||
## MVVM convention
|
||||
|
||||
Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only.
|
||||
@@ -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).
|
||||
- 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
|
||||
|
||||
```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
|
||||
|
||||
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the
|
||||
cascaded IQueueService's Enqueue/EnqueueRange (which append without disturbing current playback and
|
||||
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select.
|
||||
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole
|
||||
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@
|
||||
cascaded IQueueService's Enqueue/EnqueueRange (which append to the END without disturbing current
|
||||
playback; a first add into a dormant queue seeds the head from the externally-playing track when one
|
||||
exists, then appends) — never PlayRelease/PlayTrack/Start/Select. Track mode (Track set) appends a
|
||||
single track; release mode (ReleaseTracks set) appends the whole ordered list. Reads queue state from
|
||||
the layout-level cascade (C1); owns no data fetch. *@
|
||||
|
||||
<MudTooltip Text="@Tooltip">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
|
||||
|
||||
@@ -10,6 +10,22 @@ else
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
|
||||
<MudPaper Elevation="8" Class="player-surface pa-3">
|
||||
|
||||
@* Theater Mode "now showing" band (Phase 20 §5/§7, Wave 2 §2). Keyed off the playing
|
||||
track's Release, not off any detail page (the bar reaches into no page; §6). The release
|
||||
page is hidden in Theater Mode, so the bar carries its identity: cover, linked title,
|
||||
release share. The band stays mounted whenever a release is playing and eases in/out via
|
||||
the shared .dd-theater-collapsible wrapper — collapsed (zero height, faded) unless
|
||||
Theater is ON — so the bar grows/shrinks smoothly instead of popping. *@
|
||||
@if (CurrentTrack?.Release is not null)
|
||||
{
|
||||
var nowShowing = VisualizerControlState.TheaterMode;
|
||||
<div class="dd-theater-collapsible @(nowShowing ? null : "dd-theater-collapsed")">
|
||||
<div class="dd-theater-collapsible-inner">
|
||||
<NowShowingPanel Release="CurrentTrack.Release" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="player-layout">
|
||||
<PlayerTransportZone IsLoaded="IsLoaded"
|
||||
CanPlay="CanPlay"
|
||||
@@ -45,7 +61,7 @@ else
|
||||
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
|
||||
controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3).
|
||||
Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked
|
||||
overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started).
|
||||
overlay, which calls JumpTo (moves the pointer and streams the row, clearing IsArmed).
|
||||
Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The
|
||||
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
|
||||
shrunken height to the host iframe. *@
|
||||
|
||||
@@ -16,12 +16,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
|
||||
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
|
||||
|
||||
// Theater Mode (Phase 20). Property-injected (no constructor growth) so the bar can read
|
||||
// TheaterMode to mount the "now showing" band and re-render when the flag flips. The toggle lives on
|
||||
// the detail pages; the bar only observes — single source, multiple observers (§6).
|
||||
[Inject] private WaveformVisualizerControlState VisualizerControlState { get; set; } = default!;
|
||||
|
||||
private bool _isMinimized = true;
|
||||
private bool _isSeeking = false;
|
||||
private double _seekPosition = 0;
|
||||
private bool _queueOpen = false;
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private IQueueService? _subscribedQueue;
|
||||
private bool _subscribedToVisualizerState;
|
||||
|
||||
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
|
||||
// spacer reserves its space. We mirror this element's live height into a CSS
|
||||
@@ -85,7 +91,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
|
||||
private bool ShowFixedPanel => Fixed && HasQueue;
|
||||
|
||||
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
|
||||
// Cached snapshot of the queue list (bug #4 fix). QueueService.Items returns the service's
|
||||
// backing list by reference, so passing it straight through means Blazor parameter diffing sees
|
||||
// an unchanged reference after an in-place Clear/remove/reorder and the child (QueueList /
|
||||
// MudDropContainer) keeps its stale snapshot until reopened. We snapshot on first access and
|
||||
// rebuild in OnQueueChanged, so every real mutation hands the child a NEW reference while
|
||||
// progress-tick re-renders (the frequent path) reuse the cached one without allocating.
|
||||
private IReadOnlyList<TrackDto>? _queueItemsCache;
|
||||
private IReadOnlyList<TrackDto> QueueItems =>
|
||||
_queueItemsCache ??= QueueService is null ? [] : QueueService.Items.ToList();
|
||||
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
|
||||
|
||||
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
|
||||
@@ -135,12 +149,28 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
QueueService.QueueChanged += OnQueueChanged;
|
||||
_subscribedQueue = QueueService;
|
||||
}
|
||||
|
||||
// Theater Mode (Phase 20 §7): re-render the bar when TheaterMode flips so the "now showing" band
|
||||
// appears/disappears. VisualizerControlState is injected (one stable scoped instance per session),
|
||||
// so the subscribe is once-only — same idempotent subscribe-here / unsubscribe-on-dispose shape.
|
||||
if (!_subscribedToVisualizerState)
|
||||
{
|
||||
VisualizerControlState.Changed += OnVisualizerStateChanged;
|
||||
_subscribedToVisualizerState = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnQueueChanged()
|
||||
{
|
||||
// Invalidate the snapshot so QueueItems rebuilds a fresh list on the next render.
|
||||
// This gives Blazor a new reference on every real mutation (bug #4 reactivity preserved)
|
||||
// while progress-tick re-renders that don't go through here keep the cached reference.
|
||||
_queueItemsCache = null;
|
||||
|
||||
// If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close
|
||||
// the overlay so it cannot strand open over an empty queue. The button gate hides the overlay
|
||||
// mount too, so this keeps state and view consistent.
|
||||
@@ -189,12 +219,14 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
|
||||
private void ClearUpcoming() => QueueService?.ClearUpcoming();
|
||||
|
||||
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that
|
||||
// touches playback — it streams the chosen track via the player.
|
||||
// Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY,
|
||||
// not an in-place seek), so a jump cannot route through it without duplicating the queue. JumpTo
|
||||
// moves the pointer to the chosen row and streams it once — preserving deque order. This is the one
|
||||
// queue action besides PLAY/skip that touches playback.
|
||||
private async Task OnQueueJump(int index)
|
||||
{
|
||||
if (QueueService == null) return;
|
||||
await QueueService.PlayRelease(QueueService.Items, index);
|
||||
await QueueService.JumpTo(index);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
@@ -387,6 +419,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
_subscribedQueue = null;
|
||||
}
|
||||
|
||||
if (_subscribedToVisualizerState)
|
||||
{
|
||||
VisualizerControlState.Changed -= OnVisualizerStateChanged;
|
||||
_subscribedToVisualizerState = false;
|
||||
}
|
||||
|
||||
if (_spacerModule is not null)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -42,6 +42,68 @@
|
||||
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-dock {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftPublic.Client.Common
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
@* "Now showing" block surfaced in the player bar when Theater Mode is ON (Phase 20 §5/§7). Theater
|
||||
hides the release page, so the bar carries the release identity the page would have shown: cover art,
|
||||
the release title linked to its detail page, and a release-mode share. Purely presentational — it owns
|
||||
no player logic and no Theater state; AudioPlayerBar mounts it only when state.TheaterMode &&
|
||||
CurrentTrack?.Release is not null, so Release is non-null here.
|
||||
|
||||
Theming is all reuse (§8, zero new CSS): the cover reuses the deepdrft-track-detail-cover-art /
|
||||
-placeholder idiom; the share glyph goes green-accent in both themes via .dd-accent-icon; surface and
|
||||
text come from the bar's own .player-surface and the .now-showing-* classes in the global sheet, which
|
||||
bind the theme-aware --deepdrft-page-* aliases. *@
|
||||
|
||||
<div class="now-showing">
|
||||
<div class="now-showing-cover">
|
||||
@if (!string.IsNullOrEmpty(Release.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-detail-cover-art now-showing-cover-art"
|
||||
style="@($"background-image: url('api/image/{Uri.EscapeDataString(Release.ImagePath)}');")"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary now-showing-cover-placeholder">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<a href="@ReleaseRoutes.DetailHref(Release)" class="now-showing-title-link">
|
||||
<MudText Typo="Typo.subtitle2" Class="now-showing-title text-truncate">
|
||||
@Release.Title
|
||||
</MudText>
|
||||
</a>
|
||||
|
||||
<div class="dd-accent-icon now-showing-share">
|
||||
<SharePopover ReleaseEntryKey="@Release.EntryKey" ReleaseMedium="@Release.Medium" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>The current playing track's release. Non-null by the bar's mount gate.</summary>
|
||||
[Parameter, EditorRequired] public ReleaseDto Release { get; set; } = default!;
|
||||
}
|
||||
@@ -14,14 +14,6 @@
|
||||
Color="Color.Primary"
|
||||
Disabled="!CanPlay"
|
||||
OnToggle="@TogglePlayPause"/>
|
||||
@if (!Fixed || HasPrevious || HasNext)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipNext"
|
||||
Disabled="!HasNext"/>
|
||||
}
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
@@ -30,4 +22,12 @@
|
||||
OnClick="@Stop"
|
||||
Disabled="!IsLoaded"/>
|
||||
}
|
||||
@if (!Fixed || HasPrevious || HasNext)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipNext"
|
||||
Disabled="!HasNext"/>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
@* 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,
|
||||
mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
||||
@if (ShowQueueButton)
|
||||
{
|
||||
<MudTooltip Text="Queue">
|
||||
@@ -35,5 +37,5 @@
|
||||
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.8rem;
|
||||
display: flex;
|
||||
@@ -27,7 +27,7 @@
|
||||
display: block;
|
||||
width: 2.5rem;
|
||||
height: 1px;
|
||||
background: var(--deepdrft-green-accent);
|
||||
background: var(--deepdrft-green);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
@@ -36,14 +36,14 @@
|
||||
font-weight: 300;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 0.5rem;
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
|
||||
.hero-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
@@ -51,7 +51,7 @@
|
||||
font-size: clamp(1rem, 2vw, 1.35rem);
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
margin-bottom: 3rem;
|
||||
letter-spacing: 0.04em;
|
||||
animation-delay: 0.34s;
|
||||
@@ -61,7 +61,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.75;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.7;
|
||||
max-width: 36ch;
|
||||
margin-bottom: 3rem;
|
||||
@@ -81,3 +81,11 @@
|
||||
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;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
background-color: var(--deepdrft-soft);
|
||||
background-color: var(--deepdrft-play-chip);
|
||||
border-radius: 50%;
|
||||
height: 60px;
|
||||
width: 60px;
|
||||
@@ -10,5 +10,27 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
// MudDropContainer snapshots its Items into internal drop zones and does not re-read them on a
|
||||
// plain re-render — so a Clear/remove/reorder that changes the parent's Items list must be pushed
|
||||
// into the container explicitly, or the panel shows the stale order until reopened (bug #4). The
|
||||
// parent passes a fresh Items reference per mutation; refreshing here on every parameter set re-flows
|
||||
// the container's snapshot to match. Cheap: Refresh only re-reads the bound list.
|
||||
protected override void OnParametersSet() => _dropContainer?.Refresh();
|
||||
|
||||
// Index-tagged view rows. The index is the row's position in Items at render time and is the
|
||||
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
|
||||
private List<QueueRow> Rows =>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
Class="deepdrft-queue-overlay">
|
||||
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
|
||||
<div class="deepdrft-queue-modal-header">
|
||||
<span class="deepdrft-queue-modal-title">Up Next</span>
|
||||
<span class="deepdrft-queue-modal-title">Playlist</span>
|
||||
<MudButton Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace DeepDrftPublic.Client.Controls;
|
||||
public partial class ReleaseDetailScaffold : ComponentBase
|
||||
{
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
[Parameter] public required string Title { get; set; }
|
||||
[Parameter] public string? Artist { get; set; }
|
||||
@@ -96,13 +97,19 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
||||
{
|
||||
if (Track is null || PlayerService is null) return;
|
||||
|
||||
// Toggle if this track is already active (playing or paused); otherwise start a fresh
|
||||
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
|
||||
// Toggle if this track is already active (playing or paused); otherwise PLAY it —
|
||||
// prepend to the queue's front (deque PLAY semantics) so it becomes current and
|
||||
// the existing queue stays intact behind it. Falls back to a direct stream when
|
||||
// the queue cascade is absent (prerender / non-interactive).
|
||||
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
|
||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else if (Queue is not null)
|
||||
{
|
||||
await Queue.PlayTrack(Track);
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(Track);
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</MudStack>
|
||||
@if (ShareContent is not null)
|
||||
{
|
||||
<div class="release-hero-share">
|
||||
<div class="release-hero-share dd-accent-icon">
|
||||
@ShareContent
|
||||
</div>
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
@if (PlayContent is not null)
|
||||
{
|
||||
<div class="release-hero-play">
|
||||
<div class="release-hero-play dd-accent-icon">
|
||||
@PlayContent
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -151,14 +151,13 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* The play affordance and share button sit over a dark image — force their icon glyphs to the
|
||||
light theme color regardless of MudBlazor's Secondary palette. Both PlayStateIcon and
|
||||
SharePopover render MudIconButton / MudProgressCircular internals, so ::deep is required. */
|
||||
::deep .release-hero-play .mud-icon-button,
|
||||
::deep .release-hero-play .mud-progress-circular,
|
||||
::deep .release-hero-share .mud-icon-button {
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
/* The play/share glyphs are coloured by the shared .dd-accent-icon treatment (green-accent in
|
||||
both themes) applied on .release-hero-play / .release-hero-share in ReleaseHeroOverlay.razor —
|
||||
see deepdrft-styles.css. No co-located colour rule here: the former white override was removed
|
||||
because its glyph clauses (.mud-icon-button .mud-icon-root) could not reach the
|
||||
.mud-secondary-text !important glyph at wrapper specificity, and its spinner clause
|
||||
(.mud-progress-circular) was live but is now correctly covered by .dd-accent-icon —
|
||||
making the spinner green-accent (was white) in light mode, the one intentional light delta. */
|
||||
|
||||
@media (max-width: 599.98px) {
|
||||
.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 EventCallback OnStreamStarted { get; set; }
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
[Inject] public required ITrackDataService TrackData { get; set; }
|
||||
|
||||
private bool _streamLoading;
|
||||
@@ -79,7 +80,12 @@
|
||||
_findingTrack = false;
|
||||
StateHasChanged();
|
||||
|
||||
if (PlayerService is not null)
|
||||
// PLAY semantics: prepend to the queue's front so a "stream now" track becomes current and
|
||||
// any existing queue stays intact behind it. Falls back to a direct stream when the queue
|
||||
// cascade is absent.
|
||||
if (Queue is not null)
|
||||
await Queue.PlayTrack(track);
|
||||
else if (PlayerService is not null)
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@implements IDisposable
|
||||
@inject WaveformVisualizerControlState State
|
||||
|
||||
@* Theater-Mode toggle (Phase 20 §3). The single affordance placed identically on all three release
|
||||
detail pages — immediately to the LEFT of the lava-lamp WaveformVisualizerControlPopover trigger.
|
||||
It is purely a mutation surface: tapping it flips State.TheaterMode and raises Changed; the detail
|
||||
pages observe that to gate their content @if, and the player bar observes it to grow. This component
|
||||
reaches into no page and no bar — single source, multiple observers (§6).
|
||||
|
||||
Visible only when the lava OR waveform subsystem is on — there is nothing to go to theater FOR if both
|
||||
are off (§3.2) — AND when <see cref="Available"/> is true. The page supplies Available so the toggle
|
||||
only appears when this page's release is the one playing (Phase 20 Wave 2 §3): the toggle owns the
|
||||
subsystem gate; the page owns the release-playing predicate. Disabled until interactive (§3.4), the
|
||||
same prerender guard the lava/Play buttons use. Active visual state when Theater is ON. .dd-accent-icon
|
||||
gives the green-accent glyph in both themes with zero new CSS (§8) — same as the lava-lamp trigger. *@
|
||||
|
||||
@if (Available && (State.LavaEnabled || State.WaveformEnabled))
|
||||
{
|
||||
<div class="dd-accent-icon">
|
||||
<MudTooltip Text="@(State.TheaterMode ? "Exit theater mode" : "Theater mode")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Theaters"
|
||||
Size="@IconSize"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@Toggle"
|
||||
aria-label="Theater mode"
|
||||
aria-pressed="@State.TheaterMode" />
|
||||
</MudTooltip>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Trigger-icon size. Defaults Large to match the lava-lamp popover trigger it sits beside.</summary>
|
||||
[Parameter] public Size IconSize { get; set; } = Size.Large;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the toggle is available on this surface (Phase 20 Wave 2 §3). The page passes the
|
||||
/// "this release is the one playing" predicate here; Theater Mode only applies to the playing
|
||||
/// release, so a detail page whose release is not playing passes <c>false</c> and shows no toggle.
|
||||
/// Defaults <c>true</c> so surfaces with no release-scoping (none today) keep the subsystem-only gate.
|
||||
/// </summary>
|
||||
[Parameter] public bool Available { get; set; } = true;
|
||||
|
||||
protected override void OnInitialized() => State.Changed += OnStateChanged;
|
||||
|
||||
// The toggle's own visibility and active state both key off State, which another observer (or this
|
||||
// button) may mutate, so re-render on every Changed — same idempotent posture the visualizer bridge uses.
|
||||
private void OnStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void Toggle()
|
||||
{
|
||||
State.TheaterMode = !State.TheaterMode;
|
||||
State.NotifyChanged();
|
||||
}
|
||||
|
||||
public void Dispose() => State.Changed -= OnStateChanged;
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This
|
||||
host only toggles open/closed and centers the panel — it stays purely presentational. *@
|
||||
|
||||
<div class="dd-accent-icon">
|
||||
<MudTooltip Text="Visualizer settings">
|
||||
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
|
||||
Size="@IconSize"
|
||||
@@ -38,6 +39,7 @@
|
||||
aria-label="Visualizer settings"
|
||||
aria-expanded="@_open" />
|
||||
</MudTooltip>
|
||||
</div>
|
||||
|
||||
@* 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
|
||||
|
||||
@@ -34,9 +34,16 @@
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
@* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudGrid>
|
||||
@* ── Row 1 — MODE (always visible). ── *@
|
||||
<MudItem xs="2" Class="d-flex align-center">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
|
||||
<span class="wvc-section-label">MODE:</span>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="10">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudTooltip Text="Show the sound, or hide the ribbon.">
|
||||
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
|
||||
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
|
||||
@@ -58,14 +65,16 @@
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Compress"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
}
|
||||
@* </div> *@
|
||||
|
||||
@* Color applies to the whole field regardless of which subsystems are on, so it is pinned
|
||||
far-right of row 1 and never reflows when collisions hides (§3). *@
|
||||
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
|
||||
{
|
||||
<MudTooltip Text="How fast the lamp drifts through its colors.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
|
||||
<RadialKnob Value="@ControlState.GradientRotationSpeed"
|
||||
@@ -76,6 +85,7 @@
|
||||
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
<MudTooltip Text="Light the lamp — or let it go cold.">
|
||||
<div class="wvc-toggle @(ControlState.LavaEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Lava field on or off">
|
||||
@@ -86,16 +96,19 @@
|
||||
aria-pressed="@ControlState.LavaEnabled"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* ── 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. ── *@
|
||||
@* ── Row 2 — WAVE section (only when waveform on). ── *@
|
||||
@if (ControlState.WaveformEnabled)
|
||||
{
|
||||
<div class="wvc-row wvc-row-section wvc-row-wave">
|
||||
<MudItem xs="2" Class="d-flex align-center">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
|
||||
<span class="wvc-section-label">WAVE:</span>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="10">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudTooltip Text="How fast the sound rolls by.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
|
||||
@@ -119,15 +132,18 @@
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
@* ── Row 3 — LAVA section (only when lava on). ── *@
|
||||
@if (ControlState.LavaEnabled)
|
||||
{
|
||||
<div class="wvc-row wvc-row-section">
|
||||
<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>
|
||||
|
||||
<MudItem xs="10" Class="d-flex align-center">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudTooltip Text="How heavy the wax feels — float, or sink.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
|
||||
@@ -151,7 +167,7 @@
|
||||
</div>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Text="How much goo is in the lamp.">
|
||||
<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"
|
||||
@@ -173,8 +189,9 @@
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
</div>
|
||||
@@ -209,12 +226,14 @@
|
||||
private void ToggleLava()
|
||||
{
|
||||
ControlState.LavaEnabled = !ControlState.LavaEnabled;
|
||||
ControlState.CoerceTheaterMode();
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
|
||||
private void ToggleWaveform()
|
||||
{
|
||||
ControlState.WaveformEnabled = !ControlState.WaveformEnabled;
|
||||
ControlState.CoerceTheaterMode();
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -38,3 +38,8 @@
|
||||
color: var(--mud-palette-primary);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.wvc-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
aria-label="Close privacy note"
|
||||
Class="deepdrft-privacy-modal-close" />
|
||||
</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>
|
||||
</MudOverlay>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
WaveformVisualizer backdrop (z-index:0), keeping footer text fully legible. */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
border-top: 1px solid var(--deepdrft-border);
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
@@ -22,7 +22,7 @@
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
}
|
||||
|
||||
.deepdrft-footer-logo span {
|
||||
@@ -44,19 +44,19 @@
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.58rem;
|
||||
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.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@using DeepDrftPublic.Client.Services
|
||||
|
||||
@* Desktop Menu *@
|
||||
<div class="d-none d-sm-flex">
|
||||
<div class="d-none d-md-flex">
|
||||
<nav class="@NavClass">
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
<a class="dd-nav-brand" href="/">
|
||||
@@ -42,14 +42,29 @@
|
||||
|
||||
<div class="dd-nav-actions">
|
||||
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now ▶"/>
|
||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@* Mobile Menu *@
|
||||
<div class="d-flex d-sm-none">
|
||||
<div class="d-flex d-md-none">
|
||||
<nav class="@NavClass">
|
||||
<a class="dd-nav-brand" href="/">Deep DRFT</a>
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
<a class="dd-nav-brand" href="/">
|
||||
<MudImage Src="img/deepdrft-logo-l.webp"
|
||||
Alt="Deep Drft Ornamental Logo Left"
|
||||
Width="24"
|
||||
Height="24 "/>
|
||||
|
||||
<span class="mx-2">Deep DRFT</span>
|
||||
|
||||
<MudImage Src="img/deepdrft-logo-r.webp"
|
||||
Alt="Deep Drft Ornamental Logo Right"
|
||||
Width="24"
|
||||
Height="24 "/>
|
||||
</a>
|
||||
</MudStack>
|
||||
|
||||
<div class="dd-nav-actions">
|
||||
<button type="button"
|
||||
@@ -59,6 +74,8 @@
|
||||
@onclick="ToggleMobileMenu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
|
||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
||||
</div>
|
||||
|
||||
@if (_mobileMenuOpen)
|
||||
@@ -117,6 +134,12 @@
|
||||
|
||||
private string DarkLightModeIconSvg => IsDarkMode ? DDIcons.GasLampLit : DDIcons.GasLamp;
|
||||
|
||||
private string DarkLightModeButtonIcon => IsDarkMode switch
|
||||
{
|
||||
true => DDIcons.GasLampLit,
|
||||
false => DDIcons.GasLamp,
|
||||
};
|
||||
|
||||
private async Task DarkModeToggle()
|
||||
{
|
||||
IsDarkMode = !IsDarkMode;
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
justify-content: space-between;
|
||||
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);
|
||||
box-shadow: none;
|
||||
@@ -50,6 +55,10 @@
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
.dd-nav-dark .dd-nav-brand > ::deep img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* Centred link list */
|
||||
.dd-nav-links {
|
||||
display: flex;
|
||||
@@ -222,6 +231,6 @@
|
||||
/* Mobile padding — give the nav room to breathe without crowding */
|
||||
@media (max-width: 599px) {
|
||||
.dd-nav {
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />
|
||||
<MudPopoverProvider />
|
||||
@@ -15,7 +16,7 @@
|
||||
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
|
||||
<AudioPlayerProvider>
|
||||
<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">
|
||||
@Body
|
||||
</MudContainer>
|
||||
@@ -42,10 +43,13 @@
|
||||
private string _audioPlayerClass = "minimized";
|
||||
private const string DarkModeKey = "darkMode";
|
||||
private bool _isDarkMode = false;
|
||||
private bool? _lastAppliedDarkMode = null;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
private IJSObjectReference? _themeModule;
|
||||
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
|
||||
[Inject] public required IJSRuntime JS { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -66,6 +70,24 @@
|
||||
_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
|
||||
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
|
||||
|
||||
@@ -80,6 +102,15 @@
|
||||
_persistingSubscription.Dispose();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_themeModule != null)
|
||||
{
|
||||
try { await _themeModule.DisposeAsync(); }
|
||||
catch (JSDisconnectedException) { /* circuit torn down */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleAudioPlayerMinimized(bool isMinimized)
|
||||
{
|
||||
_audioPlayerClass = isMinimized ? "minimized" : "expanded";
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JsRuntime
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>The Collective - Deep DRFT</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForAbout(Seo)" />
|
||||
|
||||
@* ──────────────────────────────────────────────────────────────────────────────
|
||||
THE LINER NOTES — a numbered three-movement editorial essay.
|
||||
@@ -23,8 +24,8 @@
|
||||
|
||||
@* ── 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). ── *@
|
||||
<section class="hero">
|
||||
<MudGrid Spacing="0" Style="height: 100%;">
|
||||
<section class="hero pb-20">
|
||||
<MudGrid Spacing="0">
|
||||
<MudItem xs="12" md="6">
|
||||
<div class="hero-left">
|
||||
<div class="hero-eyebrow @AnimClass">Charleston, South Carolina</div>
|
||||
@@ -344,11 +345,6 @@
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Member bios. Khabran's body is an intentional empty slot — the card composes
|
||||
// without it (graceful degrade). Daniel's copy is verbatim per spec COPY C,
|
||||
// including the two typos he chose to keep ("embarked in", "metalhead at from").
|
||||
// PortraitImage* are null until final portrait files land — the card renders a
|
||||
// placeholder treatment in their absence.
|
||||
private record Member(
|
||||
string Name,
|
||||
string Role,
|
||||
@@ -361,13 +357,13 @@
|
||||
new(
|
||||
Name: "Khabran Peters",
|
||||
Role: "Production · Sound Design · Live",
|
||||
Bio: "Raised on the Chicago underground, this artist cut their teeth on DJ Assault and DJ Funk. They started DJing young, learning to read a room long before they opened a DAW. After fifteen years as a visual artist, they moved into music production.\n\nNow based in Charleston, their sound carries the city's late-night feel but keeps the kinetic edge of its Midwest roots—deep one minute, fast the next. As much indie sensibility as booty-house grit.\n\nThe work is hardware-first, with software kept to remixes and edits. Onstage they stay out of the way and let the tracks do the talking. Polished without being precious—built by someone who cares more about the craft than the spotlight.",
|
||||
Bio: "Raised on the Chicago underground, this artist cut his teeth on DJ Assault and DJ Funk. He started DJing young, learning to read a room long before he opened a DAW. After fifteen years as a visual artist, he moved into music production.\n\nNow based in Charleston, his sound carries the city's late-night feel but keeps the kinetic edge of its Midwest roots—deep one minute, fast the next. As much indie sensibility as booty-house grit.\n\nThe work is hardware-first, with software kept to remixes and edits. Onstage he stays out of the way and lets the tracks do the talking. Polished without being precious—built by someone who cares more about the craft than the spotlight.",
|
||||
PortraitImage1: "img/dd-khabran-bw.jpeg",
|
||||
PortraitImage2: "img/dd-khabran.jpeg"),
|
||||
new(
|
||||
Name: "Daniel Harvey",
|
||||
Role: "Production · Sound Design · Live",
|
||||
Bio: "Daniel started on drums at ten and embarked in electronic music at seventeen — synthesizers first. A metalhead at from a young age, he spent ten years as an engineer living near Detroit filling the nights with synthesized tones and rhythms, shaped most of all by the thriving local underground techno scene.\n\nNow back home in the lowcountry, Daniel carries the varied sounds of his past into a new future, inspired by the wandering cypress swamps and soulful sunsets over the Ashley River.\n\nArt & engineering cannot be separated: custom plugins, hardware recording & performance rigs; the tools behind the tracks are just as important as the finished sound. To him the science and the math matter as much as the beauty — tension and release, built deliberately.",
|
||||
Bio: "Daniel started on drums at ten and embarked in electronic music at seventeen — synthesizers first. A metalhead from a young age, he spent ten years as an engineer living near Detroit filling the nights with synthesized tones and rhythms, shaped most of all by the thriving local underground techno scene.\n\nNow back home in the lowcountry, Daniel carries the varied sounds of his past into a new future, inspired by the wandering cypress swamps and soulful sunsets over the Ashley River.\n\nArt & engineering cannot be separated: custom plugins, hardware recording & performance rigs; the tools behind the tracks are just as important as the finished sound. To him, the science and the math matter as much as the beauty — tension and release, built deliberately.",
|
||||
PortraitImage1: "img/dd-daniel-bw.jpeg",
|
||||
PortraitImage2: "img/dd-daniel.jpeg"),
|
||||
];
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
/* ── HERO — the page opener (type scale from Home's .hero-*) ── */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -35,7 +34,7 @@
|
||||
justify-content: center;
|
||||
padding: 6rem 3rem;
|
||||
position: relative;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -43,7 +42,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -56,7 +55,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.8rem;
|
||||
display: flex;
|
||||
@@ -70,7 +69,7 @@
|
||||
display: block;
|
||||
width: 2.5rem;
|
||||
height: 1px;
|
||||
background: var(--deepdrft-green-accent);
|
||||
background: var(--deepdrft-green);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
@@ -79,21 +78,21 @@
|
||||
font-weight: 300;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 0.5rem;
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
|
||||
.hero-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.75;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.7;
|
||||
max-width: 36ch;
|
||||
margin-bottom: 3rem;
|
||||
@@ -109,7 +108,7 @@
|
||||
.movement {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 14%) minmax(0, 1fr);
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -141,14 +140,14 @@
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.14;
|
||||
padding-left: 1.4rem;
|
||||
transition: color 0.5s ease, opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.movement.is-active .rail-numeral {
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
@@ -163,7 +162,7 @@
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
transform-origin: center;
|
||||
@@ -195,7 +194,7 @@
|
||||
|
||||
.wave-stroke path {
|
||||
fill: none;
|
||||
stroke: var(--deepdrft-green-accent);
|
||||
stroke: var(--deepdrft-green);
|
||||
stroke-width: 1.4;
|
||||
opacity: 0.7;
|
||||
vector-effect: non-scaling-stroke;
|
||||
@@ -207,7 +206,7 @@
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -221,7 +220,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
@@ -231,20 +230,20 @@
|
||||
font-size: clamp(2.6rem, 5vw, 4.2rem);
|
||||
font-weight: 300;
|
||||
line-height: 1.02;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.movement-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.movement-prose {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.85;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.72;
|
||||
max-width: 56ch;
|
||||
}
|
||||
@@ -279,14 +278,15 @@
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-white)) 0%,
|
||||
color-mix(in srgb, var(--deepdrft-navy) 16%, var(--deepdrft-white)) 100%);
|
||||
color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-page-surface)) 0%,
|
||||
color-mix(in srgb, var(--deepdrft-navy) 16%, var(--deepdrft-page-surface)) 100%);
|
||||
}
|
||||
|
||||
/* The marginalia caption — mono, sits directly under the framed portrait. */
|
||||
@@ -295,7 +295,7 @@
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
margin-top: 0.9rem;
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
@@ -309,7 +309,7 @@
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.1;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.8;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -339,7 +339,7 @@
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
@@ -467,7 +467,7 @@
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@
|
||||
font-size: clamp(1.8rem, 3.4vw, 2.9rem);
|
||||
font-weight: 300;
|
||||
line-height: 1.15;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
}
|
||||
|
||||
/* ══════════════════ CLOSING CTA (reused vocabulary) ══════════════════ */
|
||||
@@ -606,6 +606,14 @@
|
||||
|
||||
.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 ══════════════════
|
||||
|
||||
Below 960px the rail collapses: the spine + vertical numeral can't survive a
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@page "/cuts"
|
||||
@using DeepDrftModels.Enums
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>DeepDrft Cuts</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Cut, "/cuts")" />
|
||||
|
||||
@* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table.
|
||||
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
@page "/archive"
|
||||
@using DeepDrftModels.Enums
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>DeepDrft Archive</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForBrowse(Seo, null, "/archive")" />
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 36px 0 20px 0;
|
||||
padding: 12px 0 20px 0;
|
||||
}
|
||||
|
||||
.archive-controls-search {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits CutDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
|
||||
@inject SeoOptions Seo
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
@@ -17,6 +16,9 @@
|
||||
}
|
||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
{
|
||||
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
|
||||
(mirrors the dedicated /404 NotFound page). *@
|
||||
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
||||
@@ -37,6 +39,14 @@ else
|
||||
var hasYear = release.ReleaseDate is not null;
|
||||
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
|
||||
|
||||
@* SEO head — fed from the same bridged release + ordered tracks, so the prerender and WASM passes
|
||||
render identical tags (AC6). MusicAlbum/StudioAlbum with the ordered track list (§3.4). *@
|
||||
<SeoHead Model="@SeoModel.ForRelease(Seo, release, ViewModel.Tracks)" />
|
||||
|
||||
@* Full-screen content body (Phase 20 Wave 2 §1): the scaffold has no Class param, so a thin wrapper
|
||||
carries the min-height. dd-detail-fill keeps the body >= viewport height (below the nav) so the
|
||||
ambient visualizer reads full-screen and the site footer is pushed below the fold. *@
|
||||
<div class="dd-detail-fill">
|
||||
<ReleaseDetailScaffold Title="@release.Title"
|
||||
Artist="@release.Artist"
|
||||
Track="@firstTrack"
|
||||
@@ -54,11 +64,24 @@ else
|
||||
TrackEntryKey="@firstTrack?.EntryKey" />
|
||||
</Ambient>
|
||||
<TopRightAction>
|
||||
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both are
|
||||
controls over the experience, not release content, so both stay in Theater Mode (§4/OQ4).
|
||||
Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
|
||||
<div class="dd-detail-top-actions">
|
||||
@* Theater toggle only appears when this Cut is the currently-playing release (Phase 20
|
||||
Wave 2 §3). ShowTheaterToggle folds in the subsystem gate + the release-playing check. *@
|
||||
<TheaterModeToggle Available="ShowTheaterToggle" />
|
||||
@* Lava-lamp icon → popover panel (full parity, §3d-revised). Sits top-right across from the
|
||||
back link, clear of the header's own Play/Share affordances below. *@
|
||||
<WaveformVisualizerControlPopover />
|
||||
</div>
|
||||
</TopRightAction>
|
||||
<Header>
|
||||
@* Theater Mode (Phase 20 §4, Wave 2 §2): the release content stays mounted and eases out via
|
||||
a collapsing wrapper so it does not pop — IsContentHidden collapses it to zero height when
|
||||
Theater is on AND this Cut is the playing release. OFF eases it back to its normal layout. *@
|
||||
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
|
||||
<div class="dd-theater-collapsible-inner">
|
||||
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
|
||||
<div class="cut-detail-header">
|
||||
<div class="cut-detail-meta">
|
||||
@@ -83,7 +106,7 @@ else
|
||||
</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,
|
||||
closed P11 W1). Disabled until at least one streamable track is resolved. *@
|
||||
<MudButton Variant="Variant.Filled"
|
||||
@@ -117,8 +140,13 @@ else
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
<BodyContent>
|
||||
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Header region. *@
|
||||
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
|
||||
<div class="dd-theater-collapsible-inner">
|
||||
@* Blurb sits between the header and the track-list divider. *@
|
||||
<ReleaseDescription Description="@release.Description" />
|
||||
<MudDivider Class="cut-detail-divider" />
|
||||
@@ -133,7 +161,7 @@ else
|
||||
{
|
||||
var track = ViewModel.Tracks[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>
|
||||
<div class="cut-detail-track-play">
|
||||
<PlayStateIcon Track="@track"
|
||||
@@ -149,12 +177,15 @@ else
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</BodyContent>
|
||||
</ReleaseDetailScaffold>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
// PlayerService is cascaded by CutDetailBase (used there for the Theater release-playing predicate).
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
// Header Play: load the full album into the queue starting at track 0.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -19,7 +20,41 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
|
||||
[Inject] public required CutDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
|
||||
// Theater Mode (Phase 20). The page owns the content gate, so it must re-render when the flag flips
|
||||
// on the toggle. Property-injected; no constructor growth.
|
||||
[Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; }
|
||||
|
||||
// Theater Mode is scoped to the currently-playing release (Phase 20 Wave 2 §3). The page observes
|
||||
// player state so the toggle availability and content gate re-evaluate live when playback starts,
|
||||
// stops, or moves to a different release. Cascaded by AudioPlayerProvider; no constructor growth.
|
||||
// The cascade is IsFixed, so the provider's own re-render does not reach this page — the page must
|
||||
// subscribe to StateChanged to re-render itself (same posture as AudioPlayerBar).
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
private IStreamingPlayerService? _subscribedPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// True when the currently-playing track belongs to this page's release. Theater Mode only applies
|
||||
/// to the playing release: a detail page whose release is not playing ignores the global flag and
|
||||
/// shows no toggle. Identity is the release <c>EntryKey</c> — the canonical public key the routes
|
||||
/// and <see cref="DeepDrftPublic.Client.Common.ReleaseRoutes"/> use.
|
||||
/// </summary>
|
||||
protected bool IsThisReleasePlaying =>
|
||||
PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey;
|
||||
|
||||
/// <summary>
|
||||
/// True when this page's release content should be hidden for Theater Mode — only when Theater is on
|
||||
/// AND this release is the one playing. Drives the eased collapse of the header/track-list regions.
|
||||
/// </summary>
|
||||
protected bool IsContentHidden => VisualizerControlState.TheaterMode && IsThisReleasePlaying;
|
||||
|
||||
/// <summary>
|
||||
/// True when the Theater toggle should be offered on this page: a visualizer subsystem is on AND
|
||||
/// this page's release is the one playing.
|
||||
/// </summary>
|
||||
protected bool ShowTheaterToggle =>
|
||||
(VisualizerControlState.LavaEnabled || VisualizerControlState.WaveformEnabled) && IsThisReleasePlaying;
|
||||
|
||||
// The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g.
|
||||
// /cuts/{a} -> /cuts/{b}) which reuse this component instance and fire OnParametersSet without
|
||||
@@ -28,10 +63,29 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
|
||||
private bool _loaded;
|
||||
|
||||
protected override void OnInitialized()
|
||||
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
{
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
VisualizerControlState.Changed += OnVisualizerStateChanged;
|
||||
}
|
||||
|
||||
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// The player cascade is IsFixed, so the provider's re-render does not reach this page; subscribe
|
||||
// to the StateChanged side-channel to re-render when playback moves between releases. Idempotent
|
||||
// (reference-guarded) and unsubscribed on dispose — same posture as AudioPlayerBar.
|
||||
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedPlayer))
|
||||
{
|
||||
if (_subscribedPlayer is not null)
|
||||
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
|
||||
|
||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||
_subscribedPlayer = PlayerService;
|
||||
}
|
||||
|
||||
if (_loaded && _loadedKey == EntryKey) return;
|
||||
|
||||
// Capture the key synchronously before any await so a re-entrant call (rapid navigation or a
|
||||
@@ -61,7 +115,16 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
_persistingSubscription.Dispose();
|
||||
VisualizerControlState.Changed -= OnVisualizerStateChanged;
|
||||
if (_subscribedPlayer is not null)
|
||||
{
|
||||
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
|
||||
_subscribedPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
|
||||
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
@page "/"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForHome(Seo)" />
|
||||
|
||||
@* Hero - split 50/50 *@
|
||||
<section class="hero">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
justify-content: center;
|
||||
padding: 6rem 3rem;
|
||||
position: relative;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 2rem 3rem;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
@@ -43,7 +43,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.25em;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
/* ── SECTION (sound) ── */
|
||||
.section {
|
||||
padding: 7rem 3rem;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@@ -64,7 +64,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
@@ -74,12 +74,12 @@
|
||||
font-size: clamp(2.8rem, 5vw, 4.5rem);
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
}
|
||||
|
||||
.section-title em {
|
||||
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
|
||||
@@ -106,7 +106,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.8;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.65;
|
||||
max-width: 52ch;
|
||||
}
|
||||
@@ -130,7 +130,7 @@
|
||||
}
|
||||
|
||||
.medium-card {
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
border: 1px solid var(--deepdrft-border);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
@@ -189,7 +189,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
@@ -198,7 +198,7 @@
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
@@ -207,7 +207,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.65;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: clamp(2rem, 3.5vw, 3rem);
|
||||
font-weight: 300;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
line-height: 1.05;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -417,7 +417,7 @@
|
||||
|
||||
.connect-option:hover {
|
||||
border-color: var(--deepdrft-green-accent);
|
||||
background: #f3f6f4;
|
||||
background: color-mix(in srgb, var(--deepdrft-page-surface), var(--deepdrft-green-accent) 8%);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
@@ -426,14 +426,16 @@
|
||||
display: flex;
|
||||
align-items: 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;
|
||||
}
|
||||
|
||||
.option-icon svg {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
stroke: var(--deepdrft-white);
|
||||
stroke: var(--deepdrft-page-surface);
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
@@ -442,14 +444,14 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.option-text-sub {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.75rem;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -558,6 +560,14 @@
|
||||
|
||||
.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) {
|
||||
.cta-banner {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits ReleaseDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
|
||||
@inject SeoOptions Seo
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
@@ -16,6 +15,9 @@
|
||||
}
|
||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
{
|
||||
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
|
||||
(mirrors the dedicated /404 NotFound page). *@
|
||||
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
|
||||
@@ -32,6 +34,11 @@ else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
else
|
||||
{
|
||||
var release = ViewModel.Release;
|
||||
var mixTracks = ViewModel.Track is not null ? new[] { ViewModel.Track } : null;
|
||||
|
||||
@* SEO head — fed from the same bridged release + single track, so prerender and WASM render identical
|
||||
tags (AC6). MusicRecording with ISO-8601 duration from the track (§3.4). *@
|
||||
<SeoHead Model="@SeoModel.ForRelease(Seo, release, mixTracks)" />
|
||||
|
||||
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
|
||||
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
|
||||
@@ -41,7 +48,7 @@ else
|
||||
TrackId="@ViewModel.Track?.Id"
|
||||
TrackEntryKey="@ViewModel.Track?.EntryKey" />
|
||||
|
||||
<div class="mix-detail-foreground">
|
||||
<div class="mix-detail-foreground dd-detail-fill">
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
|
||||
@* Mix keeps the scaffold solely for the Phase 10 top row (back link | controls | lava-lamp).
|
||||
Title/artist/genre/date/share/play all move into the overlaid hero, so the scaffold's
|
||||
@@ -56,13 +63,26 @@ else
|
||||
ShowMeta="false"
|
||||
ShowShareRow="false">
|
||||
<TopRightAction>
|
||||
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both stay
|
||||
visible in Theater Mode — controls over the experience, not release content (§4/OQ4).
|
||||
Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
|
||||
<div class="dd-detail-top-actions">
|
||||
@* Theater toggle only appears when this Mix is the currently-playing release
|
||||
(Phase 20 Wave 2 §3). ShowTheaterToggle folds in the subsystem + release-playing gate. *@
|
||||
<TheaterModeToggle Available="ShowTheaterToggle" />
|
||||
@* Lava-lamp icon → popover panel, top-right across from the back link (Phase 12
|
||||
§3d-revised). Replaces the former inline TopContent knob-bar: the icon IS the toggle
|
||||
and the popover IS the panel. Mix takes the cleanest anchor case (§8e) — the popover's
|
||||
default bottom-right anchor opens down over the full-bleed field. *@
|
||||
<WaveformVisualizerControlPopover />
|
||||
</div>
|
||||
</TopRightAction>
|
||||
<Hero>
|
||||
@* Theater Mode (Phase 20 §4, Wave 2 §2): the hero overlay stays mounted and eases out via
|
||||
a collapsing wrapper so it does not pop — collapsed to zero height when Theater is on AND
|
||||
this Mix is the playing release. OFF eases the full-bleed visualizer back behind the hero. *@
|
||||
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
|
||||
<div class="dd-theater-collapsible-inner">
|
||||
@* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The
|
||||
cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
|
||||
to null). Share and play ride in as slots, matching Sessions. *@
|
||||
@@ -86,10 +106,17 @@ else
|
||||
}
|
||||
</PlayContent>
|
||||
</ReleaseHeroOverlay>
|
||||
</div>
|
||||
</div>
|
||||
</Hero>
|
||||
<BodyContent>
|
||||
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Hero region. *@
|
||||
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
|
||||
<div class="dd-theater-collapsible-inner">
|
||||
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
|
||||
<ReleaseDescription Description="@release.Description" />
|
||||
</div>
|
||||
</div>
|
||||
</BodyContent>
|
||||
</ReleaseDetailScaffold>
|
||||
</MudContainer>
|
||||
@@ -99,11 +126,14 @@ else
|
||||
@code {
|
||||
protected override string PersistKey => "mix-detail";
|
||||
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
// PlayerService is cascaded by ReleaseDetailBase (used there for the Theater release-playing predicate).
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
// The hero now carries the play affordance (the scaffold's header is suppressed), so the
|
||||
// play-toggle is wired here directly — mirroring SessionDetail. Toggle if this track is already
|
||||
// active, otherwise start a fresh stream.
|
||||
// active, otherwise PLAY it: prepend to the queue's front (deque PLAY semantics) so it becomes
|
||||
// current and the existing queue stays intact behind it. Falls back to a direct stream when the
|
||||
// queue cascade is absent (prerender / non-interactive).
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
var track = ViewModel.Track;
|
||||
@@ -114,6 +144,10 @@ else
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else if (Queue is not null)
|
||||
{
|
||||
await Queue.PlayTrack(track);
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@page "/mixes"
|
||||
@using DeepDrftModels.Enums
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@inherits MediumBrowseBase
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>DeepDrft Mixes</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Mix, "/mixes")" />
|
||||
|
||||
<ReleaseGallery Releases="@Releases"
|
||||
Loading="@Loading"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
@page "/404"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@inject SeoOptions Seo
|
||||
|
||||
@* The 404 must not be indexed (AC8): noindex,follow — no canonical, no JSON-LD. *@
|
||||
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
|
||||
|
||||
<MudText Typo="Typo.h3">
|
||||
Not Found
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -17,7 +18,41 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
|
||||
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
|
||||
// Theater Mode (Phase 20). The page owns the content gate, so it must re-render when the flag flips
|
||||
// on the toggle. Property-injected; no constructor growth.
|
||||
[Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; }
|
||||
|
||||
// Theater Mode is scoped to the currently-playing release (Phase 20 Wave 2 §3). The page observes
|
||||
// player state so the toggle availability and content gate re-evaluate live when playback starts,
|
||||
// stops, or moves to a different release. Cascaded by AudioPlayerProvider; no constructor growth.
|
||||
// The cascade is IsFixed, so the provider's own re-render does not reach this page — the page must
|
||||
// subscribe to StateChanged to re-render itself (same posture as AudioPlayerBar).
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
private IStreamingPlayerService? _subscribedPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// True when the currently-playing track belongs to this page's release. Theater Mode only applies
|
||||
/// to the playing release: a detail page whose release is not playing ignores the global flag and
|
||||
/// shows no toggle. Identity is the release <c>EntryKey</c> — the canonical public key the routes
|
||||
/// and <see cref="DeepDrftPublic.Client.Common.ReleaseRoutes"/> use.
|
||||
/// </summary>
|
||||
protected bool IsThisReleasePlaying =>
|
||||
PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey;
|
||||
|
||||
/// <summary>
|
||||
/// True when this page's release content should be hidden for Theater Mode — only when Theater is on
|
||||
/// AND this release is the one playing. Drives the eased collapse of the hero/blurb regions.
|
||||
/// </summary>
|
||||
protected bool IsContentHidden => VisualizerControlState.TheaterMode && IsThisReleasePlaying;
|
||||
|
||||
/// <summary>
|
||||
/// True when the Theater toggle should be offered on this page: a visualizer subsystem is on AND
|
||||
/// this page's release is the one playing.
|
||||
/// </summary>
|
||||
protected bool ShowTheaterToggle =>
|
||||
(VisualizerControlState.LavaEnabled || VisualizerControlState.WaveformEnabled) && IsThisReleasePlaying;
|
||||
|
||||
// The release EntryKey the ViewModel currently holds. Tracks param-only navigations (e.g.
|
||||
// /mixes/{a} -> /mixes/{b}) which reuse this component instance and fire OnParametersSet
|
||||
@@ -30,10 +65,29 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
|
||||
protected abstract string PersistKey { get; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
{
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
VisualizerControlState.Changed += OnVisualizerStateChanged;
|
||||
}
|
||||
|
||||
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// The player cascade is IsFixed, so the provider's re-render does not reach this page; subscribe
|
||||
// to the StateChanged side-channel to re-render when playback moves between releases. Idempotent
|
||||
// (reference-guarded) and unsubscribed on dispose — same posture as AudioPlayerBar.
|
||||
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedPlayer))
|
||||
{
|
||||
if (_subscribedPlayer is not null)
|
||||
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
|
||||
|
||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||
_subscribedPlayer = PlayerService;
|
||||
}
|
||||
|
||||
// Re-run whenever the route key changes. Component instances are reused across
|
||||
// same-template navigations, so the load decision must live here, not in
|
||||
// OnInitialized (which fires once per instance).
|
||||
@@ -69,7 +123,16 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
_persistingSubscription.Dispose();
|
||||
VisualizerControlState.Changed -= OnVisualizerStateChanged;
|
||||
if (_subscribedPlayer is not null)
|
||||
{
|
||||
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
|
||||
_subscribedPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
|
||||
protected sealed record BridgedDetail(ReleaseDto Release, TrackDto? Track);
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits ReleaseDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
|
||||
@inject SeoOptions Seo
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
@@ -20,6 +19,9 @@
|
||||
}
|
||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
{
|
||||
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
|
||||
(mirrors the dedicated /404 NotFound page). *@
|
||||
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Session not found.</MudText>
|
||||
@@ -40,6 +42,10 @@ else
|
||||
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
|
||||
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
|
||||
|
||||
@* SEO head — fed from the same bridged release, so prerender and WASM render identical tags (AC6).
|
||||
MusicAlbum/LiveAlbum (a session is a live release, §3.4/OQ6). *@
|
||||
<SeoHead Model="@SeoModel.ForRelease(Seo, release)" />
|
||||
|
||||
@* Ambient living waveform behind the hero overlay (Phase 12 §3e option b / §3f mode B). Session does
|
||||
NOT compose ReleaseDetailScaffold, so it mounts the shared engine directly with its own thin
|
||||
full-bleed wrapper — the engine is single-source either way, only the mount differs (§3b). The
|
||||
@@ -49,18 +55,30 @@ else
|
||||
TrackId="@ViewModel.Track?.Id"
|
||||
TrackEntryKey="@ViewModel.Track?.EntryKey" />
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page session-detail-foreground">
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page session-detail-foreground dd-detail-fill">
|
||||
|
||||
<div class="session-detail-top-row">
|
||||
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
|
||||
← All sessions
|
||||
</MudLink>
|
||||
|
||||
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). The whole top
|
||||
row (back + theater + lava) stays in Theater Mode — controls, not release content (§4/OQ4). *@
|
||||
<div class="dd-detail-top-actions">
|
||||
@* Theater toggle only appears when this Session is the currently-playing release
|
||||
(Phase 20 Wave 2 §3). ShowTheaterToggle folds in the subsystem + release-playing gate. *@
|
||||
<TheaterModeToggle Available="ShowTheaterToggle" />
|
||||
@* Lava-lamp icon → popover panel (full parity, §3e/§3d-revised). Anchored top-right, clear of
|
||||
the hero overlay and the share/play affordances overlaid on the hero below. *@
|
||||
<WaveformVisualizerControlPopover />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Theater Mode (Phase 20 §4, Wave 2 §2): the hero overlay + blurb stay mounted and ease out via a
|
||||
collapsing wrapper so they do not pop — collapsed to zero height when Theater is on AND this
|
||||
Session is the playing release. The top row above stays. OFF eases this region back in. *@
|
||||
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
|
||||
<div class="dd-theater-collapsible-inner">
|
||||
@* The overlay shows the cover thumbnail only when it differs from the resolved hero image —
|
||||
when there is no dedicated hero, heroImage already falls back to release.ImagePath, so the
|
||||
thumb would duplicate the background. That logic lives in ReleaseHeroOverlay. *@
|
||||
@@ -86,6 +104,8 @@ else
|
||||
</ReleaseHeroOverlay>
|
||||
|
||||
<ReleaseDescription Description="@release.Description" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</MudContainer>
|
||||
}
|
||||
@@ -93,11 +113,14 @@ else
|
||||
@code {
|
||||
protected override string PersistKey => "session-detail";
|
||||
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
// PlayerService is cascaded by ReleaseDetailBase (used there for the Theater release-playing predicate).
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
// Mirrors the play-toggle wiring the shared scaffold owns. Session detail composes the player
|
||||
// affordance directly (it diverges from ReleaseDetailScaffold for the overlay layout), so the
|
||||
// toggle logic lives here: toggle if this track is already active, otherwise start a fresh stream.
|
||||
// toggle logic lives here: toggle if this track is already active, otherwise PLAY it — prepend to
|
||||
// the queue's front (deque PLAY semantics) so it becomes current and the existing queue stays
|
||||
// intact behind it. Falls back to a direct stream when the queue cascade is absent.
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
var track = ViewModel.Track;
|
||||
@@ -108,6 +131,10 @@ else
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else if (Queue is not null)
|
||||
{
|
||||
await Queue.PlayTrack(track);
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@page "/sessions"
|
||||
@using DeepDrftModels.Enums
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@inherits MediumBrowseBase
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>DeepDrft Sessions</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Session, "/sessions")" />
|
||||
|
||||
<ReleaseGallery Releases="@Releases"
|
||||
Loading="@Loading"
|
||||
|
||||
@@ -10,18 +10,35 @@ namespace DeepDrftPublic.Client.Services;
|
||||
/// — it adds no new playback semantics.
|
||||
///
|
||||
/// <para>
|
||||
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are
|
||||
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/
|
||||
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/>
|
||||
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> +
|
||||
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only
|
||||
/// adding new ones — so consumers written against today's surface keep working.
|
||||
/// <b>Two-level deque model (the load-bearing invariant).</b> The queue is a deque whose
|
||||
/// <see cref="Current"/> track (the item at <see cref="CurrentIndex"/>) is the live "front of play".
|
||||
/// Two families of mutation enter the deque from opposite ends:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>PLAY (manual)</b> — <see cref="PlayTrack"/> / <see cref="PlayRelease"/> prepend to the
|
||||
/// <em>front</em>. The previously-current track is removed, the prepended track(s) become the head
|
||||
/// in order, the new head becomes current and starts streaming, and whatever sat after the old
|
||||
/// current stays intact behind the prepend. A whole release prepends in order in one operation.</item>
|
||||
/// <item><b>Add-to-queue</b> — <see cref="Enqueue"/> / <see cref="EnqueueRange"/> append to the
|
||||
/// <em>end</em>. They never interrupt the current track and never start playback.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Advance and end-of-track.</b> <see cref="Next"/> and auto-advance (the player's
|
||||
/// <see cref="IPlayerService.TrackEnded"/>) walk <see cref="CurrentIndex"/> forward, leaving the just-
|
||||
/// played track in the list behind the pointer so <see cref="Previous"/> can step back to it. The one
|
||||
/// exception is the <em>last</em> track: when the current track ends naturally and there is nothing
|
||||
/// after it, the queue <b>empties</b> and goes dormant (<see cref="CurrentIndex"/> == -1) rather than
|
||||
/// stranding the finished track as current.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// With an empty queue (<see cref="CurrentIndex"/> == -1) the queue is dormant: it drives nothing and
|
||||
/// auto-advances nothing, so direct single-track play through the player behaves exactly as it did
|
||||
/// before the queue existed.
|
||||
/// before the queue existed. The <b>first</b> <see cref="Enqueue"/>/<see cref="EnqueueRange"/> into a
|
||||
/// dormant queue while a track is already playing externally seeds the head from the player's current
|
||||
/// track (learned through the attached player, no extra dependency) and then appends the added item, so
|
||||
/// the resulting deque is <c>[now-playing, added…]</c> rather than a phantom single entry.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IQueueService
|
||||
@@ -41,9 +58,10 @@ public interface IQueueService
|
||||
/// <summary>
|
||||
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet —
|
||||
/// the embed's pre-gesture state. Set by <see cref="Arm"/>; cleared the moment playback actually
|
||||
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="Next"/>/<see cref="Previous"/>)
|
||||
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through
|
||||
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone.
|
||||
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="PlayTrack"/>/<see cref="Next"/>/
|
||||
/// <see cref="Previous"/>) or on <see cref="Clear"/>. The player bar reads this to route the first
|
||||
/// play gesture through <see cref="Start"/> (which begins the armed release) rather than streaming
|
||||
/// the staged track alone.
|
||||
/// </summary>
|
||||
bool IsArmed { get; }
|
||||
|
||||
@@ -55,26 +73,40 @@ public interface IQueueService
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever the queue's contents or current position change. The player bar subscribes
|
||||
/// to re-render its skip-forward/back affordances. Fires on enqueue, advance, step-back, and clear.
|
||||
/// to re-render its skip-forward/back affordances. Fires on enqueue, prepend, advance, step-back,
|
||||
/// and clear.
|
||||
/// </summary>
|
||||
event Action? QueueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming
|
||||
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts
|
||||
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses
|
||||
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to
|
||||
/// the end from there. No-op when <paramref name="tracks"/> is empty.
|
||||
/// Manual PLAY of a single track: prepends <paramref name="track"/> to the <em>front</em> of the
|
||||
/// deque, removes the previously-current track, makes <paramref name="track"/> the new head/current,
|
||||
/// and starts streaming it. The rest of the queue (everything that sat after the old current) stays
|
||||
/// intact behind the new head. Into a dormant queue this simply becomes the sole head and plays.
|
||||
/// This is the deque-front counterpart to the append-only <see cref="Enqueue"/>.
|
||||
/// </summary>
|
||||
Task PlayTrack(TrackDto track);
|
||||
|
||||
/// <summary>
|
||||
/// Manual PLAY of a release: prepends <paramref name="tracks"/> (in the order given) to the
|
||||
/// <em>front</em> of the deque, removes the previously-current track, and starts streaming the
|
||||
/// prepended track at <paramref name="startIndex"/> — which becomes current. Tracks prepended
|
||||
/// before <paramref name="startIndex"/> sit behind the pointer (reachable via <see cref="Previous"/>);
|
||||
/// tracks after it are up-next; whatever sat after the old current stays intact behind the whole
|
||||
/// prepend. This is the "play album" entry point the detail pages consume: a header Play uses
|
||||
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index. No-op when
|
||||
/// <paramref name="tracks"/> is empty.
|
||||
/// </summary>
|
||||
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT
|
||||
/// streaming anything — the queue is "armed". This is the embed's prerender-safe entry point: it
|
||||
/// performs no JS interop, so it runs identically during prerender and after WASM boot. The first
|
||||
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which
|
||||
/// keeps the loaded release queued so it advances through its tracks. No-op when
|
||||
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed).
|
||||
/// performs no JS interop, so it runs identically during prerender and after WASM boot. It replaces
|
||||
/// the queue (an armed embed is a fresh staged release, not a prepend). The first play gesture (see
|
||||
/// <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which keeps the loaded
|
||||
/// release queued so it advances through its tracks. No-op when <paramref name="tracks"/> is empty
|
||||
/// (the queue stays empty and disarmed).
|
||||
/// </summary>
|
||||
void Arm(IEnumerable<TrackDto> tracks);
|
||||
|
||||
@@ -88,18 +120,24 @@ public interface IQueueService
|
||||
Task Start();
|
||||
|
||||
/// <summary>
|
||||
/// Appends a track to the end of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
|
||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
||||
/// Appends a track to the <em>end</em> of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) while a track is already playing
|
||||
/// externally (through the attached player but not via the queue), the append first seeds the head
|
||||
/// with that now-playing track, then appends <paramref name="track"/> — yielding
|
||||
/// <c>[now-playing, track]</c> so the queue reflects what the listener actually hears. Into a fully
|
||||
/// dormant queue with nothing playing, the single appended track becomes the head at
|
||||
/// <see cref="CurrentIndex"/> == 0. Either way it does NOT begin playback (add is not play).
|
||||
/// Interop-free; safe during prerender.
|
||||
/// </summary>
|
||||
void Enqueue(TrackDto track);
|
||||
|
||||
/// <summary>
|
||||
/// Appends tracks to the end of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
|
||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
||||
/// Appends tracks to the <em>end</em> of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue while a track is already playing externally, the append first seeds the head
|
||||
/// with that now-playing track (see <see cref="Enqueue"/>), then appends the range. Into a fully
|
||||
/// dormant queue with nothing playing, the first appended track becomes the head at
|
||||
/// <see cref="CurrentIndex"/> == 0. It does NOT begin playback (add is not play). Interop-free; safe
|
||||
/// during prerender.
|
||||
/// </summary>
|
||||
void EnqueueRange(IEnumerable<TrackDto> tracks);
|
||||
|
||||
@@ -136,6 +174,15 @@ public interface IQueueService
|
||||
/// </summary>
|
||||
Task Previous();
|
||||
|
||||
/// <summary>
|
||||
/// Moves the current pointer to <paramref name="index"/> and streams that track once. This is the
|
||||
/// row-jump primitive the open playlist panel uses: unlike <see cref="PlayRelease"/> it does not
|
||||
/// prepend (the track is already in the deque), and unlike repeated <see cref="Next"/> it does not
|
||||
/// stream the intervening rows. No-op when <paramref name="index"/> is out of range or already
|
||||
/// current.
|
||||
/// </summary>
|
||||
Task JumpTo(int index);
|
||||
|
||||
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
|
||||
void Clear();
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ using DeepDrftModels.DTOs;
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IQueueService"/>: a single-slot orchestrator over an
|
||||
/// Default <see cref="IQueueService"/>: a two-level deque orchestrator over an
|
||||
/// <see cref="IStreamingPlayerService"/>. Holds the ordered list and current index as pure state,
|
||||
/// drives playback through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>,
|
||||
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal.
|
||||
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal. PLAY mutations enter
|
||||
/// the front (prepend); add-to-queue mutations enter the back (append) — see <see cref="IQueueService"/>
|
||||
/// for the full invariant.
|
||||
///
|
||||
/// <para>
|
||||
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
|
||||
@@ -14,7 +16,8 @@ namespace DeepDrftPublic.Client.Services;
|
||||
/// creates the player) rather than constructor injection. This keeps the player single-slot, avoids a
|
||||
/// construction cycle between provider/player/queue, and needs no <c>IServiceProvider</c>. The queue's
|
||||
/// own constructor stays parameterless, so the queue logic is unit-testable against a fake player with
|
||||
/// no container.
|
||||
/// no container. The attached player is also the seam by which the queue learns the externally-playing
|
||||
/// track when a dormant <see cref="Enqueue"/> needs to seed the head.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class QueueService : IQueueService, IDisposable
|
||||
@@ -54,23 +57,42 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
_player.TrackEnded += OnTrackEnded;
|
||||
}
|
||||
|
||||
public async Task PlayTrack(TrackDto track)
|
||||
{
|
||||
PrependForPlay(new[] { track }, prependIndex: 0);
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
|
||||
{
|
||||
var list = tracks.ToList();
|
||||
if (list.Count == 0) return;
|
||||
|
||||
var start = Math.Clamp(startIndex, 0, list.Count - 1);
|
||||
|
||||
_items.Clear();
|
||||
_items.AddRange(list);
|
||||
CurrentIndex = start;
|
||||
// Playback is now starting for real, so the queue is no longer merely armed.
|
||||
IsArmed = false;
|
||||
PrependForPlay(list, start);
|
||||
QueueChanged?.Invoke();
|
||||
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
// The shared PLAY-prepend mutation (bug #5). Removes the previously-current track, inserts the
|
||||
// played track(s) at the front in order, and points CurrentIndex at the prepended item the caller
|
||||
// chose to start on. Whatever sat AFTER the old current stays intact behind the prepend; the old
|
||||
// back-history (items before the old current) is discarded because a fresh PLAY defines a new
|
||||
// front. Pure state — callers invoke QueueChanged + PlayCurrent. IsArmed clears: playback is real now.
|
||||
private void PrependForPlay(IReadOnlyList<TrackDto> played, int prependIndex)
|
||||
{
|
||||
// Drop the previously-current track only (its tail — the up-next after it — is preserved).
|
||||
// Anything before the old current is back-history that a new PLAY supersedes.
|
||||
if (CurrentIndex >= 0 && CurrentIndex < _items.Count)
|
||||
_items.RemoveRange(0, CurrentIndex + 1);
|
||||
|
||||
_items.InsertRange(0, played);
|
||||
CurrentIndex = prependIndex;
|
||||
IsArmed = false;
|
||||
}
|
||||
|
||||
public void Arm(IEnumerable<TrackDto> tracks)
|
||||
{
|
||||
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||
@@ -94,27 +116,47 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
|
||||
public void Enqueue(TrackDto track)
|
||||
{
|
||||
SeedHeadFromPlayerIfDormant();
|
||||
_items.Add(track);
|
||||
// OQ8: appending into a dormant (empty) queue leaves a coherent CurrentIndex so the next
|
||||
// play/skip is correct — but does NOT auto-play (add is not play). PlayCurrent is never
|
||||
// called here, so this stays interop-free and prerender-safe.
|
||||
if (CurrentIndex == -1)
|
||||
CurrentIndex = 0;
|
||||
EnsureCoherentDormantIndex();
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void EnqueueRange(IEnumerable<TrackDto> tracks)
|
||||
{
|
||||
var before = _items.Count;
|
||||
_items.AddRange(tracks);
|
||||
if (_items.Count == before) return;
|
||||
// OQ8: see Enqueue — first append into a dormant queue stages a coherent CurrentIndex
|
||||
// without playing. The first newly-appended track becomes current.
|
||||
if (CurrentIndex == -1)
|
||||
CurrentIndex = 0;
|
||||
var toAdd = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||
if (toAdd.Count == 0) return;
|
||||
|
||||
SeedHeadFromPlayerIfDormant();
|
||||
_items.AddRange(toAdd);
|
||||
EnsureCoherentDormantIndex();
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
// Bug #3: the first add into a dormant queue while a track is already playing externally (through
|
||||
// the attached player but not via the queue) must seed the head with that now-playing track, so the
|
||||
// append yields [now-playing, added] instead of a phantom single entry. We read the player's
|
||||
// CurrentTrack — the same seam OnTrackEnded uses — so no extra dependency is introduced. Only seeds
|
||||
// when truly dormant (empty list) AND a player track exists; a non-dormant queue is untouched.
|
||||
private void SeedHeadFromPlayerIfDormant()
|
||||
{
|
||||
if (_items.Count != 0) return;
|
||||
var playing = _player?.CurrentTrack;
|
||||
if (playing is null) return;
|
||||
|
||||
_items.Add(playing);
|
||||
CurrentIndex = 0;
|
||||
}
|
||||
|
||||
// After an append, a dormant queue (CurrentIndex == -1, e.g. nothing was playing to seed from)
|
||||
// needs a coherent head so a subsequent play/skip is correct — but add is not play, so we never
|
||||
// stream here. A queue that already has a current index is left untouched.
|
||||
private void EnsureCoherentDormantIndex()
|
||||
{
|
||||
if (CurrentIndex == -1 && _items.Count > 0)
|
||||
CurrentIndex = 0;
|
||||
}
|
||||
|
||||
public void Move(int fromIndex, int toIndex)
|
||||
{
|
||||
if (fromIndex == toIndex) return;
|
||||
@@ -192,6 +234,16 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public async Task JumpTo(int index)
|
||||
{
|
||||
if (index < 0 || index >= _items.Count) return;
|
||||
if (index == CurrentIndex) return;
|
||||
CurrentIndex = index;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
if (_items.Count == 0 && CurrentIndex == -1) return;
|
||||
@@ -217,24 +269,41 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
|
||||
// Advance on organic end-of-stream only. TrackEnded is not raised by stop/unload/track-switch,
|
||||
// so a manual stop or a fresh single-track selection elsewhere never spuriously advances the
|
||||
// queue. When the queue is past its last track, end-of-stream simply stops — nothing to advance.
|
||||
// queue.
|
||||
//
|
||||
// Guard: only advance when the track that just ended is the queue's own current item. Call sites
|
||||
// that stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
|
||||
// Guard: only act when the track that just ended is the queue's own current item. Call sites that
|
||||
// stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
|
||||
// overwrite the player's CurrentTrack without touching the queue. If their track reaches natural
|
||||
// end, the player fires TrackEnded — but the queue's Current no longer matches the player's
|
||||
// CurrentTrack, so we must not advance. Id-based equality is used rather than ReferenceEquals
|
||||
// CurrentTrack, so we must not touch the queue. Id-based equality is used rather than ReferenceEquals
|
||||
// because DTO copies through serialisation are not reference-equal.
|
||||
//
|
||||
// When the ended track IS the queue's current: advance if there is a next track, otherwise the queue
|
||||
// has reached its end — empty it (bug #2), so the finished last track is not stranded as current and
|
||||
// the queue goes dormant (panel/button gone per HasQueue gating).
|
||||
private void OnTrackEnded()
|
||||
{
|
||||
if (!HasNext) return;
|
||||
if (_player?.CurrentTrack?.Id != Current?.Id) return;
|
||||
if (Current is null) return;
|
||||
|
||||
if (HasNext)
|
||||
{
|
||||
// Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's
|
||||
// end-of-playback callback continuation; we must not block it. Advancing kicks off the next
|
||||
// stream, whose own failures surface through the player's ErrorMessage/state — the queue does
|
||||
// not own playback error handling.
|
||||
_ = Next();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Last track ended naturally → empty the deque. The player is left alone (its stream has
|
||||
// already ended on its own); we only reset queue state.
|
||||
_items.Clear();
|
||||
CurrentIndex = -1;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PlayCurrent()
|
||||
{
|
||||
|
||||
@@ -97,6 +97,13 @@ public sealed class WaveformVisualizerControlState
|
||||
/// </summary>
|
||||
public const bool DefaultWaveformEnabled = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default Theater-mode state. <c>false</c> so a fresh page load opens with the full release page,
|
||||
/// not the bare visualizer (Phase 20 §4/OQ5). Has no TS-side anchor: Theater Mode is a page-chrome
|
||||
/// presentation flag, not a visualizer dial — the bridge never reads it.
|
||||
/// </summary>
|
||||
public const bool DefaultTheaterMode = false;
|
||||
|
||||
/// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
|
||||
/// time-span via <see cref="WaveformZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
|
||||
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
|
||||
@@ -137,12 +144,35 @@ public sealed class WaveformVisualizerControlState
|
||||
/// </summary>
|
||||
public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Whether Theater Mode is on (Phase 20). When <c>true</c> the three release-detail pages remove
|
||||
/// their release content via <c>@if</c> so the visualizer fills the surface, and the player bar
|
||||
/// grows to carry the playing release's identity. Distinct from the visualizer dials: the bridge
|
||||
/// ignores it — the pages and the player bar observe it through the same <see cref="Changed"/> seam.
|
||||
/// Gated for visibility on <see cref="LavaEnabled"/> || <see cref="WaveformEnabled"/> at the toggle.
|
||||
/// </summary>
|
||||
public bool TheaterMode { get; set; } = DefaultTheaterMode;
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
|
||||
/// affected dial(s). Mutators set the property then raise this; subscribers re-read the values.
|
||||
/// affected dial(s); the Theater-Mode observers (detail pages, player bar) subscribe to react to
|
||||
/// <see cref="TheaterMode"/>. Mutators set the property then raise this; subscribers re-read the values.
|
||||
/// </summary>
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer
|
||||
/// subsystems are off (there is nothing to go to theater FOR). Call this after mutating
|
||||
/// <see cref="LavaEnabled"/> or <see cref="WaveformEnabled"/> and before
|
||||
/// <see cref="NotifyChanged"/> so all observers see a consistent, coerced state in the same
|
||||
/// <see cref="Changed"/> cycle.
|
||||
/// </summary>
|
||||
public void CoerceTheaterMode()
|
||||
{
|
||||
if (TheaterMode && !LavaEnabled && !WaveformEnabled)
|
||||
TheaterMode = false;
|
||||
}
|
||||
|
||||
/// <summary>Raise <see cref="Changed"/>. Called by the controls component after mutating a value.</summary>
|
||||
public void NotifyChanged() => Changed?.Invoke();
|
||||
}
|
||||
|
||||
@@ -45,6 +45,16 @@ public static class Startup
|
||||
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
|
||||
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
||||
services.AddScoped<ShareTracker>();
|
||||
|
||||
// Phase 22 SEO defaults — non-secret brand constants (canonical origin, site name, default share
|
||||
// image, social links). Singleton: stateless config, identical in the server-prerender and WASM
|
||||
// passes (this method runs in both), which is what makes SeoHead's double-render output identical.
|
||||
services.AddSingleton(new SeoOptions());
|
||||
|
||||
// Environment-gated robots bridge. Scoped + [PersistentState] like DarkModeSettings: the server
|
||||
// seeds IsProduction during prerender and it rounds to the WASM pass, so SeoHead resolves the same
|
||||
// default robots in both render passes (non-production → noindex,nofollow, keeping beta uncrawled).
|
||||
services.AddScoped<SeoEnvironment>();
|
||||
}
|
||||
|
||||
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@using DeepDrftPublic.Client
|
||||
@using DeepDrftPublic.Client.Common
|
||||
@using DeepDrftPublic.Services
|
||||
@using DeepDrftShared.Client.Components
|
||||
<!DOCTYPE html>
|
||||
@@ -34,11 +35,16 @@
|
||||
@code {
|
||||
|
||||
[Inject] public required DarkModeService DarkModeService { get; set; }
|
||||
[Inject] public required SeoEnvironment SeoEnvironment { get; set; }
|
||||
[Inject] public required IWebHostEnvironment HostEnvironment { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
DarkModeService.CheckDarkMode();
|
||||
// Seed the environment-gated robots bridge during prerender; [PersistentState] rounds it to WASM
|
||||
// so both render passes resolve the same default robots (Production → index, else noindex).
|
||||
SeoEnvironment.IsProduction = HostEnvironment.IsProduction();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,14 +10,81 @@
|
||||
* read it. One observer at a time, re-pointed on each `observe` call; the var
|
||||
* resets to 0 on `unobserve` (player minimized / disposed) so the spacer
|
||||
* collapses.
|
||||
*
|
||||
* COALESCING (Phase 20 theater-flash fix). `--player-height` has two consumers:
|
||||
* the layout spacer div AND the ambient WaveformVisualizer backdrop, whose
|
||||
* `bottom` inset is this var (WaveformVisualizer.razor.css `.mix-waveform-bg`).
|
||||
* Moving that inset changes the visualizer canvas's CSS box, which fires the
|
||||
* renderer's own canvas ResizeObserver — and a GL resize CLEARS the backing
|
||||
* store. That is correct and cheap for a discrete bar-height change (breakpoint
|
||||
* reflow, minimize/expand, error banner). But Theater Mode eases the player bar's
|
||||
* "now showing" band open/closed over ~0.45s via a CSS grid-rows transition, so
|
||||
* the bar height changes EVERY FRAME of the ease. Mirroring each intermediate
|
||||
* frame here would re-clear the GL backing store ~27×, reading as a flash.
|
||||
*
|
||||
* The fix coalesces the publish with a LEADING + TRAILING edge: the first change
|
||||
* after a quiet period is written immediately (so a discrete jump — the common
|
||||
* case — has zero added latency and the clip never lags), then a rapid STREAM of
|
||||
* further changes (an animated transition) is debounced and only its SETTLED
|
||||
* end-state is written. So a Theater ease resizes the visualizer at most twice
|
||||
* (leading 1px move + final settle) instead of once per frame. The settled value
|
||||
* is always the last write, so at-rest sizing/clip stays exact; and this remains
|
||||
* the SOLE writer of `--player-height`, so the renderer's ResizeObserver stays the
|
||||
* sole canvas size writer (its invariant is untouched).
|
||||
*/
|
||||
|
||||
const HEIGHT_VAR = '--player-height';
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
function writeHeight(px: number): void {
|
||||
/**
|
||||
* Quiet window (ms) after which a pending settled height is flushed. One change
|
||||
* then silence (a discrete reflow) flushes after this delay but was ALSO written
|
||||
* on the leading edge, so the trailing flush is a no-op — discrete jumps pay no
|
||||
* latency. A continuous transition keeps resetting this timer until it ends, then
|
||||
* flushes the final height once. ~80ms comfortably exceeds a frame interval (so a
|
||||
* mid-ease frame never trips an early flush) yet settles promptly after the ease.
|
||||
*/
|
||||
const SETTLE_MS = 80;
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
let lastWritten = -1;
|
||||
let pendingHeight = -1;
|
||||
let settleTimer: number | null = null;
|
||||
|
||||
function setVar(px: number): void {
|
||||
// Round up so sub-pixel heights never leave a hairline of overlap.
|
||||
document.documentElement.style.setProperty(HEIGHT_VAR, `${Math.ceil(px)}px`);
|
||||
const rounded = Math.ceil(px);
|
||||
if (rounded === lastWritten) return;
|
||||
lastWritten = rounded;
|
||||
document.documentElement.style.setProperty(HEIGHT_VAR, `${rounded}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a measured height with leading + trailing coalescing. Leading: if no
|
||||
* settle is pending, this is the first change after a quiet period — write it now.
|
||||
* Trailing: (re)arm the settle timer so the final value of a rapid stream lands
|
||||
* once the stream stops.
|
||||
*/
|
||||
function publishHeight(px: number): void {
|
||||
pendingHeight = px;
|
||||
if (settleTimer === null) {
|
||||
// Leading edge — discrete jumps land immediately; the first frame of a
|
||||
// transition lands too (one resize), then the rest is debounced below.
|
||||
setVar(px);
|
||||
}
|
||||
if (settleTimer !== null) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
settleTimer = window.setTimeout(() => {
|
||||
settleTimer = null;
|
||||
setVar(pendingHeight);
|
||||
}, SETTLE_MS);
|
||||
}
|
||||
|
||||
function measure(entry: ResizeObserverEntry): number {
|
||||
// Prefer the border-box measurement; fall back to contentRect on the
|
||||
// (older) engines that don't populate borderBoxSize.
|
||||
const box = entry.borderBoxSize?.[0];
|
||||
return box ? box.blockSize : entry.contentRect.height;
|
||||
}
|
||||
|
||||
export function observe(element: Element): void {
|
||||
@@ -27,20 +94,28 @@ export function observe(element: Element): void {
|
||||
observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
// Prefer the border-box measurement; fall back to contentRect on the
|
||||
// (older) engines that don't populate borderBoxSize.
|
||||
const box = entry.borderBoxSize?.[0];
|
||||
writeHeight(box ? box.blockSize : entry.contentRect.height);
|
||||
publishHeight(measure(entry));
|
||||
});
|
||||
observer.observe(element);
|
||||
|
||||
// Seed synchronously so the spacer is correct on this frame, before the
|
||||
// first ResizeObserver callback fires.
|
||||
writeHeight(element.getBoundingClientRect().height);
|
||||
// first ResizeObserver callback fires. A fresh observe target is a discrete
|
||||
// change, so write it straight through (bypassing the debounce) — re-pointing
|
||||
// the observer (e.g. expanded <-> minimized) must not lag behind a settle.
|
||||
if (settleTimer !== null) {
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
setVar(element.getBoundingClientRect().height);
|
||||
}
|
||||
|
||||
export function unobserve(): void {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
writeHeight(0);
|
||||
if (settleTimer !== null) {
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
pendingHeight = -1;
|
||||
setVar(0);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.deepdrft-theme-dark,
|
||||
.deepdrft-theme-light {
|
||||
@@ -342,6 +351,75 @@ h2, h3, h4, h5, h6,
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Theater toggle + lava-lamp popover cluster on the detail-page top action row (Phase 20 §3). Keeps
|
||||
the two icon affordances adjacent on the right edge rather than letting the SpaceBetween row spread
|
||||
them apart. Shared by Cut/Mix (scaffold TopRightAction) and Session (its own top row). */
|
||||
.dd-detail-top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Full-screen detail body (Phase 20 Wave 2 §1). The content body always fills the viewport below the
|
||||
fixed nav so the ambient/full-bleed visualizer reads as genuinely full-screen and the footer is pushed
|
||||
below the fold (scroll to reach it) — independent of Theater Mode. Reuses the shared
|
||||
--deepdrft-nav-height token (88px desktop / 72px mobile) so the clearance tracks the bar across
|
||||
breakpoints; no new layout token. Applied to each detail page's foreground content container. */
|
||||
.dd-detail-fill {
|
||||
min-height: calc(100vh - var(--deepdrft-nav-height, 88px));
|
||||
}
|
||||
|
||||
/* Eased content collapse for Theater Mode (Phase 20 Wave 2 §2). The detail content stays mounted and
|
||||
collapses smoothly when .dd-theater-collapsed is applied, so toggling Theater eases both directions
|
||||
instead of popping — when collapsed the content is fully out of the way and the visualizer is
|
||||
unobstructed. The same pattern drives the player-bar "now showing" band so the bar grows/shrinks
|
||||
smoothly too.
|
||||
|
||||
Technique: grid-template-rows 1fr → 0fr interpolates the REAL content height (no 400vh ceiling
|
||||
artifact / delayed-start that the old max-height approach had). The direct child receives
|
||||
overflow:hidden + min-height:0 so it actually clips during the transition (the grid child is the
|
||||
collapsing unit). visibility:hidden removes all descendants from the tab order and from pointer/
|
||||
keyboard interaction once collapsed — this fixes the Major accessibility defect where Tab could
|
||||
reach hidden controls. transition-behavior:allow-discrete makes visibility flip discretely: it
|
||||
flips to hidden AFTER the ease-out finishes (so the animation plays fully), and flips back to
|
||||
visible BEFORE the ease-in starts (so content is immediately interactive on the way back in).
|
||||
The visibility transition duration matches the height ease (0.45s) so allow-discrete has a real
|
||||
interval to defer against: on collapse the flip to hidden is held until t=0.45s; on reopen it
|
||||
fires at t=0 (immediately interactive). A 0s duration would fire the flip at t≈0 on collapse,
|
||||
defeating the deferral and hiding content before the ease-out finishes. */
|
||||
.dd-theater-collapsible {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: grid-template-rows 0.45s ease, opacity 0.3s ease, visibility 0.45s;
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
/* The single direct child clips itself during the grid-row collapse. min-height:0 overrides the
|
||||
implicit min-height:auto that would prevent the row from shrinking past the content's intrinsic
|
||||
height. overflow:hidden clips painted content when the row is partially collapsed. */
|
||||
.dd-theater-collapsible > * {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dd-theater-collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
/* visibility flips to hidden at the END of the 0.45s ease-out (deferred by allow-discrete);
|
||||
on reopen it flips back to visible at t=0 so content is immediately interactive. */
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Honor reduced-motion: collapse still happens (it is layout, not decoration) but instantly, matching
|
||||
the parallax precedent (transition-duration: 0). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dd-theater-collapsible {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
.deepdrft-track-detail-meta {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -358,6 +436,22 @@ h2, h3, h4, h5, h6,
|
||||
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 {
|
||||
padding: 0.75rem 1rem;
|
||||
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.
|
||||
============================================================================= */
|
||||
.waveform-visualizer-control-panel.mix-visualizer-controls-bar {
|
||||
/* Greyed panel ground — desaturated charcoal so the blue slider reads against it (defect #1).
|
||||
Token is tunable in deepdrft-tokens.css without touching this rule. */
|
||||
background: var(--deepdrft-panel-ground);
|
||||
/* Square corners + thin light border — NowPlayingCard chrome (§5). */
|
||||
border: 1px solid var(--deepdrft-border-light);
|
||||
/* Theme-aware glass ground — dark charcoal in dark theme, light translucent glass in light
|
||||
(so the deck reads against the light page). Tunable in deepdrft-tokens.css. */
|
||||
background: var(--deepdrft-panel-surface);
|
||||
/* Square corners + thin theme-aware border — NowPlayingCard chrome (§5). */
|
||||
border: 1px solid var(--deepdrft-panel-border);
|
||||
border-radius: 0;
|
||||
/* Optional backdrop blur — cheap on a small modal panel, nice over the visualizer (§5). */
|
||||
backdrop-filter: blur(8px);
|
||||
@@ -415,12 +509,12 @@ h2, h3, h4, h5, h6,
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
max-width: 420px;
|
||||
max-width: 480px;
|
||||
/* Pin the MudBlazor palette vars the portaled RadialKnob + slider consume. */
|
||||
--mud-palette-primary: var(--deepdrft-green-accent); /* knob arc/pointer + slider track/thumb (interactive) */
|
||||
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */
|
||||
--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
|
||||
@@ -461,13 +555,13 @@ h2, h3, h4, h5, h6,
|
||||
}
|
||||
|
||||
/* ── 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 {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-white);
|
||||
color: var(--deepdrft-panel-text);
|
||||
align-self: center;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.85;
|
||||
@@ -495,10 +589,11 @@ h2, h3, h4, h5, h6,
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
/* Caption icons render LIGHT (§5/§9: static/decorative = light). !important beats the scoped
|
||||
.mix-visualizer-control ::deep .mix-visualizer-control-icon rule (which sets green for the legacy
|
||||
inline mount) when the icon also carries mix-visualizer-control-icon. Lamp toggles are MudIconButton
|
||||
not MudIcon so they are unaffected — they stay green (interactive, Color.Primary). (defect #3) */
|
||||
/* Caption icons inherit the portaled panel's body text — theme-aware (dark text on light glass,
|
||||
off-white on dark glass). !important beats the scoped .mix-visualizer-control ::deep
|
||||
.mix-visualizer-control-icon rule (which sets green for the legacy inline mount) when the icon also
|
||||
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 {
|
||||
opacity: 0.85;
|
||||
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})
|
||||
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
|
||||
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);
|
||||
height: min(90vw, 520px);
|
||||
max-height: 90vh;
|
||||
background: var(--deepdrft-panel-ground);
|
||||
border: 1px solid var(--deepdrft-border-light);
|
||||
background: var(--deepdrft-panel-surface);
|
||||
border: 1px solid var(--deepdrft-panel-border);
|
||||
border-radius: 0;
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
@@ -803,16 +983,16 @@ body:has(.deepdrft-queue-overlay) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-white);
|
||||
color: var(--deepdrft-panel-text);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -840,12 +1020,12 @@ body:has(.deepdrft-queue-overlay) {
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: var(--deepdrft-white);
|
||||
color: var(--deepdrft-panel-text);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.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. */
|
||||
@@ -863,7 +1043,7 @@ body:has(.deepdrft-queue-overlay) {
|
||||
.deepdrft-queue-position {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
color: var(--deepdrft-panel-text-muted);
|
||||
min-width: 1.4rem;
|
||||
text-align: right;
|
||||
flex: 0 0 auto;
|
||||
@@ -888,7 +1068,7 @@ body:has(.deepdrft-queue-overlay) {
|
||||
|
||||
.deepdrft-queue-artist {
|
||||
font-size: 0.74rem;
|
||||
opacity: 0.6;
|
||||
color: var(--deepdrft-panel-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -950,8 +1130,8 @@ body:has(.deepdrft-privacy-overlay) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(90vw, 480px);
|
||||
background: var(--deepdrft-panel-ground);
|
||||
border: 1px solid var(--deepdrft-border-light);
|
||||
background: var(--deepdrft-panel-surface);
|
||||
border: 1px solid var(--deepdrft-panel-border);
|
||||
border-radius: 0;
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
@@ -962,7 +1142,7 @@ body:has(.deepdrft-privacy-overlay) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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. */
|
||||
@@ -971,27 +1151,28 @@ body:has(.deepdrft-privacy-overlay) {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-white);
|
||||
color: var(--deepdrft-panel-text);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Tuck the close icon flush with the panel edge; keep it subtle. */
|
||||
.deepdrft-privacy-modal-close {
|
||||
opacity: 0.6;
|
||||
color: var(--deepdrft-white) !important;
|
||||
color: var(--deepdrft-panel-text) !important;
|
||||
}
|
||||
|
||||
.deepdrft-privacy-modal-close:hover {
|
||||
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 {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.7;
|
||||
color: var(--deepdrft-white);
|
||||
color: var(--deepdrft-panel-text);
|
||||
opacity: 0.8;
|
||||
margin: 0;
|
||||
padding: 1rem 1rem 1.25rem;
|
||||
|
||||
@@ -12,11 +12,16 @@ public static class DDIcons
|
||||
""";
|
||||
|
||||
/// <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>
|
||||
public const string GasLampLit = """
|
||||
<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="12.5" rx="1.5" ry="2.5" fill="#FFCA28"/>
|
||||
<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
|
||||
Background = "#0D1B2A", // Navy - the light palette's primary as the dark ground
|
||||
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",
|
||||
DrawerBackground = "#162437", // Navy-mid
|
||||
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. */
|
||||
--deepdrft-modal-scrim-alpha: 0.15;
|
||||
/* 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;
|
||||
|
||||
/* 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 */
|
||||
--deepdrft-font-display: "Cormorant Garamond", Georgia, serif;
|
||||
--deepdrft-font-mono: "Geist Mono", monospace;
|
||||
@@ -68,11 +83,54 @@
|
||||
--gradient-warm: var(--deepdrft-green);
|
||||
--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-*.
|
||||
Palette aliases (--deepdrft-primary, --theme-*, etc.) remain; they still have
|
||||
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).
|
||||
Mirrors the light palette's vocabulary on a dark ground. Same alias structure
|
||||
as :root so utility classes (.deepdrft-chip-*, .deepdrft-border-*, .deepdrft-text-*)
|
||||
@@ -108,4 +166,55 @@
|
||||
--gradient-accent: var(--deepdrft-green-accent);
|
||||
--gradient-warm: var(--deepdrft-green);
|
||||
--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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the play-queue orchestrator (<see cref="QueueService"/>). The queue is pure
|
||||
/// domain logic over the single-slot player, so it is exercised here against a recording fake
|
||||
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage:
|
||||
/// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and
|
||||
/// auto-advance on the player's <see cref="IPlayerService.TrackEnded"/> signal.
|
||||
/// Unit tests for the two-level deque play-queue orchestrator (<see cref="QueueService"/>). The queue
|
||||
/// is pure domain logic over the single-slot player, so it is exercised here against a recording fake
|
||||
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage: PLAY-
|
||||
/// prepend (single + release), add-to-queue append, dormant-seed-from-player, ordered advance,
|
||||
/// next/previous bounds, jump, clear, current-index integrity, and auto-advance / last-track-empty on
|
||||
/// the player's <see cref="IPlayerService.TrackEnded"/> signal.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class QueueServiceTests
|
||||
@@ -125,8 +126,12 @@ public class QueueServiceTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlayRelease_ReplacesAnExistingQueue()
|
||||
public async Task PlayRelease_PrependsToFront_RemovesPreviousCurrent_KeepsRemainderIntact()
|
||||
{
|
||||
// Deque PLAY (bug #5): PlayRelease into a non-empty queue prepends the release at the front,
|
||||
// removes the previously-current track, and leaves the up-next that sat after it intact behind
|
||||
// the prepend. Current was track-1 (index 0) → after prepend, the old current is dropped and
|
||||
// its tail (track-2, track-3) stays behind [x-1, x-2].
|
||||
await _queue.PlayRelease(Tracks(3));
|
||||
var second = new List<TrackDto>
|
||||
{
|
||||
@@ -138,39 +143,133 @@ public class QueueServiceTests
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(2));
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" }));
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "x-1", "x-2", "track-2", "track-3" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("x-1"));
|
||||
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("x-1"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlayRelease_ViaLiveQueueItems_PreservesTracksAndJumpsToIndex()
|
||||
public async Task PlayRelease_FromMidQueueCurrent_DropsOnlyTheCurrentTrack_NotItsTail()
|
||||
{
|
||||
// Regression guard for the aliasing bug: OnQueueJump calls PlayRelease(QueueService.Items, index).
|
||||
// Items returns the backing list directly; without a defensive copy, the cast
|
||||
// "tracks as IReadOnlyList<TrackDto>" aliases _items, so _items.Clear() also clears list,
|
||||
// and _items.AddRange(list) adds nothing — wiping the queue and playing nothing.
|
||||
await _queue.PlayRelease(Tracks(4)); // populate the live queue
|
||||
// Current advanced to track-2 (index 1) with track-3, track-4 after it. PLAY of a new release
|
||||
// drops only track-2 (the current) and keeps track-3, track-4 behind the prepend. The old
|
||||
// back-history (track-1, before the current) is discarded — a fresh PLAY defines a new front.
|
||||
await _queue.PlayRelease(Tracks(4));
|
||||
await _queue.Next(); // current = track-2 at index 1
|
||||
|
||||
// Jump to index 2 via the live Items reference, exactly as OnQueueJump does.
|
||||
await _queue.PlayRelease(new List<TrackDto> { new() { EntryKey = "p-1", TrackName = "P1" } });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "p-1", "track-3", "track-4" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("p-1"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlayRelease_WithStartIndex_PrependsWholeReleaseInOrder_CurrentAtStartIndex()
|
||||
{
|
||||
// A mid-album row play prepends the whole release in order; the chosen startIndex becomes
|
||||
// current. Tracks before it sit behind the pointer (Previous reaches them); tracks after are
|
||||
// up-next. The previous current is dropped.
|
||||
await _queue.PlayRelease(Tracks(2)); // existing queue: [track-1*, track-2]
|
||||
var release = new List<TrackDto>
|
||||
{
|
||||
new() { EntryKey = "r-1", TrackName = "R1" },
|
||||
new() { EntryKey = "r-2", TrackName = "R2" },
|
||||
new() { EntryKey = "r-3", TrackName = "R3" },
|
||||
};
|
||||
|
||||
await _queue.PlayRelease(release, startIndex: 1);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "r-1", "r-2", "r-3", "track-2" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("r-2"));
|
||||
Assert.That(_queue.HasPrevious, Is.True); // r-1 is behind the pointer
|
||||
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("r-2"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlayRelease_ViaLiveQueueItems_DoesNotCorruptListUnderPrepend()
|
||||
{
|
||||
// Aliasing guard retained under the deque model: a caller that passes the live Items reference
|
||||
// into PlayRelease must not corrupt the list. PlayRelease materializes tracks.ToList() before
|
||||
// the RemoveRange/InsertRange prepend, so the defensive copy survives the mutation.
|
||||
await _queue.PlayRelease(Tracks(4));
|
||||
|
||||
// Pass the live Items reference (current = track-1). Prepend drops the current and re-inserts
|
||||
// the copy at the front, with the tail (track-2..4) preserved behind it.
|
||||
await _queue.PlayRelease(_queue.Items, 2);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// The queue must survive — all four tracks still present, in order.
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(4));
|
||||
// The defensive copy is intact: all four original tracks were re-prepended in order, and the
|
||||
// old current's tail follows. CurrentIndex is the chosen start.
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
|
||||
// CurrentIndex must be the jumped-to slot.
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4", "track-2", "track-3", "track-4" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
// Current must be the right track.
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
||||
// The player must have streamed the jumped-to track.
|
||||
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
|
||||
});
|
||||
}
|
||||
|
||||
// --- PlayTrack: deque PLAY of a single track (prepend to front) — bug #5 ---
|
||||
|
||||
[Test]
|
||||
public async Task PlayTrack_IntoDormantQueue_BecomesSoleHeadAndStreams()
|
||||
{
|
||||
await _queue.PlayTrack(new TrackDto { EntryKey = "solo", TrackName = "Solo" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "solo" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("solo"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlayTrack_FromNonEmptyQueue_PrependsDropsPreviousCurrent_KeepsRemainder()
|
||||
{
|
||||
// bug #5: PLAY of a single track from a non-empty queue prepends it as the new head, drops the
|
||||
// previously-current track, and leaves the remainder intact behind the new head.
|
||||
await _queue.PlayRelease(Tracks(3)); // [track-1*, track-2, track-3]
|
||||
|
||||
await _queue.PlayTrack(new TrackDto { EntryKey = "jump-in", TrackName = "Jump In" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "jump-in", "track-2", "track-3" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("jump-in"));
|
||||
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("jump-in"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlayTrack_DisarmsAnArmedQueue()
|
||||
{
|
||||
_queue.Arm(Tracks(3));
|
||||
|
||||
await _queue.PlayTrack(new TrackDto { EntryKey = "override", TrackName = "Override" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("override"));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Arm: prerender-safe load without streaming (release embed) ---
|
||||
|
||||
[Test]
|
||||
@@ -671,6 +770,81 @@ public class QueueServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Enqueue_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
|
||||
{
|
||||
// Bug #3: a single track is playing NOT through the queue (the player's CurrentTrack is set, the
|
||||
// queue is dormant). The first Add-to-queue must seed the head with that now-playing track and
|
||||
// then append the added one → [now-playing, added], even if they are the same track.
|
||||
var nowPlaying = new TrackDto { Id = 7, EntryKey = "now-playing", TrackName = "Now Playing" };
|
||||
_player.SimulateDirectPlay(nowPlaying);
|
||||
|
||||
_queue.Enqueue(new TrackDto { EntryKey = "added", TrackName = "Added" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "now-playing", "added" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "the now-playing track is the head/current");
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("now-playing"));
|
||||
Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed");
|
||||
});
|
||||
|
||||
// A second add appends a third item — no ghost/duplicate seeding.
|
||||
_queue.Enqueue(new TrackDto { EntryKey = "added-2", TrackName = "Added 2" });
|
||||
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "now-playing", "added", "added-2" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Enqueue_OfTheSameExternallyPlayingTrack_SeedsHeadThenAppendsTheDuplicate()
|
||||
{
|
||||
// Bug #3 exact repro: add the very track that is playing externally. Result must be a 2-item
|
||||
// queue [now-playing(current), same-track-appended] — not a single ghost entry.
|
||||
var nowPlaying = new TrackDto { Id = 7, EntryKey = "the-track", TrackName = "The Track" };
|
||||
_player.SimulateDirectPlay(nowPlaying);
|
||||
|
||||
_queue.Enqueue(nowPlaying);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(2));
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "the-track", "the-track" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EnqueueRange_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
|
||||
{
|
||||
var nowPlaying = new TrackDto { Id = 9, EntryKey = "live", TrackName = "Live" };
|
||||
_player.SimulateDirectPlay(nowPlaying);
|
||||
|
||||
_queue.EnqueueRange(Tracks(2));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "live", "track-1", "track-2" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Enqueue_IntoDormantQueue_WithNothingPlaying_DoesNotSeedAPhantomHead()
|
||||
{
|
||||
// No external track playing → nothing to seed. The single added track is the head (OQ8 coherent
|
||||
// index), and there is no phantom duplicate.
|
||||
_queue.Enqueue(new TrackDto { EntryKey = "only", TrackName = "Only" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "only" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex()
|
||||
{
|
||||
@@ -686,6 +860,67 @@ public class QueueServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
// --- JumpTo: row-jump within the deque (move pointer + stream once) ---
|
||||
|
||||
[Test]
|
||||
public async Task JumpTo_MovesPointerForwardAndStreamsTheTargetOnce()
|
||||
{
|
||||
await _queue.PlayRelease(Tracks(4)); // current = track-1
|
||||
var streamedBefore = _player.SelectedTracks.Count;
|
||||
|
||||
await _queue.JumpTo(2);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
||||
// Exactly one new stream — the intervening track-2 must NOT have been streamed.
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore + 1));
|
||||
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task JumpTo_MovesPointerBackwardAndStreamsTheTarget()
|
||||
{
|
||||
await _queue.PlayRelease(Tracks(4), startIndex: 3); // current = track-4
|
||||
|
||||
await _queue.JumpTo(1);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task JumpTo_DoesNotDuplicateTheQueue()
|
||||
{
|
||||
// Regression guard: JumpTo must NOT prepend (it is not a PLAY) — the deque length is unchanged.
|
||||
await _queue.PlayRelease(Tracks(4));
|
||||
|
||||
await _queue.JumpTo(2);
|
||||
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task JumpTo_SameIndexOrOutOfRange_IsNoOp()
|
||||
{
|
||||
await _queue.PlayRelease(Tracks(3)); // current = track-1
|
||||
var streamedBefore = _player.SelectedTracks.Count;
|
||||
|
||||
await _queue.JumpTo(0); // already current
|
||||
await _queue.JumpTo(-1);
|
||||
await _queue.JumpTo(3);
|
||||
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
|
||||
"no-op jumps must not re-stream");
|
||||
}
|
||||
|
||||
// --- Clear ---
|
||||
|
||||
[Test]
|
||||
@@ -820,16 +1055,38 @@ public class QueueServiceTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TrackEnded_OnLastTrack_DoesNotAdvanceOrReplay()
|
||||
public async Task TrackEnded_OnLastTrack_EmptiesTheQueueAndGoesDormant()
|
||||
{
|
||||
// Bug #2: when the current track ends naturally and there is nothing after it, the queue empties
|
||||
// (CurrentIndex == -1, dormant) rather than stranding the finished track as current. No replay.
|
||||
await _queue.PlayRelease(Tracks(2), startIndex: 1);
|
||||
var raised = false;
|
||||
_queue.QueueChanged += () => raised = true;
|
||||
|
||||
_player.RaiseTrackEnded();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
|
||||
Assert.That(_queue.Items, Is.Empty);
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||
Assert.That(_queue.Current, Is.Null);
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "no replay on end");
|
||||
Assert.That(raised, Is.True, "emptying the queue raises QueueChanged");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TrackEnded_OnSingleTrackQueue_EmptiesTheQueue()
|
||||
{
|
||||
// Bug #2, single-track variant: a one-item queue playing to its end empties (dormant).
|
||||
await _queue.PlayRelease(Tracks(1));
|
||||
|
||||
_player.RaiseTrackEnded();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Is.Empty);
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -877,17 +1134,18 @@ public class QueueServiceTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd()
|
||||
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd_ThenEmptiesOnLastEnd()
|
||||
{
|
||||
await _queue.PlayRelease(Tracks(3));
|
||||
|
||||
_player.RaiseTrackEnded(); // → track-2
|
||||
_player.RaiseTrackEnded(); // → track-3
|
||||
_player.RaiseTrackEnded(); // last track: no advance
|
||||
_player.RaiseTrackEnded(); // last track ends → queue empties (bug #2)
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
Assert.That(_queue.Items, Is.Empty);
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,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,65 @@
|
||||
using DeepDrftAPI;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the upload-staging directory resolution (<see cref="Startup.ResolveStagingPath"/>). The
|
||||
/// load-bearing invariant: large audio bodies must stage on the data disk, never the system temp
|
||||
/// mount — on the Linux host /tmp is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB WAV.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class UploadStagingPathTests
|
||||
{
|
||||
[Test]
|
||||
public void ResolveStagingPath_DefaultsToStagingUnderVault_WhenUnconfigured()
|
||||
{
|
||||
var vaultPath = Path.Combine(Path.GetTempPath(), "DeepDrftTests", Guid.NewGuid().ToString());
|
||||
|
||||
foreach (var configured in new[] { null, "", " " })
|
||||
{
|
||||
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(Path.Combine(vaultPath, "staging"))),
|
||||
"An unset/blank StagingPath must default to a 'staging' subdirectory under the vault path");
|
||||
Assert.That(Path.IsPathFullyQualified(resolved), Is.True,
|
||||
"The resolved staging path must be absolute");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResolveStagingPath_HonoursExplicitOverride()
|
||||
{
|
||||
var vaultPath = Path.Combine("data", "vaults");
|
||||
var configured = Path.Combine(Path.GetTempPath(), "DeepDrftTests", "custom-staging", Guid.NewGuid().ToString());
|
||||
|
||||
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
|
||||
|
||||
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(configured)),
|
||||
"An explicit Upload:StagingPath must win over the vault-path default");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResolveStagingPath_NeverResolvesIntoSystemTempDirectory_ForDataDiskVault()
|
||||
{
|
||||
// A production-shaped vault path on the data disk (the real config is a relative "../Database/Vaults").
|
||||
// The resolved staging dir must sit under that vault, not under Path.GetTempPath() (= /tmp on Linux).
|
||||
var vaultPath = Path.Combine("..", "Database", "Vaults");
|
||||
|
||||
var resolved = Startup.ResolveStagingPath(configuredPath: null, vaultPath);
|
||||
|
||||
var systemTemp = Path.GetFullPath(Path.GetTempPath());
|
||||
// Note: because vaultPath is relative, Path.GetFullPath resolves it against the CWD, which is
|
||||
// never the system temp directory. The StartsWith guard therefore catches the case where
|
||||
// ResolveStagingPath mistakenly uses Path.GetTempPath() directly, rather than proving the
|
||||
// absolute production path never overlaps with /tmp on any machine. The EndsWith assertion
|
||||
// is the load-bearing check: it verifies the output is rooted under the vault tree, not
|
||||
// under a hard-coded temp location.
|
||||
Assert.That(resolved.StartsWith(systemTemp, StringComparison.Ordinal), Is.False,
|
||||
"The default staging directory must never live under the system temp mount");
|
||||
Assert.That(resolved, Does.EndWith(Path.Combine("Database", "Vaults", "staging")),
|
||||
"The default staging directory must hang off the vault path on the data disk");
|
||||
}
|
||||
}
|
||||
@@ -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,6 +342,318 @@ the open-question set: `product-notes/phase-17-player-queue-view.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 19 — AuthBlocks User Management (CMS-only: admin surfaces + public self-registration)
|
||||
|
||||
Wire **all three** AuthBlocks account-creation paths into the `DeepDrftManager` CMS — the admin
|
||||
user-administration surface (provision users, manage accounts, manage registration invites, manage role
|
||||
permissions) **and** the public-facing self-service registration form. **All three paths live on
|
||||
`DeepDrftManager` (the CMS app); there are NO changes to `DeepDrftPublic` in this phase.** Daniel's
|
||||
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`.
|
||||
|
||||
**The three account-creation paths (verified against AuthBlocks source 2026-06-19) — ALL CMS routes:**
|
||||
1. **Admin provisions directly** — `SuperRegister.razor` → `/account/superregister` → `POST
|
||||
api/auth/admin-register` (UserAdmin-gated, **working**). Creates a live account now.
|
||||
2. **Public self-service** — `Register.razor` → `/account/register` → `POST api/auth/register`
|
||||
(**unauthenticated, no role gate, working**). A **public-facing CMS route, exactly like the CMS
|
||||
`/account/login` page** — invited user redeems a code (pre-filled from the invite email's deep link)
|
||||
and self-registers, all on the CMS host.
|
||||
3. **Admin provisions a token + triggers the invite email** — `NewRegistration(Form).razor` →
|
||||
`/useradmin/registrations/new` → `POST api/pendingregistration/create` (UserAdmin-gated). **Sends a
|
||||
real email server-side** via Mailtrap (`RegistrationEmailTemplate` + `IGeneralEmailSender`, configured
|
||||
in DeepDrftAPI from `environment/authblocks.json`) — **not stubbed.**
|
||||
|
||||
**Host-model correction (Daniel, 2026-06-19).** A prior revision placed public registration (path 2) on
|
||||
`DeepDrftPublic` as a cold-start integration. **Wrong — there are NO `DeepDrftPublic` changes.** Public
|
||||
registration is an unauthenticated route *on the CMS app*, mirroring the CMS's already-public
|
||||
`/account/login`. The only genuinely stubbed surface is **Reset Password** (`Users.razor`, `// todo`; **no
|
||||
backing endpoint** in `AuthRoutes`) — handled separately by Daniel in the AuthBlocks repo (see
|
||||
`product-notes/authblocks-password-reset-brief.md`).
|
||||
|
||||
**Most wiring already landed by side-effect.** The AuthBlocks startup separation
|
||||
(`PLAN_authblocks_trackmanager.md`, 2026-05-25) + login/logout integration already put the entire surface
|
||||
in place on `DeepDrftManager`: `Cerebellum.AuthBlocks.Web` referenced, `ConfigureAuthServices` registers
|
||||
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
|
||||
|
||||
- **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 -rsp " Email token (API key / SMTP password): " EMAIL_TOKEN
|
||||
echo
|
||||
read -rp " Sender email address (From:, e.g. noreply@${DOMAIN_PUBLIC}): " EMAIL_FROM
|
||||
|
||||
# Admin account
|
||||
echo
|
||||
@@ -201,10 +202,10 @@ if need_cred "authblocks"; then
|
||||
read -rp " Support email address: " SUPPORT_EMAIL
|
||||
|
||||
write_cred "authblocks" "$(cat <<JSON
|
||||
{"AuthBlocks":{"Jwt":{"Secret":"$(json_escape "${JWT_SECRET}")","Issuer":"$(json_escape "${JWT_ISSUER}")","Audience":"$(json_escape "${JWT_AUDIENCE}")"},"Email":{"Host":"$(json_escape "${EMAIL_HOST}")","Token":"$(json_escape "${EMAIL_TOKEN}")"},"Admin":{"UserName":"$(json_escape "${ADMIN_USERNAME}")","Email":"$(json_escape "${ADMIN_EMAIL}")","Password":"$(json_escape "${ADMIN_PASSWORD}")"},"SupportEmail":"$(json_escape "${SUPPORT_EMAIL}")"}}
|
||||
{"AuthBlocks":{"Jwt":{"Secret":"$(json_escape "${JWT_SECRET}")","Issuer":"$(json_escape "${JWT_ISSUER}")","Audience":"$(json_escape "${JWT_AUDIENCE}")"},"Email":{"Host":"$(json_escape "${EMAIL_HOST}")","Token":"$(json_escape "${EMAIL_TOKEN}")","From":"$(json_escape "${EMAIL_FROM}")"},"Admin":{"UserName":"$(json_escape "${ADMIN_USERNAME}")","Email":"$(json_escape "${ADMIN_EMAIL}")","Password":"$(json_escape "${ADMIN_PASSWORD}")"},"SupportEmail":"$(json_escape "${SUPPORT_EMAIL}")"}}
|
||||
JSON
|
||||
)"
|
||||
unset JWT_SECRET JWT_ISSUER JWT_AUDIENCE EMAIL_HOST EMAIL_TOKEN
|
||||
unset JWT_SECRET JWT_ISSUER JWT_AUDIENCE EMAIL_HOST EMAIL_TOKEN EMAIL_FROM
|
||||
unset ADMIN_USERNAME ADMIN_EMAIL ADMIN_PASSWORD SUPPORT_EMAIL
|
||||
else
|
||||
echo "[setup-step10-creds] authblocks.json already exists, skipping"
|
||||
|
||||
@@ -0,0 +1,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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user