32 Commits

Author SHA1 Message Date
daniel-c-harvey 2c1571057a feature: Manager Menu Styles and Page Titles
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m13s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m23s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m4s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m28s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-22 23:04:49 -04:00
daniel-c-harvey 0b7d8e41e7 Merge account-nav-menu into dev 2026-06-22 22:42:48 -04:00
daniel-c-harvey 4833935925 feature: About Bio text 2026-06-22 22:41:39 -04:00
daniel-c-harvey 7917d56af3 feature: Manager Logos 2026-06-22 22:41:30 -04:00
daniel-c-harvey 1fd63fe368 Add AccountNavMenu to CmsLayout nav drawer 2026-06-22 22:39:21 -04:00
daniel-c-harvey 4e1f540945 Merge bump-cerebellum-final into dev 2026-06-22 22:28:10 -04:00
daniel-c-harvey 1ed518b018 chore: bump Cerebellum stack to NetBlocks 10.3.32 / BlazorBlocks 10.3.35 / AuthBlocks 10.3.39
Delivers the ResultDtoBase.From() null-crash fix to DeepDrft's
Users/Registrations pages.
2026-06-22 22:27:57 -04:00
daniel-c-harvey 7c41aa678d Revert "Merge bisect-match-skipper into dev"
This reverts commit 475e5e671c, reversing
changes made to 0d1da9e63c.
2026-06-22 12:47:02 -04:00
daniel-c-harvey 475e5e671c Merge bisect-match-skipper into dev 2026-06-22 12:24:00 -04:00
daniel-c-harvey 9971474403 bisect: pin DeepDrftHome to Skipper's known-good package versions
AuthBlocks* → 10.3.35, BlazorBlocks* → 10.3.32. Diagnostic downgrade to
isolate null-ref crash on Users/Registrations pages.
2026-06-22 12:23:19 -04:00
daniel-c-harvey 0d1da9e63c docs: note Phase 20 visualizer-flash fix (coalesced --player-height publish) 2026-06-22 08:38:55 -04:00
daniel-c-harvey d47c186045 Merge p20-theater-visualizer-flash into dev 2026-06-22 08:36:05 -04:00
daniel-c-harvey 670eaab34d fix(visualizer): coalesce --player-height publish so Theater ease doesn't thrash the WebGL backing store 2026-06-22 08:19:53 -04:00
daniel-c-harvey c58b1c9386 Merge bump-cerebellum-deps into dev 2026-06-21 11:55:40 -04:00
daniel-c-harvey 450204cdbf Bump Cerebellum packages to fix null-Items crash on Users/Registrations pages
AuthBlocks → 10.3.38, BlazorBlocks → 10.3.34, NetBlocks → 10.3.31.
Pulls server-side null-Items guard (AuthBlocks) and BlazorBlocks render
guard. Direct refs for BlazorBlocks/NetBlocks raised to avoid NU1605
downgrade conflicts with AuthBlocks 10.3.38's transitive requirements.
2026-06-21 11:50:05 -04:00
daniel-c-harvey 5c22c1626a docs: reflect Phase 20 Wave 2 theater refinements (full-screen body, eased collapse, playing-release scoping) 2026-06-21 10:18:19 -04:00
daniel-c-harvey 8628fbf215 Merge Theater Mode refinements (Phase 20 Wave 2) into dev 2026-06-21 09:23:56 -04:00
daniel-c-harvey a23a22a2a3 fix(css): visibility transition 0s->0.45s so allow-discrete defers collapse flip to end of ease-out 2026-06-21 09:20:18 -04:00
daniel-c-harvey 6e12d0161a fix(theater): replace max-height collapse with grid-rows + visibility; fix keyboard-focus leak when collapsed 2026-06-21 09:12:24 -04:00
daniel-c-harvey 9716092805 feat(theater): full-screen detail body, eased content collapse, playing-release scoping
Detail bodies fill 100vh below the nav so the visualizer reads full-screen; Theater toggle eases page content and the player-bar now-showing panel in/out instead of popping (reduced-motion honored); Theater only applies to the currently-playing release.
2026-06-21 08:59:09 -04:00
daniel-c-harvey a577df88dd docs: reflect Phase 20 Theater Mode landing in PLAN, COMPLETED, CLAUDE.md, and spec status 2026-06-20 22:17:58 -04:00
daniel-c-harvey 011dbe8d81 Merge Theater Mode (Phase 20) into dev 2026-06-20 22:12:23 -04:00
daniel-c-harvey 2fc2d4eb6d test: fix PascalCase nit in CoerceTheaterMode_BothOff_TheaterBecomesFalse 2026-06-20 22:09:34 -04:00
daniel-c-harvey 14f3af41e4 fix(theater): auto-exit Theater Mode when both visualizer subsystems are disabled
Adds CoerceTheaterMode() to WaveformVisualizerControlState; ToggleLava/ToggleWaveform
call it before NotifyChanged so all observers see consistent state in one Changed cycle.
Covers the dead-end escape route bug (Phase 20 review finding).
2026-06-20 22:03:39 -04:00
daniel-c-harvey fa01b9c8e0 feat(public): add Theater Mode to release detail pages
Toggle left of the lava popover hides release content so the visualizer fills
the surface; player bar grows to carry the playing release's cover, title, and
share. State on WaveformVisualizerControlState; pages and bar observe it.
2026-06-20 21:51:30 -04:00
daniel-c-harvey 835fb71337 docs(plan): mark Phase 20 Theater Mode scoped after sign-off 2026-06-20 21:40:56 -04:00
daniel-c-harvey 021801999c docs(plan): add Phase 20 Theater Mode spec and roadmap entry 2026-06-20 19:08:44 -04:00
daniel-c-harvey 54cba7eea0 docs(queue): sync client CLAUDE.md to deque cleanup — cached QueueItems, scaffold/StreamNow PLAY routing 2026-06-20 19:05:18 -04:00
daniel-c-harvey fbaf545c90 Merge queue-deque-redesign into dev
Two-level deque queue model + five bug fixes, plus review cleanup.
2026-06-20 19:01:07 -04:00
daniel-c-harvey d3f89c494a fix: Waveform Visualizer Controls layout 2026-06-20 18:56:53 -04:00
daniel-c-harvey c3ec3acafa fix(queue): route scaffold masthead PLAY through queue; cache QueueItems snapshot 2026-06-20 18:51:30 -04:00
daniel-c-harvey 214f708e65 feat(queue): two-level deque model — PLAY prepends, add appends, last-track-end empties
Fixes five queue bugs: Playlist relabel, last-track-empties, dormant-seed-from-player on first add, immediate panel reactivity, and front/back deque semantics. Adds JumpTo for row jumps.
2026-06-20 15:26:37 -04:00
44 changed files with 1629 additions and 177 deletions
+27
View File
@@ -6,6 +6,33 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 20 — Theater Mode (landed 2026-06-20)
**Landed:** 2026-06-20 on dev. Pending: final manual browser/GPU smoke-test on dev.
- **What:** A presentation-only Theater Mode toggle on the three public Release Detail views (`CutDetail.razor`, `MixDetail.razor`, `SessionDetail.razor`). Toggling ON hides the release page content via `@if` so the lava-lamp + waveform visualizer fills the surface unobstructed; the player bar grows to surface the playing release's cover art, release title (linked), and a release-mode `SharePopover`. Toggling OFF restores the page byte-for-byte. The top action row (back link, lava-lamp popover, Theater toggle) stays visible in both states. Behavior is identical across all three mediums. Persists across SPA navigation within a session; resets to OFF on fresh page load.
- **Why:** The visualizer is the site's most distinctive feature (Phases 10/12/15). Theater Mode makes it the *whole* thing on demand — a "lean back and watch the lamp" experience — and relocates the minimum release identity to the one piece of chrome that stays (the player bar), so nothing essential is lost.
- **Shape:**
- **`Controls/TheaterModeToggle.razor`** (new): shared toggle button placed immediately left of the lava-lamp `WaveformVisualizerControlPopover` on all three detail pages inside a `.dd-detail-top-actions` cluster. Material `Theaters` glyph; `.dd-accent-icon` for green-accent in both themes. Visible only when `LavaEnabled || WaveformEnabled`; disabled until interactive. Flips `WaveformVisualizerControlState.TheaterMode` and calls `NotifyChanged()`. Subscribes to `State.Changed` for its own active-state re-render; disposes cleanly.
- **`Controls/AudioPlayerBar/NowShowingPanel.razor`** (new): presentational "now showing" band rendered by `AudioPlayerBar` only when `TheaterMode && CurrentTrack?.Release is not null`. Shows cover art (`deepdrft-track-detail-cover-art` / `deepdrft-gradient-soft-secondary` placeholder), release title link (`ReleaseRoutes.DetailHref`), and release-mode `SharePopover` in `.dd-accent-icon`. Layout CSS in `AudioPlayerBar.razor.css` (`.now-showing-*`); surface/text bind `--deepdrft-page-*` aliases — no new dark overrides.
- **`Services/WaveformVisualizerControlState.cs`** (widened): gained `TheaterMode` bool + `DefaultTheaterMode = false` const, and `CoerceTheaterMode()` — enforces the invariant that Theater Mode cannot remain on when both subsystems are off. Called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` before `NotifyChanged()` so all observers see a consistent coerced state in the same `Changed` cycle.
- **`Controls/AudioPlayerBar/AudioPlayerBar.razor` + `.razor.cs` + `.razor.css`**: subscribes to `WaveformVisualizerControlState.Changed`; mounts `<NowShowingPanel>` above transport controls when Theater is on and a release is playing.
- **Three detail pages** (`CutDetail.razor`, `MixDetail.razor`, `SessionDetail.razor`): page-level `@if (!VisualizerControlState.TheaterMode)` gates content regions on each page individually (not in `ReleaseDetailScaffold`, so Session — which does not use the scaffold — is covered identically). Each page's top action cluster hosts `<TheaterModeToggle />` in a `.dd-detail-top-actions` flex wrapper.
- **`deepdrft-styles.css`**: new `.dd-detail-top-actions` layout-only class (`display:flex; align-items:center; gap:0.25rem`) — no colour; shared by all three pages.
- **`DeepDrftTests/WaveformVisualizerControlStateTests.cs`** (new): unit tests for the `CoerceTheaterMode()` auto-exit invariant.
- **Design memo:** `product-notes/phase-20-theater-mode.md`.
### Phase 20 — Wave 2 — Theater Mode refinements (landed 2026-06-21)
**Landed:** 2026-06-21 on dev.
- **What:** Three refinements to the base Phase 20 feature. (1) **Full-screen detail body:** each detail page's foreground container gained `.dd-detail-fill` (`min-height: calc(100vh - var(--deepdrft-nav-height, 88px))`), so the visualizer reads as full-screen and the footer is pushed below the fold regardless of Theater Mode. (2) **Eased collapse (no pop):** the hard `@if` content-hide on the three detail pages was replaced by a `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` wrapper pair that receives `.dd-theater-collapsed` when `IsContentHidden` is true — animates `grid-template-rows: 1fr → 0fr`, `opacity`, and `visibility` (deferred via `transition-behavior: allow-discrete`) so Theater ON/OFF eases rather than pops; `prefers-reduced-motion` collapses instantly. The same wrapper pattern drives the player-bar `NowShowingPanel`, which is now kept mounted whenever a release is playing and collapsed (not `@if`-removed) when Theater is OFF — enabling the ease-in when Theater turns ON (resolves OQ2 design intent for a mounted-but-dormant panel). (3) **Playing-release scoping:** Theater Mode now only applies to the currently-playing release. `ReleaseDetailBase` and `CutDetailBase` each gained a cascaded `IStreamingPlayerService PlayerService`, a reference-guarded `StateChanged` subscription (disposed in `Dispose`), and three predicates: `IsThisReleasePlaying` (`CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). `TheaterModeToggle.razor` gained an `Available` parameter (default `true`) folded into its render gate; all three pages pass `Available="ShowTheaterToggle"`. A detail page whose release is not playing shows no toggle and ignores the global `TheaterMode` flag.
---
## Phase 18 — Theme / Dark-Mode Remediation (landed 2026-06-19)
**Landed:** 2026-06-19 on dev (Wave 1 + Wave 2 + Wave 3).
+1 -1
View File
@@ -15,7 +15,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.37" />
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.39" />
</ItemGroup>
<ItemGroup>
+3 -3
View File
@@ -18,9 +18,9 @@
</PackageReference>
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.30" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.35" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.35" />
</ItemGroup>
<ItemGroup>
@@ -6,9 +6,23 @@
<MudLayout>
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
Deep Drft — Admin
</MudText>
<a href="/" class="mx-2">
<MudStack Row AlignItems="AlignItems.Center">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "
Style="filter: invert(1);"/>
<MudText Typo="Typo.button" Style="color: var(--deepdrft-white);">Deep DRFT Management</MudText>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24"
Style="filter: invert(1);"/>
</MudStack>
</a>
</MudAppBar>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Small"
@@ -13,9 +13,23 @@
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
Deep Drft — Admin
</MudText>
<a href="/" class="mx-2">
<MudStack Row AlignItems="AlignItems.Center">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "
Style="filter: invert(1);"/>
<MudText Typo="Typo.button" Style="color: var(--deepdrft-white);">Deep DRFT Management</MudText>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24"
Style="filter: invert(1);"/>
</MudStack>
</a>
<MudSpacer />
<MudTooltip Text="Catalogue">
<MudIconButton Icon="@Icons.Material.Filled.Home"
@@ -34,6 +48,7 @@
<MudNavLink Href="/useradmin/users/new" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PersonAdd">Provision User</MudNavLink>
</Authorized>
</HierarchicalRoleAuthorizeView>
<AccountNavMenu />
</MudNavMenu>
</MudDrawer>
<MudMainContent Class="pt-14 pb-8">
+1 -1
View File
@@ -1,6 +1,6 @@
@page "/404"
<PageTitle>SkipperHaven - Page Not Found</PageTitle>
<PageTitle>Deep DRFT Management - Page Not Found</PageTitle>
<MudText Typo="Typo.h1" Color="Color.Primary">
404 - Resource Not Found
+1 -1
View File
@@ -1,7 +1,7 @@
@page "/"
@layout Layout.CmsHomeLayout
<PageTitle>Deep Drft — Admin</PageTitle>
<PageTitle>Deep DRFT Management</PageTitle>
<HierarchicalRoleAuthorizeView>
<Authorized>
+1 -1
View File
@@ -7,7 +7,7 @@
@inject ICmsReleaseService CmsReleaseService
@inject ILogger<Index> Logger
<PageTitle>DeepDrft CMS</PageTitle>
<PageTitle>Deep DRFT Management - Catalogue</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
@@ -14,7 +14,7 @@
@inject IDialogService DialogService
@inject ILogger<BatchEdit> Logger
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
<PageTitle>Edit Release — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
@@ -12,7 +12,7 @@
@inject ISnackbar Snackbar
@inject ILogger<BatchUpload> Logger
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
<PageTitle>Upload Release — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
@@ -18,7 +18,7 @@
}
else
{
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
<PageTitle>Mixes — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
@@ -19,7 +19,7 @@
}
else
{
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
<PageTitle>Sessions — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
@@ -11,7 +11,7 @@
@inject NavigationManager NavigationManager
@attribute [Authorize]
<PageTitle>Releases — DeepDrft CMS</PageTitle>
<PageTitle>Releases — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
+1 -1
View File
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="MudBlazor" Version="8.15.0" />
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.37" />
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.39" />
</ItemGroup>
<ItemGroup>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+2 -2
View File
@@ -7,8 +7,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.30" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.35" />
</ItemGroup>
</Project>
+14 -6
View File
@@ -10,17 +10,18 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
## Actual structure
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"` with `.dd-detail-fill` so the ambient visualizer reads full-screen and the footer is pushed below the fold; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; **Phase 20:** top action row carries `<TheaterModeToggle Available="ShowTheaterToggle" />` immediately left of the lava-lamp popover in a `.dd-detail-top-actions` cluster — the toggle only appears when this page's release is the one currently playing (`ShowTheaterToggle` from `ReleaseDetailBase` folds in the subsystem gate + release-playing check); hero overlay and `<ReleaseDescription>` are wrapped in a `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` pair that gets `.dd-theater-collapsed` when `IsContentHidden` is true — eased collapse via `grid-template-rows: 1fr → 0fr` + `opacity` + `visibility` (no hard `@if` pop); renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; the foreground container carries `.dd-detail-fill`; renders `<ReleaseDescription>` below the hero for the release's description blurb; **Phase 20:** `TopRightAction` slot holds `<TheaterModeToggle Available="ShowTheaterToggle" />` + lava-lamp popover in a `.dd-detail-top-actions` cluster — toggle only appears when this Mix is the playing release; hero overlay and description are wrapped in `.dd-theater-collapsible` / `.dd-theater-collapsed` eased collapse driven by `IsContentHidden`), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; the scaffold is wrapped in a `.dd-detail-fill` div; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`; **Phase 20:** `TopRightAction` slot holds `<TheaterModeToggle Available="ShowTheaterToggle" />` + lava-lamp popover in a `.dd-detail-top-actions` cluster — toggle only appears when this Cut is the playing release; header and track-list body are each wrapped in a `.dd-theater-collapsible` / `.dd-theater-collapsed` eased collapse driven by `IsContentHidden`, replacing the prior hard `@if`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list), `DeepDrftFooter.razor` (site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal via `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) carrying the anonymous-listener privacy note; trigger-button styling in the co-located `DeepDrftFooter.razor.css`, overlay chrome in the global `deepdrft-styles.css`; follows the `QueueOverlay`/`WaveformVisualizerControlPopover` `MudOverlay` idiom — scrim-click closes, panel stops propagation).
- `Controls/`: Reusable components.
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
- `AppNavLink.razor`: Nav link with active-page highlight.
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored).
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming — routes through `IQueueService.PlayTrack` (deque PLAY semantics) when the queue cascade is present, falls back to `IStreamingPlayerService.SelectTrackStreaming` when absent. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored). **Phase 20:** injects `WaveformVisualizerControlState` and subscribes to `Changed` (added alongside the existing `IPlayerService.StateChanged` subscription — same reference-guard + dispose pattern); mounts `<NowShowingPanel Release="CurrentTrack.Release" />` above the transport controls when `CurrentTrack?.Release is not null` — the panel is kept **always mounted** whenever a release is playing and wrapped in the shared `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` pair; it gets `.dd-theater-collapsed` when Theater Mode is OFF, so the bar grows/shrinks via the same eased collapse that the detail-page content regions use rather than popping via `@if` (Phase 20 Wave 2).
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
- `AudioPlayerBar/NowShowingPanel.razor`: Phase 20 "now showing" presentational band rendered by `AudioPlayerBar` **only when** `VisualizerControlState.TheaterMode && CurrentTrack?.Release is not null`. Carries the release identity the hidden detail page would otherwise show: cover art thumbnail (`deepdrft-track-detail-cover-art` / `deepdrft-gradient-soft-secondary` placeholder), release title linked via `ReleaseRoutes.DetailHref(Release)`, and a release-mode `SharePopover` (`ReleaseEntryKey` + `ReleaseMedium`) wrapped in `.dd-accent-icon`. `[Parameter, EditorRequired] ReleaseDto Release` — non-null by the bar's mount gate. Purely presentational: owns no player logic, no Theater state, and no data fetch. Layout CSS lives in `AudioPlayerBar.razor.css` (`.now-showing` / `.now-showing-cover` / `.now-showing-cover-art` / `.now-showing-cover-placeholder` / `.now-showing-title-link` / `.now-showing-title` / `.now-showing-share`); all surface/text binds `--deepdrft-page-*` theme-aware aliases — no new dark overrides.
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0100%), with fixed three-zone gradient (green 060%, yellow 6085%, orange 85100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
@@ -29,6 +30,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `WaveformVisualizer.razor`: The single WebGL2 lava-lamp visualizer engine. Hosts the waveform of whatever track is currently playing/selected. Three hosting modes: mode A (Mix detail — full-bleed centerpiece), mode B (Cut/Session detail — ambient layer behind hero+content via `ReleaseDetailScaffold`'s `Ambient` slot), mode C (NowPlaying hero panel — full-bleed background for the home hero's right side, mounted by `NowPlaying.razor` inside `.np-visualizer-bg`). `[Parameter] bool Fill` switches from fixed-viewport positioning to container-relative sizing (CSS-only; the renderer is identical in both modes). The bridge resolves the current track's `EntryKey` and re-fetches the high-res datum on track change. Subscribes to `WaveformVisualizerControlState.Changed` and pushes each updated dial to the WebGL module via JS interop. Follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`).
- `WaveformVisualizerControls.razor`: The waveform visualizer control panel (content hosted by `WaveformVisualizerControlPopover`). Phase 15 re-layout: a deterministic **three-row sectioned layout** encoding the visualizer's two subsystems. Row 1 (MODE, always visible): two iconographic lamp toggles (lava on/off, waveform on/off) left-aligned + collisions knob (conditional — only when both subsystems on) + color knob pinned far-right. Row 2 (LAVA, visible only when `LavaEnabled`): "LAVA:" section label + Gravity / Heat / FluidAmount / FluidViscosity knobs. Row 3 (WAVE, visible only when `WaveformEnabled`): "WAVE:" section label + scroll-speed `MudSlider` (not a knob) + width knob pinned far-right. Total: two lamp toggles, seven `RadialKnob`s, one `MudSlider`. Colour principle: lamp toggles / knob arcs / slider are green (`Color.Primary` — interactive); section labels / knob caption icons are light (static). Each control has a playful `MudTooltip`. `[Parameter] bool PanelChrome` scopes panel chrome (NowPlayingCard look — square corners, lighter-navy, thin border) to the popover mount; chrome classes live in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). `[Parameter] bool Visible` gates the rows via `@if` while the container holds reserved min-height. Owns no JS interop: mutates the injected `WaveformVisualizerControlState` and raises `Changed`. No control is a seek surface (read-only contract).
- `WaveformVisualizerControlPopover.razor`: Pairs the lava-lamp icon button with `WaveformVisualizerControls` as a **screen-centered tinted modal** (Phase 15). The primitive is `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) — **not** `MudPopover`; `AnchorOrigin`/`TransformOrigin` parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: `RadialKnob`'s `position:fixed` capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. `[Parameter] Size IconSize` controls the trigger-icon size (default `Large`). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in `.np-visualizer-controls` at the panel's top-right corner, not inside `NowPlayingCard`).
- `TheaterModeToggle.razor`: Phase 20 Theater-Mode toggle button. Visible only when `Available && (State.LavaEnabled || State.WaveformEnabled)` — no visualizer subsystem active → no theater to enter; `Available` is false when this page's release is not the one currently playing (Phase 20 Wave 2). Disabled until interactive (`!RendererInfo.IsInteractive`), same guard as Play and the lava-lamp trigger. On click: flips `WaveformVisualizerControlState.TheaterMode` and calls `NotifyChanged()`. Shows an on/off `aria-pressed` active state. Glyph: Material `Theaters`. `.dd-accent-icon` container gives the green-accent glyph in both themes with zero new CSS — same treatment as `WaveformVisualizerControlPopover`. Subscribes to `State.Changed` in `OnInitialized` and unsubscribes on `Dispose` to re-render when another observer (e.g. `CoerceTheaterMode()`) flips the state. `[Parameter] Size IconSize` (default `Large`) matches the adjacent lava-lamp trigger. `[Parameter] bool Available` (default `true`) — the page passes its `ShowTheaterToggle` predicate here so the toggle is scoped to the playing release; surfaces with no release-scoping pass the default `true`. Placed **immediately left** of the lava-lamp popover on all three detail pages inside a `.dd-detail-top-actions` cluster.
- `WaveformZoomMapping.cs`: Maps the `WaveformVisualizerControlState.Resolution` fraction to an integer zoom level for the WebGL renderer.
- `NowPlayingCard.razor`: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded `IStreamingPlayerService`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to `NowPlaying.razor`.
- `NowPlayingStats.razor`: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-`ReleaseType` Cut release breakdown), Mixes (`MixReleaseCount` labelled "Sets" + `hh:mm` total mix runtime via `RuntimeFormat`), and Plays (live `TotalPlays` odometer in `.hero-stat-odometer` + `UniqueListeners` "N listeners" secondary line via `.hero-stat-sub` — Phase 16 wave 16.5). All three cards read from the same `HomeStatsDto` round-trip; no extra fetch path. Fetches via `IStatsDataService` on init; bridges the prerender fetch across the WASM seam with `PersistentComponentState` (persists only on a successful load, matching the medium-browse bridge pattern). Implements `IDisposable` to release the `PersistingComponentStateSubscription`.
@@ -36,7 +38,7 @@ 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.
- `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.
@@ -48,13 +50,13 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 1664 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions, **two subsystem on/off toggles** (Phase 15), and one **Theater-Mode flag** (Phase 20): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`), `TheaterMode` (bool, default `false``DefaultTheaterMode`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and `AudioPlayerBar`) subscribe to react to `TheaterMode`. **`CoerceTheaterMode()`**: enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` **before** `NotifyChanged()` so all observers see a consistent, coerced state in the same `Changed` cycle. `TheaterMode` is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. **Phase 20 Wave 2 — playing-release predicates** live in `ReleaseDetailBase` / `CutDetailBase` (not in this state holder): `IsThisReleasePlaying` (`PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). Both base classes also subscribe to `IStreamingPlayerService.StateChanged` (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1). Serializes the play classification and fires it via `BeaconInterop` to `api/event/play`. Synchronous (`EmitPlay` cannot await — it is called from the player close path and the page-unload handler). **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/telemetry/anonid.ts` (mints GUID on first visit, returns null without throwing when storage is unavailable).
- `IQueueService` / `QueueService`: Ordered playback orchestrator above the single-slot player. `PlayRelease(tracks, startIndex)` replaces the queue and starts streaming; `Next`/`Previous` advance or step back; `Enqueue`/`EnqueueRange` append without interrupting the current track; `Clear` empties the queue. **Armed-idle state** added to support prerender-safe release embeds: `Arm(tracks)` loads the track list at index 0 with no JS interop (safe during prerender); `IsArmed` signals the armed-but-not-streaming state; `Start()` begins streaming the current track and clears `IsArmed`, leaving the list and position intact so auto-advance carries on. `AudioPlayerBar` reads `IsArmed` to route the first play gesture through `Start()` instead of streaming the staged track alone. `QueueChanged` event fires on all list/position changes; cascaded via `AudioPlayerProvider`. **Wave 17.1 additions:** `Move(int fromIndex, int toIndex)` reorders `Items` in-place, adjusting `CurrentIndex` so the same track stays current across the move — never re-streams or interrupts playback; `RemoveAt(int index)` removes an item and adjusts `CurrentIndex` (removing the current track does not stop playback; removing the last remaining item leaves the queue empty and dormant). Both are interop-free state mutations that re-emit `QueueChanged`. **Dormant-`Enqueue` coherence (OQ8):** `Enqueue`/`EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) set `CurrentIndex` to 0 so a subsequent play/skip is correct — but do not auto-play. **Wave 17.2 additions:** `ClearUpcoming()` removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0` and re-emitting `QueueChanged` — touches no playback (OQ5: Clear does not stop or remove the current track). `PlayRelease` now always materializes a defensive copy of its input (`tracks.ToList()`) so it can never alias the service's own `Items` list — fixes a row-jump bug where `PlayRelease(Items, index)` could mutate the live list mid-operation.
- `IQueueService` / `QueueService`: **Two-level deque** orchestrator above the single-slot player. The deque has two entry ends. **PLAY (manual)** enters the FRONT: `PlayTrack(track)` and `PlayRelease(tracks, startIndex)` prepend the played track/release in order, **remove the previously-current track**, make the new front current, start streaming it, and leave whatever sat after the old current intact behind the prepend (a whole release prepends in order in one op). The detail pages (Cut header/row, Session/Mix hero) and `StreamNowButton` route their PLAY through these. **Add-to-queue** enters the BACK: `Enqueue`/`EnqueueRange` append to the end without interrupting the current track (`AddToQueueButton`). `Next`/`Previous` advance or step back, walking `CurrentIndex` and leaving played tracks behind so `Previous` can reach them; `JumpTo(index)` moves the pointer to a queued row and streams it once (the playlist panel's row-jump — it does NOT prepend or stream the intervening rows). **End-of-track:** auto-advance (`TrackEnded`) advances when there is a next track; when the **last** track ends naturally the queue **empties** and goes dormant (bug #2) rather than stranding the finished track. `Clear` empties the queue. **Bug #3 (dormant-seed):** the first `Enqueue`/`EnqueueRange` into a dormant queue while a track is already playing externally (via the attached player, not through the queue) seeds the head with that now-playing track and then appends — yielding `[now-playing, added]` (even when adding the same track). The queue learns the externally-playing track through the existing `Attach(player)` seam (`_player.CurrentTrack`) — no new dependency, no `IServiceProvider`. **Armed-idle state** (prerender-safe release embeds): `Arm(tracks)` replaces the queue at index 0 with no JS interop; `IsArmed` signals armed-but-not-streaming; `Start()` streams the current track and clears `IsArmed`. `AudioPlayerBar` reads `IsArmed` to route the embed's first play gesture through `Start()`. `QueueChanged` fires on all list/position changes; cascaded via `AudioPlayerProvider`. `Move`/`RemoveAt` are interop-free reorder/remove mutations that adjust `CurrentIndex` and never re-stream. `ClearUpcoming()` keeps the current track and drops the up-next. **Bug #4 (reactivity):** `AudioPlayerBar.QueueItems` caches `QueueService.Items` as a `_queueItemsCache` snapshot (the service exposes its backing list by reference); the cache is invalidated and set to `null` in `OnQueueChanged`, so every real mutation hands `QueueList` a new list reference while frequent progress-tick re-renders reuse the cached one without allocating. `QueueList.OnParametersSet` calls `_dropContainer?.Refresh()` so the `MudDropContainer` re-reads the new list and the open panel re-flows immediately. **Bug #1 (label):** the docked `QueueOverlay` panel header reads **"Playlist"** (the current track stays listed). `PlayRelease` materializes `tracks.ToList()` before mutating so it can never alias the service's own `Items` list.
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)``Stream` with optional Range header support for seek-beyond-buffer.
@@ -148,6 +150,12 @@ Two reasons this is needed and why it's a class, not a palette colour: (1) no Mu
**Self-themed components are authoritative over `.dd-accent-icon`.** `PlayStateIcon` owns its glyph colour inside `.icon-container` and must beat a surrounding `.dd-accent-icon` in dark — its scoped CSS rule targets `.mud-icon-root` at (0,5,0) `!important` (after Blazor's scope attribute is applied), which outranks the consolidation rule's (0,3,0) `!important`. Do not wrap a `PlayStateIcon` in `.dd-accent-icon` expecting to recolor its play-chip glyph — the play chip always shows navy (`--deepdrft-play-glyph`) against the moss-green chip in dark.
**Layout-only cluster class: `.dd-detail-top-actions`.** When two or more icon affordances sit together in a top-action row (e.g. the Theater toggle + lava-lamp popover on the three detail pages), wrap them in `.dd-detail-top-actions` — a layout-only `display:flex; align-items:center; gap:0.25rem` class in `deepdrft-styles.css`. No colour; prevents the `SpaceBetween` row from spreading the icons apart. Each affordance inside still carries its own `.dd-accent-icon` wrapper independently.
**Full-screen detail body: `.dd-detail-fill`.** Phase 20 Wave 2. Applied to each detail page's foreground content container (the `<div>` or `<MudContainer>` that wraps the scaffold/hero); sets `min-height: calc(100vh - var(--deepdrft-nav-height, 88px))` so the ambient/full-bleed visualizer reads as genuinely full-screen and the site footer is pushed below the fold, independent of Theater Mode. Reuses `--deepdrft-nav-height` (88px desktop / 72px mobile) so the clearance tracks the nav bar height across breakpoints; no new layout token. Defined in `deepdrft-styles.css`.
**Eased Theater Mode collapse: `.dd-theater-collapsible` / `.dd-theater-collapsed`.** Phase 20 Wave 2. Used wherever Theater Mode should ease content in/out rather than pop via `@if`. The outer wrapper carries `.dd-theater-collapsible` (always present); its single direct child carries `.dd-theater-collapsible-inner`; adding `.dd-theater-collapsed` to the outer collapses the region. Technique: `grid-template-rows: 1fr → 0fr` (real-height interpolation), `opacity`, and `visibility: hidden` + `transition-behavior: allow-discrete` (visibility flip deferred to end of ease-out so collapsed content is removed from the tab order once the animation completes; immediately re-shown on expand). A `prefers-reduced-motion` block collapses instantly. Used on the release content regions in all three detail pages (`IsContentHidden` predicate) and on the player-bar `NowShowingPanel` band (collapsed when `!TheaterMode`). Defined in `deepdrft-styles.css`.
**Gas-lamp toggle is self-colored in its SVG.** `DDIcons.GasLampLit` (dark-mode icon) carries `fill="#2A5C4F"` directly on its frame path — no CSS colour override is needed. The former dark nav rule (`.deepdrft-theme-dark .dd-nav-actions .mud-icon-button`) has been removed as dead. `DDIcons.GasLamp` (light-mode icon) continues to use `currentColor` and inherits nav text colour in light (the unlit toggle is theme-divergent by design).
## Development commands
@@ -3,10 +3,11 @@
@using DeepDrftPublic.Client.Services
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the
cascaded IQueueService's Enqueue/EnqueueRange (which append without disturbing current playback and
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select.
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@
cascaded IQueueService's Enqueue/EnqueueRange (which append to the END without disturbing current
playback; a first add into a dormant queue seeds the head from the externally-playing track when one
exists, then appends) — never PlayRelease/PlayTrack/Start/Select. Track mode (Track set) appends a
single track; release mode (ReleaseTracks set) appends the whole ordered list. Reads queue state from
the layout-level cascade (C1); owns no data fetch. *@
<MudTooltip Text="@Tooltip">
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
@@ -10,6 +10,22 @@ else
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
<MudPaper Elevation="8" Class="player-surface pa-3">
@* Theater Mode "now showing" band (Phase 20 §5/§7, Wave 2 §2). Keyed off the playing
track's Release, not off any detail page (the bar reaches into no page; §6). The release
page is hidden in Theater Mode, so the bar carries its identity: cover, linked title,
release share. The band stays mounted whenever a release is playing and eases in/out via
the shared .dd-theater-collapsible wrapper — collapsed (zero height, faded) unless
Theater is ON — so the bar grows/shrinks smoothly instead of popping. *@
@if (CurrentTrack?.Release is not null)
{
var nowShowing = VisualizerControlState.TheaterMode;
<div class="dd-theater-collapsible @(nowShowing ? null : "dd-theater-collapsed")">
<div class="dd-theater-collapsible-inner">
<NowShowingPanel Release="CurrentTrack.Release" />
</div>
</div>
}
<div class="player-layout">
<PlayerTransportZone IsLoaded="IsLoaded"
CanPlay="CanPlay"
@@ -45,7 +61,7 @@ else
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3).
Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked
overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started).
overlay, which calls JumpTo (moves the pointer and streams the row, clearing IsArmed).
Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
shrunken height to the host iframe. *@
@@ -16,12 +16,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
// Theater Mode (Phase 20). Property-injected (no constructor growth) so the bar can read
// TheaterMode to mount the "now showing" band and re-render when the flag flips. The toggle lives on
// the detail pages; the bar only observes — single source, multiple observers (§6).
[Inject] private WaveformVisualizerControlState VisualizerControlState { get; set; } = default!;
private bool _isMinimized = true;
private bool _isSeeking = false;
private double _seekPosition = 0;
private bool _queueOpen = false;
private IStreamingPlayerService? _subscribedService;
private IQueueService? _subscribedQueue;
private bool _subscribedToVisualizerState;
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
// spacer reserves its space. We mirror this element's live height into a CSS
@@ -85,7 +91,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
private bool ShowFixedPanel => Fixed && HasQueue;
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
// Cached snapshot of the queue list (bug #4 fix). QueueService.Items returns the service's
// backing list by reference, so passing it straight through means Blazor parameter diffing sees
// an unchanged reference after an in-place Clear/remove/reorder and the child (QueueList /
// MudDropContainer) keeps its stale snapshot until reopened. We snapshot on first access and
// rebuild in OnQueueChanged, so every real mutation hands the child a NEW reference while
// progress-tick re-renders (the frequent path) reuse the cached one without allocating.
private IReadOnlyList<TrackDto>? _queueItemsCache;
private IReadOnlyList<TrackDto> QueueItems =>
_queueItemsCache ??= QueueService is null ? [] : QueueService.Items.ToList();
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
@@ -135,12 +149,28 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
QueueService.QueueChanged += OnQueueChanged;
_subscribedQueue = QueueService;
}
// Theater Mode (Phase 20 §7): re-render the bar when TheaterMode flips so the "now showing" band
// appears/disappears. VisualizerControlState is injected (one stable scoped instance per session),
// so the subscribe is once-only — same idempotent subscribe-here / unsubscribe-on-dispose shape.
if (!_subscribedToVisualizerState)
{
VisualizerControlState.Changed += OnVisualizerStateChanged;
_subscribedToVisualizerState = true;
}
}
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private void OnQueueChanged()
{
// Invalidate the snapshot so QueueItems rebuilds a fresh list on the next render.
// This gives Blazor a new reference on every real mutation (bug #4 reactivity preserved)
// while progress-tick re-renders that don't go through here keep the cached reference.
_queueItemsCache = null;
// If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close
// the overlay so it cannot strand open over an empty queue. The button gate hides the overlay
// mount too, so this keeps state and view consistent.
@@ -189,12 +219,14 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private void ClearUpcoming() => QueueService?.ClearUpcoming();
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that
// touches playback — it streams the chosen track via the player.
// Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY,
// not an in-place seek), so a jump cannot route through it without duplicating the queue. JumpTo
// moves the pointer to the chosen row and streams it once — preserving deque order. This is the one
// queue action besides PLAY/skip that touches playback.
private async Task OnQueueJump(int index)
{
if (QueueService == null) return;
await QueueService.PlayRelease(QueueService.Items, index);
await QueueService.JumpTo(index);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -387,6 +419,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
_subscribedQueue = null;
}
if (_subscribedToVisualizerState)
{
VisualizerControlState.Changed -= OnVisualizerStateChanged;
_subscribedToVisualizerState = false;
}
if (_spacerModule is not null)
{
try
@@ -56,6 +56,54 @@
color: var(--mud-palette-primary);
}
/* Theater Mode "now showing" band (Phase 20 §5/§7). Sits above the transport layout inside the
player surface and lets the bar grow taller to carry the hidden release's identity. The band only
renders when Theater is ON, so this geometry is gated by render-inclusion, not a CSS flag when
Theater is OFF the player bar is byte-for-byte its non-Theater self.
Colour/surface come from the bar's themed --deepdrft-page-* aliases; no new token, no dark override. */
::deep .now-showing {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--deepdrft-page-text-muted);
min-width: 0;
}
/* Fixed cover box the reused .deepdrft-track-detail-cover-art / -placeholder idioms are height:100%,
so the band supplies the square frame they fill. */
::deep .now-showing-cover {
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 6px;
overflow: hidden;
}
::deep .now-showing-cover-art,
::deep .now-showing-cover-placeholder {
width: 100%;
}
::deep .now-showing-cover-placeholder .mud-icon-root {
font-size: 24px;
}
::deep .now-showing-title-link {
flex: 1 1 auto;
min-width: 0;
text-decoration: none;
}
::deep .now-showing-title {
color: var(--deepdrft-page-text);
}
::deep .now-showing-share {
flex: 0 0 auto;
}
/* Minimized floating dock — positioning + hover only; colour from MudFab */
.minimized-dock {
position: fixed;
@@ -0,0 +1,46 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
@using DeepDrftModels.DTOs
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Controls
@* "Now showing" block surfaced in the player bar when Theater Mode is ON (Phase 20 §5/§7). Theater
hides the release page, so the bar carries the release identity the page would have shown: cover art,
the release title linked to its detail page, and a release-mode share. Purely presentational — it owns
no player logic and no Theater state; AudioPlayerBar mounts it only when state.TheaterMode &&
CurrentTrack?.Release is not null, so Release is non-null here.
Theming is all reuse (§8, zero new CSS): the cover reuses the deepdrft-track-detail-cover-art /
-placeholder idiom; the share glyph goes green-accent in both themes via .dd-accent-icon; surface and
text come from the bar's own .player-surface and the .now-showing-* classes in the global sheet, which
bind the theme-aware --deepdrft-page-* aliases. *@
<div class="now-showing">
<div class="now-showing-cover">
@if (!string.IsNullOrEmpty(Release.ImagePath))
{
<div class="deepdrft-track-detail-cover-art now-showing-cover-art"
style="@($"background-image: url('api/image/{Uri.EscapeDataString(Release.ImagePath)}');")"></div>
}
else
{
<div class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary now-showing-cover-placeholder">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</div>
}
</div>
<a href="@ReleaseRoutes.DetailHref(Release)" class="now-showing-title-link">
<MudText Typo="Typo.subtitle2" Class="now-showing-title text-truncate">
@Release.Title
</MudText>
</a>
<div class="dd-accent-icon now-showing-share">
<SharePopover ReleaseEntryKey="@Release.EntryKey" ReleaseMedium="@Release.Medium" />
</div>
</div>
@code {
/// <summary>The current playing track's release. Non-null by the bar's mount gate.</summary>
[Parameter, EditorRequired] public ReleaseDto Release { get; set; } = default!;
}
@@ -71,6 +71,13 @@
private MudDropContainer<QueueRow>? _dropContainer;
// MudDropContainer snapshots its Items into internal drop zones and does not re-read them on a
// plain re-render — so a Clear/remove/reorder that changes the parent's Items list must be pushed
// into the container explicitly, or the panel shows the stale order until reopened (bug #4). The
// parent passes a fresh Items reference per mutation; refreshing here on every parameter set re-flows
// the container's snapshot to match. Cheap: Refresh only re-reads the bound list.
protected override void OnParametersSet() => _dropContainer?.Refresh();
// Index-tagged view rows. The index is the row's position in Items at render time and is the
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
private List<QueueRow> Rows =>
@@ -20,7 +20,7 @@
Class="deepdrft-queue-overlay">
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
<div class="deepdrft-queue-modal-header">
<span class="deepdrft-queue-modal-title">Up Next</span>
<span class="deepdrft-queue-modal-title">Playlist</span>
<MudButton Variant="Variant.Text"
Size="Size.Small"
Color="Color.Primary"
@@ -13,6 +13,7 @@ namespace DeepDrftPublic.Client.Controls;
public partial class ReleaseDetailScaffold : ComponentBase
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
[Parameter] public required string Title { get; set; }
[Parameter] public string? Artist { get; set; }
@@ -96,13 +97,19 @@ public partial class ReleaseDetailScaffold : ComponentBase
{
if (Track is null || PlayerService is null) return;
// Toggle if this track is already active (playing or paused); otherwise start a fresh
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
// Toggle if this track is already active (playing or paused); otherwise PLAY it —
// prepend to the queue's front (deque PLAY semantics) so it becomes current and
// the existing queue stays intact behind it. Falls back to a direct stream when
// the queue cascade is absent (prerender / non-interactive).
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(Track);
}
else
{
await PlayerService.SelectTrackStreaming(Track);
@@ -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;
}
@@ -36,13 +36,13 @@
{
<MudGrid>
@* ── Row 1 — MODE (always visible). ── *@
<MudItem xs="3" Class="d-flex align-center">
<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="9">
<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">
@@ -102,13 +102,13 @@
@* ── Row 2 — WAVE section (only when waveform on). ── *@
@if (ControlState.WaveformEnabled)
{
<MudItem xs="3" Class="d-flex align-center">
<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="9">
<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">
@@ -137,13 +137,13 @@
@if (ControlState.LavaEnabled)
{
<MudItem xs="3" Class="d-flex align-center">
<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="9" Class="d-flex align-center">
<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">
@@ -167,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"
@@ -226,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();
}
+2 -7
View File
@@ -344,11 +344,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 +356,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"),
];
+26 -1
View File
@@ -37,6 +37,10 @@ else
var hasYear = release.ReleaseDate is not null;
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
@* 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 +58,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">
@@ -117,8 +134,13 @@ else
}
</div>
</div>
</div>
</div>
</Header>
<BodyContent>
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Header region. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Blurb sits between the header and the track-list divider. *@
<ReleaseDescription Description="@release.Description" />
<MudDivider Class="cut-detail-divider" />
@@ -149,12 +171,15 @@ else
}
</div>
}
</div>
</div>
</BodyContent>
</ReleaseDetailScaffold>
</div>
}
@code {
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// PlayerService is cascaded by CutDetailBase (used there for the Theater release-playing predicate).
[CascadingParameter] public IQueueService? Queue { get; set; }
// Header Play: load the full album into the queue starting at track 0.
+65 -2
View File
@@ -1,4 +1,5 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
@@ -19,7 +20,41 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
[Inject] public required CutDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
// Theater Mode (Phase 20). The page owns the content gate, so it must re-render when the flag flips
// on the toggle. Property-injected; no constructor growth.
[Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; }
// Theater Mode is scoped to the currently-playing release (Phase 20 Wave 2 §3). The page observes
// player state so the toggle availability and content gate re-evaluate live when playback starts,
// stops, or moves to a different release. Cascaded by AudioPlayerProvider; no constructor growth.
// The cascade is IsFixed, so the provider's own re-render does not reach this page — the page must
// subscribe to StateChanged to re-render itself (same posture as AudioPlayerBar).
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
private IStreamingPlayerService? _subscribedPlayer;
/// <summary>
/// True when the currently-playing track belongs to this page's release. Theater Mode only applies
/// to the playing release: a detail page whose release is not playing ignores the global flag and
/// shows no toggle. Identity is the release <c>EntryKey</c> — the canonical public key the routes
/// and <see cref="DeepDrftPublic.Client.Common.ReleaseRoutes"/> use.
/// </summary>
protected bool IsThisReleasePlaying =>
PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey;
/// <summary>
/// True when this page's release content should be hidden for Theater Mode — only when Theater is on
/// AND this release is the one playing. Drives the eased collapse of the header/track-list regions.
/// </summary>
protected bool IsContentHidden => VisualizerControlState.TheaterMode && IsThisReleasePlaying;
/// <summary>
/// True when the Theater toggle should be offered on this page: a visualizer subsystem is on AND
/// this page's release is the one playing.
/// </summary>
protected bool ShowTheaterToggle =>
(VisualizerControlState.LavaEnabled || VisualizerControlState.WaveformEnabled) && IsThisReleasePlaying;
// The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g.
// /cuts/{a} -> /cuts/{b}) which reuse this component instance and fire OnParametersSet without
@@ -28,10 +63,29 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
private bool _loaded;
protected override void OnInitialized()
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
VisualizerControlState.Changed += OnVisualizerStateChanged;
}
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
protected override async Task OnParametersSetAsync()
{
// The player cascade is IsFixed, so the provider's re-render does not reach this page; subscribe
// to the StateChanged side-channel to re-render when playback moves between releases. Idempotent
// (reference-guarded) and unsubscribed on dispose — same posture as AudioPlayerBar.
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedPlayer))
{
if (_subscribedPlayer is not null)
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedPlayer = PlayerService;
}
if (_loaded && _loadedKey == EntryKey) return;
// Capture the key synchronously before any await so a re-entrant call (rapid navigation or a
@@ -61,7 +115,16 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
public void Dispose()
{
_persistingSubscription.Dispose();
VisualizerControlState.Changed -= OnVisualizerStateChanged;
if (_subscribedPlayer is not null)
{
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
_subscribedPlayer = null;
}
}
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
+30 -3
View File
@@ -41,7 +41,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 +56,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 +99,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 +119,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 +137,10 @@ else
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(track);
}
else
{
await PlayerService.SelectTrackStreaming(track);
@@ -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);
@@ -49,18 +49,30 @@ else
TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" />
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page session-detail-foreground">
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page session-detail-foreground dd-detail-fill">
<div class="session-detail-top-row">
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; All sessions
</MudLink>
@* 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 +98,8 @@ else
</ReleaseHeroOverlay>
<ReleaseDescription Description="@release.Description" />
</div>
</div>
</MudContainer>
}
@@ -93,11 +107,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 +125,10 @@ else
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(track);
}
else
{
await PlayerService.SelectTrackStreaming(track);
+75 -28
View File
@@ -10,18 +10,35 @@ namespace DeepDrftPublic.Client.Services;
/// — it adds no new playback semantics.
///
/// <para>
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/>
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> +
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only
/// adding new ones — so consumers written against today's surface keep working.
/// <b>Two-level deque model (the load-bearing invariant).</b> The queue is a deque whose
/// <see cref="Current"/> track (the item at <see cref="CurrentIndex"/>) is the live "front of play".
/// Two families of mutation enter the deque from opposite ends:
/// <list type="bullet">
/// <item><b>PLAY (manual)</b> — <see cref="PlayTrack"/> / <see cref="PlayRelease"/> prepend to the
/// <em>front</em>. The previously-current track is removed, the prepended track(s) become the head
/// in order, the new head becomes current and starts streaming, and whatever sat after the old
/// current stays intact behind the prepend. A whole release prepends in order in one operation.</item>
/// <item><b>Add-to-queue</b> — <see cref="Enqueue"/> / <see cref="EnqueueRange"/> append to the
/// <em>end</em>. They never interrupt the current track and never start playback.</item>
/// </list>
/// </para>
///
/// <para>
/// <b>Advance and end-of-track.</b> <see cref="Next"/> and auto-advance (the player's
/// <see cref="IPlayerService.TrackEnded"/>) walk <see cref="CurrentIndex"/> forward, leaving the just-
/// played track in the list behind the pointer so <see cref="Previous"/> can step back to it. The one
/// exception is the <em>last</em> track: when the current track ends naturally and there is nothing
/// after it, the queue <b>empties</b> and goes dormant (<see cref="CurrentIndex"/> == -1) rather than
/// stranding the finished track as current.
/// </para>
///
/// <para>
/// With an empty queue (<see cref="CurrentIndex"/> == -1) the queue is dormant: it drives nothing and
/// auto-advances nothing, so direct single-track play through the player behaves exactly as it did
/// before the queue existed.
/// before the queue existed. The <b>first</b> <see cref="Enqueue"/>/<see cref="EnqueueRange"/> into a
/// dormant queue while a track is already playing externally seeds the head from the player's current
/// track (learned through the attached player, no extra dependency) and then appends the added item, so
/// the resulting deque is <c>[now-playing, added…]</c> rather than a phantom single entry.
/// </para>
/// </summary>
public interface IQueueService
@@ -41,9 +58,10 @@ public interface IQueueService
/// <summary>
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet —
/// the embed's pre-gesture state. Set by <see cref="Arm"/>; cleared the moment playback actually
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="Next"/>/<see cref="Previous"/>)
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone.
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="PlayTrack"/>/<see cref="Next"/>/
/// <see cref="Previous"/>) or on <see cref="Clear"/>. The player bar reads this to route the first
/// play gesture through <see cref="Start"/> (which begins the armed release) rather than streaming
/// the staged track alone.
/// </summary>
bool IsArmed { get; }
@@ -55,26 +73,40 @@ public interface IQueueService
/// <summary>
/// Raised whenever the queue's contents or current position change. The player bar subscribes
/// to re-render its skip-forward/back affordances. Fires on enqueue, advance, step-back, and clear.
/// to re-render its skip-forward/back affordances. Fires on enqueue, prepend, advance, step-back,
/// and clear.
/// </summary>
event Action? QueueChanged;
/// <summary>
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to
/// the end from there. No-op when <paramref name="tracks"/> is empty.
/// Manual PLAY of a single track: prepends <paramref name="track"/> to the <em>front</em> of the
/// deque, removes the previously-current track, makes <paramref name="track"/> the new head/current,
/// and starts streaming it. The rest of the queue (everything that sat after the old current) stays
/// intact behind the new head. Into a dormant queue this simply becomes the sole head and plays.
/// This is the deque-front counterpart to the append-only <see cref="Enqueue"/>.
/// </summary>
Task PlayTrack(TrackDto track);
/// <summary>
/// Manual PLAY of a release: prepends <paramref name="tracks"/> (in the order given) to the
/// <em>front</em> of the deque, removes the previously-current track, and starts streaming the
/// prepended track at <paramref name="startIndex"/> — which becomes current. Tracks prepended
/// before <paramref name="startIndex"/> sit behind the pointer (reachable via <see cref="Previous"/>);
/// tracks after it are up-next; whatever sat after the old current stays intact behind the whole
/// prepend. This is the "play album" entry point the detail pages consume: a header Play uses
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index. No-op when
/// <paramref name="tracks"/> is empty.
/// </summary>
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
/// <summary>
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT
/// streaming anything — the queue is "armed". This is the embed's prerender-safe entry point: it
/// performs no JS interop, so it runs identically during prerender and after WASM boot. The first
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which
/// keeps the loaded release queued so it advances through its tracks. No-op when
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed).
/// performs no JS interop, so it runs identically during prerender and after WASM boot. It replaces
/// the queue (an armed embed is a fresh staged release, not a prepend). The first play gesture (see
/// <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which keeps the loaded
/// release queued so it advances through its tracks. No-op when <paramref name="tracks"/> is empty
/// (the queue stays empty and disarmed).
/// </summary>
void Arm(IEnumerable<TrackDto> tracks);
@@ -88,18 +120,24 @@ public interface IQueueService
Task Start();
/// <summary>
/// Appends a track to the end of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
/// Appends a track to the <em>end</em> of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) while a track is already playing
/// externally (through the attached player but not via the queue), the append first seeds the head
/// with that now-playing track, then appends <paramref name="track"/> — yielding
/// <c>[now-playing, track]</c> so the queue reflects what the listener actually hears. Into a fully
/// dormant queue with nothing playing, the single appended track becomes the head at
/// <see cref="CurrentIndex"/> == 0. Either way it does NOT begin playback (add is not play).
/// Interop-free; safe during prerender.
/// </summary>
void Enqueue(TrackDto track);
/// <summary>
/// Appends tracks to the end of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
/// Appends tracks to the <em>end</em> of the queue without changing what is currently playing.
/// Into a dormant queue while a track is already playing externally, the append first seeds the head
/// with that now-playing track (see <see cref="Enqueue"/>), then appends the range. Into a fully
/// dormant queue with nothing playing, the first appended track becomes the head at
/// <see cref="CurrentIndex"/> == 0. It does NOT begin playback (add is not play). Interop-free; safe
/// during prerender.
/// </summary>
void EnqueueRange(IEnumerable<TrackDto> tracks);
@@ -136,6 +174,15 @@ public interface IQueueService
/// </summary>
Task Previous();
/// <summary>
/// Moves the current pointer to <paramref name="index"/> and streams that track once. This is the
/// row-jump primitive the open playlist panel uses: unlike <see cref="PlayRelease"/> it does not
/// prepend (the track is already in the deque), and unlike repeated <see cref="Next"/> it does not
/// stream the intervening rows. No-op when <paramref name="index"/> is out of range or already
/// current.
/// </summary>
Task JumpTo(int index);
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
void Clear();
+95 -26
View File
@@ -3,10 +3,12 @@ using DeepDrftModels.DTOs;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Default <see cref="IQueueService"/>: a single-slot orchestrator over an
/// Default <see cref="IQueueService"/>: a two-level deque orchestrator over an
/// <see cref="IStreamingPlayerService"/>. Holds the ordered list and current index as pure state,
/// drives playback through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>,
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal. PLAY mutations enter
/// the front (prepend); add-to-queue mutations enter the back (append) — see <see cref="IQueueService"/>
/// for the full invariant.
///
/// <para>
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
@@ -14,7 +16,8 @@ namespace DeepDrftPublic.Client.Services;
/// creates the player) rather than constructor injection. This keeps the player single-slot, avoids a
/// construction cycle between provider/player/queue, and needs no <c>IServiceProvider</c>. The queue's
/// own constructor stays parameterless, so the queue logic is unit-testable against a fake player with
/// no container.
/// no container. The attached player is also the seam by which the queue learns the externally-playing
/// track when a dormant <see cref="Enqueue"/> needs to seed the head.
/// </para>
/// </summary>
public sealed class QueueService : IQueueService, IDisposable
@@ -54,23 +57,42 @@ public sealed class QueueService : IQueueService, IDisposable
_player.TrackEnded += OnTrackEnded;
}
public async Task PlayTrack(TrackDto track)
{
PrependForPlay(new[] { track }, prependIndex: 0);
QueueChanged?.Invoke();
await PlayCurrent();
}
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
{
var list = tracks.ToList();
if (list.Count == 0) return;
var start = Math.Clamp(startIndex, 0, list.Count - 1);
_items.Clear();
_items.AddRange(list);
CurrentIndex = start;
// Playback is now starting for real, so the queue is no longer merely armed.
IsArmed = false;
PrependForPlay(list, start);
QueueChanged?.Invoke();
await PlayCurrent();
}
// The shared PLAY-prepend mutation (bug #5). Removes the previously-current track, inserts the
// played track(s) at the front in order, and points CurrentIndex at the prepended item the caller
// chose to start on. Whatever sat AFTER the old current stays intact behind the prepend; the old
// back-history (items before the old current) is discarded because a fresh PLAY defines a new
// front. Pure state — callers invoke QueueChanged + PlayCurrent. IsArmed clears: playback is real now.
private void PrependForPlay(IReadOnlyList<TrackDto> played, int prependIndex)
{
// Drop the previously-current track only (its tail — the up-next after it — is preserved).
// Anything before the old current is back-history that a new PLAY supersedes.
if (CurrentIndex >= 0 && CurrentIndex < _items.Count)
_items.RemoveRange(0, CurrentIndex + 1);
_items.InsertRange(0, played);
CurrentIndex = prependIndex;
IsArmed = false;
}
public void Arm(IEnumerable<TrackDto> tracks)
{
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
@@ -94,27 +116,47 @@ public sealed class QueueService : IQueueService, IDisposable
public void Enqueue(TrackDto track)
{
SeedHeadFromPlayerIfDormant();
_items.Add(track);
// OQ8: appending into a dormant (empty) queue leaves a coherent CurrentIndex so the next
// play/skip is correct — but does NOT auto-play (add is not play). PlayCurrent is never
// called here, so this stays interop-free and prerender-safe.
if (CurrentIndex == -1)
CurrentIndex = 0;
EnsureCoherentDormantIndex();
QueueChanged?.Invoke();
}
public void EnqueueRange(IEnumerable<TrackDto> tracks)
{
var before = _items.Count;
_items.AddRange(tracks);
if (_items.Count == before) return;
// OQ8: see Enqueue — first append into a dormant queue stages a coherent CurrentIndex
// without playing. The first newly-appended track becomes current.
if (CurrentIndex == -1)
CurrentIndex = 0;
var toAdd = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
if (toAdd.Count == 0) return;
SeedHeadFromPlayerIfDormant();
_items.AddRange(toAdd);
EnsureCoherentDormantIndex();
QueueChanged?.Invoke();
}
// Bug #3: the first add into a dormant queue while a track is already playing externally (through
// the attached player but not via the queue) must seed the head with that now-playing track, so the
// append yields [now-playing, added] instead of a phantom single entry. We read the player's
// CurrentTrack — the same seam OnTrackEnded uses — so no extra dependency is introduced. Only seeds
// when truly dormant (empty list) AND a player track exists; a non-dormant queue is untouched.
private void SeedHeadFromPlayerIfDormant()
{
if (_items.Count != 0) return;
var playing = _player?.CurrentTrack;
if (playing is null) return;
_items.Add(playing);
CurrentIndex = 0;
}
// After an append, a dormant queue (CurrentIndex == -1, e.g. nothing was playing to seed from)
// needs a coherent head so a subsequent play/skip is correct — but add is not play, so we never
// stream here. A queue that already has a current index is left untouched.
private void EnsureCoherentDormantIndex()
{
if (CurrentIndex == -1 && _items.Count > 0)
CurrentIndex = 0;
}
public void Move(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return;
@@ -192,6 +234,16 @@ public sealed class QueueService : IQueueService, IDisposable
await PlayCurrent();
}
public async Task JumpTo(int index)
{
if (index < 0 || index >= _items.Count) return;
if (index == CurrentIndex) return;
CurrentIndex = index;
IsArmed = false;
QueueChanged?.Invoke();
await PlayCurrent();
}
public void Clear()
{
if (_items.Count == 0 && CurrentIndex == -1) return;
@@ -217,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();
}
+85 -10
View File
@@ -10,14 +10,81 @@
* read it. One observer at a time, re-pointed on each `observe` call; the var
* resets to 0 on `unobserve` (player minimized / disposed) so the spacer
* collapses.
*
* COALESCING (Phase 20 theater-flash fix). `--player-height` has two consumers:
* the layout spacer div AND the ambient WaveformVisualizer backdrop, whose
* `bottom` inset is this var (WaveformVisualizer.razor.css `.mix-waveform-bg`).
* Moving that inset changes the visualizer canvas's CSS box, which fires the
* renderer's own canvas ResizeObserver and a GL resize CLEARS the backing
* store. That is correct and cheap for a discrete bar-height change (breakpoint
* reflow, minimize/expand, error banner). But Theater Mode eases the player bar's
* "now showing" band open/closed over ~0.45s via a CSS grid-rows transition, so
* the bar height changes EVERY FRAME of the ease. Mirroring each intermediate
* frame here would re-clear the GL backing store ~27×, reading as a flash.
*
* The fix coalesces the publish with a LEADING + TRAILING edge: the first change
* after a quiet period is written immediately (so a discrete jump the common
* case has zero added latency and the clip never lags), then a rapid STREAM of
* further changes (an animated transition) is debounced and only its SETTLED
* end-state is written. So a Theater ease resizes the visualizer at most twice
* (leading 1px move + final settle) instead of once per frame. The settled value
* is always the last write, so at-rest sizing/clip stays exact; and this remains
* the SOLE writer of `--player-height`, so the renderer's ResizeObserver stays the
* sole canvas size writer (its invariant is untouched).
*/
const HEIGHT_VAR = '--player-height';
let observer: ResizeObserver | null = null;
function writeHeight(px: number): void {
/**
* Quiet window (ms) after which a pending settled height is flushed. One change
* then silence (a discrete reflow) flushes after this delay but was ALSO written
* on the leading edge, so the trailing flush is a no-op discrete jumps pay no
* latency. A continuous transition keeps resetting this timer until it ends, then
* flushes the final height once. ~80ms comfortably exceeds a frame interval (so a
* mid-ease frame never trips an early flush) yet settles promptly after the ease.
*/
const SETTLE_MS = 80;
let observer: ResizeObserver | null = null;
let lastWritten = -1;
let pendingHeight = -1;
let settleTimer: number | null = null;
function setVar(px: number): void {
// Round up so sub-pixel heights never leave a hairline of overlap.
document.documentElement.style.setProperty(HEIGHT_VAR, `${Math.ceil(px)}px`);
const rounded = Math.ceil(px);
if (rounded === lastWritten) return;
lastWritten = rounded;
document.documentElement.style.setProperty(HEIGHT_VAR, `${rounded}px`);
}
/**
* Publish a measured height with leading + trailing coalescing. Leading: if no
* settle is pending, this is the first change after a quiet period write it now.
* Trailing: (re)arm the settle timer so the final value of a rapid stream lands
* once the stream stops.
*/
function publishHeight(px: number): void {
pendingHeight = px;
if (settleTimer === null) {
// Leading edge — discrete jumps land immediately; the first frame of a
// transition lands too (one resize), then the rest is debounced below.
setVar(px);
}
if (settleTimer !== null) {
clearTimeout(settleTimer);
}
settleTimer = window.setTimeout(() => {
settleTimer = null;
setVar(pendingHeight);
}, SETTLE_MS);
}
function measure(entry: ResizeObserverEntry): number {
// Prefer the border-box measurement; fall back to contentRect on the
// (older) engines that don't populate borderBoxSize.
const box = entry.borderBoxSize?.[0];
return box ? box.blockSize : entry.contentRect.height;
}
export function observe(element: Element): void {
@@ -27,20 +94,28 @@ export function observe(element: Element): void {
observer = new ResizeObserver(entries => {
const entry = entries[0];
if (!entry) return;
// Prefer the border-box measurement; fall back to contentRect on the
// (older) engines that don't populate borderBoxSize.
const box = entry.borderBoxSize?.[0];
writeHeight(box ? box.blockSize : entry.contentRect.height);
publishHeight(measure(entry));
});
observer.observe(element);
// Seed synchronously so the spacer is correct on this frame, before the
// first ResizeObserver callback fires.
writeHeight(element.getBoundingClientRect().height);
// first ResizeObserver callback fires. A fresh observe target is a discrete
// change, so write it straight through (bypassing the debounce) — re-pointing
// the observer (e.g. expanded <-> minimized) must not lag behind a settle.
if (settleTimer !== null) {
clearTimeout(settleTimer);
settleTimer = null;
}
setVar(element.getBoundingClientRect().height);
}
export function unobserve(): void {
observer?.disconnect();
observer = null;
writeHeight(0);
if (settleTimer !== null) {
clearTimeout(settleTimer);
settleTimer = null;
}
pendingHeight = -1;
setVar(0);
}
@@ -351,6 +351,75 @@ h2, h3, h4, h5, h6,
gap: 0.5rem;
}
/* Theater toggle + lava-lamp popover cluster on the detail-page top action row (Phase 20 §3). Keeps
the two icon affordances adjacent on the right edge rather than letting the SpaceBetween row spread
them apart. Shared by Cut/Mix (scaffold TopRightAction) and Session (its own top row). */
.dd-detail-top-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Full-screen detail body (Phase 20 Wave 2 §1). The content body always fills the viewport below the
fixed nav so the ambient/full-bleed visualizer reads as genuinely full-screen and the footer is pushed
below the fold (scroll to reach it) independent of Theater Mode. Reuses the shared
--deepdrft-nav-height token (88px desktop / 72px mobile) so the clearance tracks the bar across
breakpoints; no new layout token. Applied to each detail page's foreground content container. */
.dd-detail-fill {
min-height: calc(100vh - var(--deepdrft-nav-height, 88px));
}
/* Eased content collapse for Theater Mode (Phase 20 Wave 2 §2). The detail content stays mounted and
collapses smoothly when .dd-theater-collapsed is applied, so toggling Theater eases both directions
instead of popping when collapsed the content is fully out of the way and the visualizer is
unobstructed. The same pattern drives the player-bar "now showing" band so the bar grows/shrinks
smoothly too.
Technique: grid-template-rows 1fr 0fr interpolates the REAL content height (no 400vh ceiling
artifact / delayed-start that the old max-height approach had). The direct child receives
overflow:hidden + min-height:0 so it actually clips during the transition (the grid child is the
collapsing unit). visibility:hidden removes all descendants from the tab order and from pointer/
keyboard interaction once collapsed this fixes the Major accessibility defect where Tab could
reach hidden controls. transition-behavior:allow-discrete makes visibility flip discretely: it
flips to hidden AFTER the ease-out finishes (so the animation plays fully), and flips back to
visible BEFORE the ease-in starts (so content is immediately interactive on the way back in).
The visibility transition duration matches the height ease (0.45s) so allow-discrete has a real
interval to defer against: on collapse the flip to hidden is held until t=0.45s; on reopen it
fires at t=0 (immediately interactive). A 0s duration would fire the flip at t0 on collapse,
defeating the deferral and hiding content before the ease-out finishes. */
.dd-theater-collapsible {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
transition: grid-template-rows 0.45s ease, opacity 0.3s ease, visibility 0.45s;
transition-behavior: allow-discrete;
}
/* The single direct child clips itself during the grid-row collapse. min-height:0 overrides the
implicit min-height:auto that would prevent the row from shrinking past the content's intrinsic
height. overflow:hidden clips painted content when the row is partially collapsed. */
.dd-theater-collapsible > * {
overflow: hidden;
min-height: 0;
}
.dd-theater-collapsed {
grid-template-rows: 0fr;
opacity: 0;
/* visibility flips to hidden at the END of the 0.45s ease-out (deferred by allow-discrete);
on reopen it flips back to visible at t=0 so content is immediately interactive. */
visibility: hidden;
}
/* Honor reduced-motion: collapse still happens (it is layout, not decoration) but instantly, matching
the parallax precedent (transition-duration: 0). */
@media (prefers-reduced-motion: reduce) {
.dd-theater-collapsible {
transition-duration: 0ms;
}
}
.deepdrft-track-detail-meta {
display: flex;
flex-direction: row;
@@ -440,7 +509,7 @@ h2, h3, h4, h5, h6,
flex-direction: column;
gap: 0.75rem;
min-height: 0;
max-width: 420px;
max-width: 480px;
/* Pin the MudBlazor palette vars the portaled RadialKnob + slider consume. */
--mud-palette-primary: var(--deepdrft-green-accent); /* knob arc/pointer + slider track/thumb (interactive) */
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */
+285 -27
View File
@@ -5,11 +5,12 @@ using Microsoft.AspNetCore.Components;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the play-queue orchestrator (<see cref="QueueService"/>). The queue is pure
/// domain logic over the single-slot player, so it is exercised here against a recording fake
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage:
/// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and
/// auto-advance on the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// Unit tests for the two-level deque play-queue orchestrator (<see cref="QueueService"/>). The queue
/// is pure domain logic over the single-slot player, so it is exercised here against a recording fake
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage: PLAY-
/// prepend (single + release), add-to-queue append, dormant-seed-from-player, ordered advance,
/// next/previous bounds, jump, clear, current-index integrity, and auto-advance / last-track-empty on
/// the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// </summary>
[TestFixture]
public class QueueServiceTests
@@ -125,8 +126,12 @@ public class QueueServiceTests
}
[Test]
public async Task PlayRelease_ReplacesAnExistingQueue()
public async Task PlayRelease_PrependsToFront_RemovesPreviousCurrent_KeepsRemainderIntact()
{
// Deque PLAY (bug #5): PlayRelease into a non-empty queue prepends the release at the front,
// removes the previously-current track, and leaves the up-next that sat after it intact behind
// the prepend. Current was track-1 (index 0) → after prepend, the old current is dropped and
// its tail (track-2, track-3) stays behind [x-1, x-2].
await _queue.PlayRelease(Tracks(3));
var second = new List<TrackDto>
{
@@ -138,39 +143,133 @@ public class QueueServiceTests
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Has.Count.EqualTo(2));
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" }));
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "x-1", "x-2", "track-2", "track-3" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("x-1"));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("x-1"));
});
}
[Test]
public async Task PlayRelease_ViaLiveQueueItems_PreservesTracksAndJumpsToIndex()
public async Task PlayRelease_FromMidQueueCurrent_DropsOnlyTheCurrentTrack_NotItsTail()
{
// Regression guard for the aliasing bug: OnQueueJump calls PlayRelease(QueueService.Items, index).
// Items returns the backing list directly; without a defensive copy, the cast
// "tracks as IReadOnlyList<TrackDto>" aliases _items, so _items.Clear() also clears list,
// and _items.AddRange(list) adds nothing — wiping the queue and playing nothing.
await _queue.PlayRelease(Tracks(4)); // populate the live queue
// Current advanced to track-2 (index 1) with track-3, track-4 after it. PLAY of a new release
// drops only track-2 (the current) and keeps track-3, track-4 behind the prepend. The old
// back-history (track-1, before the current) is discarded — a fresh PLAY defines a new front.
await _queue.PlayRelease(Tracks(4));
await _queue.Next(); // current = track-2 at index 1
// Jump to index 2 via the live Items reference, exactly as OnQueueJump does.
await _queue.PlayRelease(new List<TrackDto> { new() { EntryKey = "p-1", TrackName = "P1" } });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "p-1", "track-3", "track-4" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("p-1"));
});
}
[Test]
public async Task PlayRelease_WithStartIndex_PrependsWholeReleaseInOrder_CurrentAtStartIndex()
{
// A mid-album row play prepends the whole release in order; the chosen startIndex becomes
// current. Tracks before it sit behind the pointer (Previous reaches them); tracks after are
// up-next. The previous current is dropped.
await _queue.PlayRelease(Tracks(2)); // existing queue: [track-1*, track-2]
var release = new List<TrackDto>
{
new() { EntryKey = "r-1", TrackName = "R1" },
new() { EntryKey = "r-2", TrackName = "R2" },
new() { EntryKey = "r-3", TrackName = "R3" },
};
await _queue.PlayRelease(release, startIndex: 1);
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "r-1", "r-2", "r-3", "track-2" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("r-2"));
Assert.That(_queue.HasPrevious, Is.True); // r-1 is behind the pointer
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("r-2"));
});
}
[Test]
public async Task PlayRelease_ViaLiveQueueItems_DoesNotCorruptListUnderPrepend()
{
// Aliasing guard retained under the deque model: a caller that passes the live Items reference
// into PlayRelease must not corrupt the list. PlayRelease materializes tracks.ToList() before
// the RemoveRange/InsertRange prepend, so the defensive copy survives the mutation.
await _queue.PlayRelease(Tracks(4));
// Pass the live Items reference (current = track-1). Prepend drops the current and re-inserts
// the copy at the front, with the tail (track-2..4) preserved behind it.
await _queue.PlayRelease(_queue.Items, 2);
Assert.Multiple(() =>
{
// The queue must survive — all four tracks still present, in order.
Assert.That(_queue.Items, Has.Count.EqualTo(4));
// The defensive copy is intact: all four original tracks were re-prepended in order, and the
// old current's tail follows. CurrentIndex is the chosen start.
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
// CurrentIndex must be the jumped-to slot.
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4", "track-2", "track-3", "track-4" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
// Current must be the right track.
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
// The player must have streamed the jumped-to track.
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
});
}
// --- PlayTrack: deque PLAY of a single track (prepend to front) — bug #5 ---
[Test]
public async Task PlayTrack_IntoDormantQueue_BecomesSoleHeadAndStreams()
{
await _queue.PlayTrack(new TrackDto { EntryKey = "solo", TrackName = "Solo" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "solo" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("solo"));
});
}
[Test]
public async Task PlayTrack_FromNonEmptyQueue_PrependsDropsPreviousCurrent_KeepsRemainder()
{
// bug #5: PLAY of a single track from a non-empty queue prepends it as the new head, drops the
// previously-current track, and leaves the remainder intact behind the new head.
await _queue.PlayRelease(Tracks(3)); // [track-1*, track-2, track-3]
await _queue.PlayTrack(new TrackDto { EntryKey = "jump-in", TrackName = "Jump In" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "jump-in", "track-2", "track-3" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("jump-in"));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("jump-in"));
});
}
[Test]
public async Task PlayTrack_DisarmsAnArmedQueue()
{
_queue.Arm(Tracks(3));
await _queue.PlayTrack(new TrackDto { EntryKey = "override", TrackName = "Override" });
Assert.Multiple(() =>
{
Assert.That(_queue.IsArmed, Is.False);
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("override"));
});
}
// --- Arm: prerender-safe load without streaming (release embed) ---
[Test]
@@ -671,6 +770,81 @@ public class QueueServiceTests
});
}
[Test]
public void Enqueue_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
{
// Bug #3: a single track is playing NOT through the queue (the player's CurrentTrack is set, the
// queue is dormant). The first Add-to-queue must seed the head with that now-playing track and
// then append the added one → [now-playing, added], even if they are the same track.
var nowPlaying = new TrackDto { Id = 7, EntryKey = "now-playing", TrackName = "Now Playing" };
_player.SimulateDirectPlay(nowPlaying);
_queue.Enqueue(new TrackDto { EntryKey = "added", TrackName = "Added" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "now-playing", "added" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "the now-playing track is the head/current");
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("now-playing"));
Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed");
});
// A second add appends a third item — no ghost/duplicate seeding.
_queue.Enqueue(new TrackDto { EntryKey = "added-2", TrackName = "Added 2" });
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "now-playing", "added", "added-2" }));
}
[Test]
public void Enqueue_OfTheSameExternallyPlayingTrack_SeedsHeadThenAppendsTheDuplicate()
{
// Bug #3 exact repro: add the very track that is playing externally. Result must be a 2-item
// queue [now-playing(current), same-track-appended] — not a single ghost entry.
var nowPlaying = new TrackDto { Id = 7, EntryKey = "the-track", TrackName = "The Track" };
_player.SimulateDirectPlay(nowPlaying);
_queue.Enqueue(nowPlaying);
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Has.Count.EqualTo(2));
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "the-track", "the-track" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
});
}
[Test]
public void EnqueueRange_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
{
var nowPlaying = new TrackDto { Id = 9, EntryKey = "live", TrackName = "Live" };
_player.SimulateDirectPlay(nowPlaying);
_queue.EnqueueRange(Tracks(2));
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "live", "track-1", "track-2" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
Assert.That(_player.SelectedTracks, Is.Empty);
});
}
[Test]
public void Enqueue_IntoDormantQueue_WithNothingPlaying_DoesNotSeedAPhantomHead()
{
// No external track playing → nothing to seed. The single added track is the head (OQ8 coherent
// index), and there is no phantom duplicate.
_queue.Enqueue(new TrackDto { EntryKey = "only", TrackName = "Only" });
Assert.Multiple(() =>
{
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "only" }));
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
});
}
[Test]
public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex()
{
@@ -686,6 +860,67 @@ public class QueueServiceTests
});
}
// --- JumpTo: row-jump within the deque (move pointer + stream once) ---
[Test]
public async Task JumpTo_MovesPointerForwardAndStreamsTheTargetOnce()
{
await _queue.PlayRelease(Tracks(4)); // current = track-1
var streamedBefore = _player.SelectedTracks.Count;
await _queue.JumpTo(2);
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
// Exactly one new stream — the intervening track-2 must NOT have been streamed.
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore + 1));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
});
}
[Test]
public async Task JumpTo_MovesPointerBackwardAndStreamsTheTarget()
{
await _queue.PlayRelease(Tracks(4), startIndex: 3); // current = track-4
await _queue.JumpTo(1);
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2"));
});
}
[Test]
public async Task JumpTo_DoesNotDuplicateTheQueue()
{
// Regression guard: JumpTo must NOT prepend (it is not a PLAY) — the deque length is unchanged.
await _queue.PlayRelease(Tracks(4));
await _queue.JumpTo(2);
Assert.That(_queue.Items.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
}
[Test]
public async Task JumpTo_SameIndexOrOutOfRange_IsNoOp()
{
await _queue.PlayRelease(Tracks(3)); // current = track-1
var streamedBefore = _player.SelectedTracks.Count;
await _queue.JumpTo(0); // already current
await _queue.JumpTo(-1);
await _queue.JumpTo(3);
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
"no-op jumps must not re-stream");
}
// --- Clear ---
[Test]
@@ -820,16 +1055,38 @@ public class QueueServiceTests
}
[Test]
public async Task TrackEnded_OnLastTrack_DoesNotAdvanceOrReplay()
public async Task TrackEnded_OnLastTrack_EmptiesTheQueueAndGoesDormant()
{
// Bug #2: when the current track ends naturally and there is nothing after it, the queue empties
// (CurrentIndex == -1, dormant) rather than stranding the finished track as current. No replay.
await _queue.PlayRelease(Tracks(2), startIndex: 1);
var raised = false;
_queue.QueueChanged += () => raised = true;
_player.RaiseTrackEnded();
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
Assert.That(_queue.Current, Is.Null);
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "no replay on end");
Assert.That(raised, Is.True, "emptying the queue raises QueueChanged");
});
}
[Test]
public async Task TrackEnded_OnSingleTrackQueue_EmptiesTheQueue()
{
// Bug #2, single-track variant: a one-item queue playing to its end empties (dormant).
await _queue.PlayRelease(Tracks(1));
_player.RaiseTrackEnded();
Assert.Multiple(() =>
{
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
});
}
@@ -877,17 +1134,18 @@ public class QueueServiceTests
}
[Test]
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd()
public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd_ThenEmptiesOnLastEnd()
{
await _queue.PlayRelease(Tracks(3));
_player.RaiseTrackEnded(); // → track-2
_player.RaiseTrackEnded(); // → track-3
_player.RaiseTrackEnded(); // last track: no advance
_player.RaiseTrackEnded(); // last track ends → queue empties (bug #2)
Assert.Multiple(() =>
{
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
Assert.That(_queue.Items, Is.Empty);
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
});
@@ -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);
}
}
+2
View File
@@ -443,6 +443,8 @@ not the same work; this phase does not satisfy or depend on that one.
---
---
## Working with this file
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing.
+323
View File
@@ -0,0 +1,323 @@
# Phase 20 — Theater Mode (public Release Detail views)
Product spec. Status: **landed and merged to dev — 2026-06-20; Wave 2 refinements landed 2026-06-21.** All §9 open questions resolved at sign-off 2026-06-20.
Surface: **public listener site only** (`DeepDrftPublic` / `DeepDrftPublic.Client`). No CMS
(`DeepDrftManager`) change. No API, data, or schema change — Theater Mode is a pure
presentation-layer feature riding data the player already carries.
**Wave 2 refinements (landed 2026-06-21):** Three post-ship improvements. (1) *Full-screen detail body* — each detail page's foreground container gained `.dd-detail-fill` so the visualizer reads full-screen and the footer is pushed below the fold regardless of Theater Mode. (2) *Eased collapse* — the hard `@if` content-hide on all three detail pages and the player-bar `NowShowingPanel` was replaced by a `.dd-theater-collapsible` / `.dd-theater-collapsed` CSS pair (`grid-template-rows: 1fr → 0fr` + `opacity` + deferred `visibility`); the panel now stays mounted and collapsed rather than unmounting via `@if` (enables the ease-in; resolves OQ2 design intent). (3) *Playing-release scoping* — Theater Mode now only applies to the currently-playing release: `ReleaseDetailBase` / `CutDetailBase` each gained a cascaded `IStreamingPlayerService` subscription and predicates (`IsThisReleasePlaying`, `IsContentHidden`, `ShowTheaterToggle`); `TheaterModeToggle` gained an `Available` parameter; all three pages pass `Available="ShowTheaterToggle"`, so a detail page whose release is not playing shows no toggle and ignores the global flag.
**Wave 2 follow-up fix (landed 2026-06-22):** The eased player-bar collapse (improvement 2 above) caused a visible flash when entering or leaving Theater Mode. The `.mix-waveform-bg` ambient visualizer backdrop positions itself via `bottom: var(--player-height)`, and `spacer.ts` was writing that CSS custom property on every ResizeObserver frame — so the ~0.45 s animated bar growth rewrote `--player-height` every frame, which fired the visualizer's own canvas ResizeObserver each time and cleared the GL backing store on each resize. Fixed by adding leading + trailing-edge coalescing in `spacer.ts` (SETTLE_MS = 80 ms): a discrete height change (breakpoint reflow, minimize/expand, error banner) still writes immediately with zero added latency; a rapid animated stream only writes its settled end-state. `spacer.ts` remains the sole writer of `--player-height`; at-rest clip correctness is exact across all breakpoints.
---
## 1. Goal
On a Release Detail view, let the listener **clear the page chrome away from the visualizer** with one
toggle — hiding the release content (header/meta/track-list/blurb) so the lava-lamp + waveform field
fills the surface unobstructed — while the **player bar grows** to carry the now-essential release
identity (cover art, release title, share) that the hidden page would otherwise have shown.
It is a "lean back and watch the lamp" mode. The visualizer is already the most distinctive thing the
site does (Phase 10/12/15); Theater Mode makes it the *whole* thing on demand, and relocates the
minimum release identity to the one piece of chrome that stays — the player bar.
**One-line framing:** Theater Mode trades the release page for the visualizer, and pays for the lost
release identity by enlarging the player bar.
---
## 2. Scope — the three Release Detail views (verified against the code)
The feature must behave identically across all three release mediums. The relevant files:
| Medium | Page file | Visualizer mount | Lava-lamp toggle host |
|---------|---------------------------------------------|---------------------------------------------------|------------------------------------------------|
| CUTS | `DeepDrftPublic.Client/Pages/CutDetail.razor` | `<WaveformVisualizer>` in scaffold's `Ambient` slot (mode B) | `ReleaseDetailScaffold` `TopRightAction` slot |
| SESSIONS| `DeepDrftPublic.Client/Pages/SessionDetail.razor` | `<WaveformVisualizer>` mounted directly (does **not** use scaffold) | inline in `.session-detail-top-row` |
| MIXES | `DeepDrftPublic.Client/Pages/MixDetail.razor` | `<WaveformVisualizer>` mounted directly (mode A, full-bleed) | `ReleaseDetailScaffold` `TopRightAction` slot |
**The asymmetry to respect:** Cut and Mix compose `ReleaseDetailScaffold`; **Session deliberately does
not** (it diverges for the hero-overlay layout — see `DeepDrftPublic.Client/CLAUDE.md`). So a
"hide-content" gate placed only in the scaffold would miss Session. The feature must be expressed in a
way that all three pages consume identically without forcing Session onto the scaffold. §6 resolves
this.
**Supporting components in play:**
- `DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor` — owns `TopRightAction`, the content
regions (`Header` / `MetaContent` / `BodyContent` / share-row), and the `ShowHeader` / `ShowMeta` /
`ShowShareRow` gates.
- `DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor` — the lava-lamp icon button
unit (`MudIconButton` wrapped in `.dd-accent-icon`). The new Theater button sits **to its left**.
- `DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs` — the scoped session-persistent
holder for visualizer subsystem state (`LavaEnabled`, `WaveformEnabled`, …) and its `Changed` event.
This is the model for where Theater-Mode state and "is anything visualizing?" live (§6).
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor` (+ `.razor.cs`) — the dock UI
that grows in Theater Mode.
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/TrackMetaLabel.razor` — the now-playing identity row;
the natural home for the enlarged "now showing" presentation (§7).
- `DeepDrftModels/DTOs/TrackDto.cs` + `ReleaseDto.cs` — **already carry everything the enlarged bar
needs**: `Track.Release.Title`, `Track.Release.ImagePath` (cover art), `Track.Release.EntryKey` +
`Track.Release.Medium` (release-mode share). **No new data plumbing.**
---
## 3. The toggle button — placement and behavior
1. A new right-side action **icon button**, positioned **immediately to the left of the lava-lamp
toggle** (the `WaveformVisualizerControlPopover` trigger), in the same top-right action cluster on
each of the three pages.
2. It is **visible only when the lava-lamp OR the waveform visualizer is active** — i.e. when
`WaveformVisualizerControlState.LavaEnabled || WaveformEnabled`. If the listener has switched both
subsystems off, there is nothing to go to theater *for*, so the button is absent. (This mirrors how
the visualizer's own controls self-gate on subsystem state.)
3. It is a **toggle** with an on/off visual state (active styling when Theater Mode is ON), exactly as
the lava-lamp popover icon shows an open/closed state today.
4. **Disabled until interactive** (`!RendererInfo.IsInteractive`) — same guard the lava-lamp button and
Play buttons already carry, so it does nothing during prerender.
**Iconography:** Material `Theaters` (a film-strip glyph). A bespoke `DDIcons` glyph in the
hand-rolled house style is the higher-craft option but is **not** required for v1 (this matches the
Phase 17 OQ7 precedent — Material icons now, bespoke later). **Resolved: Material `Theaters` for v1
(OQ1, Daniel 2026-06-20).**
---
## 4. Visibility behavior (Theater ON)
When Theater Mode is ON, the release-detail **content** is conditionally removed from the render (an
`@if` gate, not CSS `display:none` — Daniel's words, and it matches how the scaffold already gates
`Header`/`MetaContent`/`BodyContent` and how `WaveformVisualizerControls` gates its rows). What hides:
- The masthead / header region (title, artist, genre, year, Play/Share affordance row).
- The metadata block and the multi-track body (the Cut track-list; the `ReleaseDescription` blurb).
- The hero overlay (Session/Mix) — the big background-image hero with its overlaid title/play/share.
**What stays visible in Theater Mode:**
- The **visualizer** (the whole point — now unobstructed).
- The **top action row**: the back link, the lava-lamp popover (so the listener can still tune the
lamp), and the Theater toggle itself (so they can leave). These are the controls *over* the
experience, not content *of* the release — they stay.
- The **player bar**, now enlarged (§5/§7).
**Toggling OFF** restores the content exactly as it appears today — the `@if` re-includes it. Because
the gate is render-inclusion, not a layout fork, OFF is byte-for-byte the current page (the Liskov
discipline the scaffold already follows for its `Ambient` slot).
**Consistency across the three pages:** all three honor the same visibility rule and the same default
(Theater starts OFF on every page load). See §6 for *how* the single rule reaches all three without
forcing Session onto the scaffold.
---
## 5. Player-bar enlargement behavior (Theater ON)
When Theater Mode is ON, the player bar **grows** to surface — for the current track in the current
release — the release identity the hidden page no longer shows:
1. **Cover art**`Track.Release.ImagePath` rendered as a thumbnail (the `deepdrft-track-detail-cover-art`
background-image idiom already used on the detail pages; reuse it, do not invent a new image
treatment). Placeholder when null, matching the detail-page placeholder treatment.
2. **Release title**`Track.Release.Title`, linking to the release detail page via the existing
`ReleaseRoutes.DetailHref(Track.Release)` resolver (the same link `TrackMetaLabel` already builds for
the track title).
3. **Share** — a release-mode `SharePopover` bound to `Track.Release.EntryKey` +
`Track.Release.Medium` (the exact wiring the detail pages already use). This is the same share the
hidden page carried, relocated to the bar.
The bar **may grow taller/larger** to accommodate this "now showing" block. The growth is conditional on
Theater Mode being ON.
**Important seam:** the enlarged presentation lives in the player bar's **own** presentation layer
(`TrackMetaLabel` / a small new sub-component), keyed off the **current track's `Release`** — not off
the detail page. This matters because the player bar is mounted at layout level
(`AudioPlayerProvider``MainLayout`), one instance for the whole app. It already shows whatever track
is current regardless of route. So the enlarged "now showing" block is a property of *the bar reacting
to Theater state*, not something the detail page pushes into it. See §6 for how the bar observes Theater
state without the detail page reaching across to it.
**Edge — Theater ON but nothing playing:** the bar's enlargement keys off `CurrentTrack?.Release`. If no
track is playing (the listener opened the page and toggled Theater without pressing play), there is no
current release to surface in the bar. **Resolved (OQ2, Daniel 2026-06-20): playing-release only**
the bar stays a pure function of player state; no detail-page→bar data push. The listener who toggles
Theater is almost always already listening; the visualizer itself is blank until a track resolves, so a
blank-ish enlarged bar in that rare pre-play window is coherent.
---
## 6. Where the toggle state lives (SOLID boundary)
**Recommendation: a small new scoped state holder, observed by both the pages and the player bar — the
same decoupling pattern `WaveformVisualizerControlState` already establishes.**
The crux: three independent pages (two via scaffold, one not) AND the layout-level player bar all need
to read one boolean and react to its change. The clean seam is a shared scoped service with a `Changed`
event — not a cascading parameter from a page (the bar is not a descendant of the page), and not state
on the scaffold (Session does not use the scaffold).
Two viable homes for the boolean:
- **Option A (recommended): extend `WaveformVisualizerControlState`** with a `TheaterMode` bool +
`DefaultTheaterMode = false` + reuse its existing `Changed` event. Rationale: Theater Mode is
*conceptually part of the visualizer experience* — it is literally "show only the visualizer," it is
gated on the visualizer's own `LavaEnabled || WaveformEnabled`, and the state object is already scoped,
already session-persistent-within-a-session, already observed by the visualizer bridge, and explicitly
designed to **widen by adding a field + default without forcing any consumer constructor to change**
(its class comment says exactly this). The Theater toggle button mutates `TheaterMode` and calls
`NotifyChanged()`; the pages and the player bar subscribe to `Changed` and re-read. **Cost:** the state
object's name now slightly under-describes its contents (it holds a presentation-mode flag, not just
visualizer dials). Acceptable — the comment can note Theater Mode as a visualizer-experience flag.
- **Option B: a dedicated `TheaterModeState` scoped holder** (`bool IsOn`, `event Action? Changed`,
`Toggle()`). Rationale: single-responsibility purity — the visualizer-control state stays strictly
about visualizer dials. **Cost:** a second tiny observer-pattern holder that does the same shape of
thing as the one next to it; the gating still has to *read* `WaveformVisualizerControlState`
(`LavaEnabled || WaveformEnabled`) to know whether to show the button, so the two are coupled at the
read site anyway.
**Resolved (OQ3, Daniel 2026-06-20): Option A** — widen `WaveformVisualizerControlState` with a
`TheaterMode` flag. The visualizer-control state is already the "how the visualizer presents" object,
Theater Mode is a visualizer-presentation concern, and the object was explicitly designed to widen this
way. Final structural call is staff-engineer's at implementation (matching the standing convention on
`IQueueService`-shape decisions).
**Why this satisfies SOLID / the "cleanly separated concerns" constraint:**
- **Single source of truth, multiple observers.** One boolean; the three pages observe it for the
content `@if`; the player bar observes it for the enlargement. No page reaches into the bar; the bar
does not reach into a page. (Memory: *one source, multiple views* — divergence lives only in
rendering.)
- **The detail pages own only the visibility `@if`.** Each page wraps its content region(s) in
`@if (!state.TheaterMode) { … }`. Cut/Mix can do this around the scaffold's slot content; Session does
it around its own content. The scaffold itself needs **no Theater knowledge** if each page gates the
fragments it passes in — keeping the scaffold's existing `ShowHeader`/`ShowMeta` gates uncomplicated.
(Alternative: give the scaffold a `ShowContent`/`Theater` gate too; only helps the two scaffold pages,
not Session — so gating at the page level is the consistent choice.)
- **The player bar owns only the enlargement.** It reads `state.TheaterMode` + `CurrentTrack.Release`
and renders the "now showing" block. No new parameter threads down from a page.
- **The toggle button owns only the mutation.** Tap → flip `TheaterMode``NotifyChanged()`.
---
## 7. Player-bar enlargement — component shape
Keep the bar from bloating. Two clean options:
- **Recommended: a new presentational sub-component** `NowShowingPanel.razor` (or fold into a new branch
of `TrackMetaLabel`) under `Controls/AudioPlayerBar/`, rendered by `AudioPlayerBar.razor` **only when**
`state.TheaterMode && CurrentTrack?.Release is not null`. It takes the current `TrackDto` (or just its
`Release`) and renders cover + title-link + release-`SharePopover`. Purely presentational; owns no
player logic and no Theater state (it is shown/hidden by the bar). This mirrors how `QueueList`,
`ReleaseHeroOverlay`, and `ReleaseDescription` are split out as presentational shells.
- Alternative: branch inside `TrackMetaLabel` on a new `Theater` bool parameter. Lighter file count, but
pushes a layout-mode branch into the always-on label component — less clean. Prefer the sub-component.
`AudioPlayerBar` subscribes to `state.Changed` (it already subscribes to `IPlayerService.StateChanged`
in `OnParametersSet` and disposes — add the visualizer-control-state subscription the same way) so the
bar re-renders when Theater flips, and `StateHasChanged` already fires on track change so the enlarged
block follows the playing release for free.
---
## 8. Theming reuse (DRY — hard requirement)
Everything binds the **existing** theme-aware token layer and the established interactive-accent icon
convention. **No new per-component dark overrides.** Concretely:
- **The Theater toggle button** is a `MudIconButton` (`Color.Secondary`) wrapped in a
**`.dd-accent-icon`** container — the exact pattern `WaveformVisualizerControlPopover` uses for the
lava-lamp trigger. This gives it the green-accent glyph (`--deepdrft-green-accent`) in **both** themes
with zero new CSS. Do **not** spawn a new dark override (root `CLAUDE.md`: "Add new green-accent icon
affordances by applying this class, not by spawning a new dark override.").
- **The enlarged player-bar "now showing" block:**
- **Surface/background, text, borders** bind the player bar's existing surface treatment
(`.player-surface` / the bar's own classes) and the theme-aware aliases —
`--deepdrft-page-surface` / `--deepdrft-page-text` / `--deepdrft-page-text-muted` for neutral
text/background, never raw source tokens. (The bar already lives inside the themed wrapper, so it is
not a portaled-popover case — no `body.deepdrft-theme-dark` re-declaration needed.)
- **The release title link** uses the bar's existing title-link treatment (`TrackMetaLabel`'s
`.track-meta-title`) — reuse it, do not restyle.
- **The Share affordance** is a `SharePopover` wrapped in **`.dd-accent-icon`** (its glyph goes
green-accent in both themes — the same treatment the detail-page hero share already uses).
- **The cover-art thumbnail** reuses the `deepdrft-track-detail-cover-art` background-image class (and
the `deepdrft-gradient-soft-secondary` placeholder for null images) — the detail pages' existing
cover idiom, theme-aware already.
- **No new palette `Color` enum value**, no new token family unless a genuinely new surface appears
(it should not — the bar surface and detail-cover idioms already exist). If the enlarged bar needs a
divider or a subtle panel inset, bind an **existing** alias; flag to Daniel if a new alias seems
unavoidable rather than inventing one silently.
---
## 9. Open questions — all resolved (Daniel, 2026-06-20)
All six open questions are resolved. Every resolution matches the spec's recommendation.
- **OQ1 — Theater toggle icon. RESOLVED: Material `Theaters` (film-strip) for v1.** Bespoke `DDIcons`
glyph deferred (Phase 17 OQ7 precedent).
- **OQ2 — bar enlargement when nothing is playing. RESOLVED: playing-release only.** The bar stays a
pure function of player state; no page→bar data path. The listener who opens Theater without pressing
play sees a blank-ish enlarged bar — coherent, because the visualizer itself is also blank.
- **OQ3 — state home. RESOLVED: Option A — widen `WaveformVisualizerControlState` with a `TheaterMode`
flag.** Theater Mode is a visualizer-presentation concern; the object was explicitly designed to widen
this way. Staff-engineer makes the final structural call at implementation.
- **OQ4 — back link in Theater Mode. RESOLVED: stays visible.** The top action row (back, lava-lamp,
theater) is controls, not release content — it remains in Theater Mode.
- **OQ5 — persistence scope. RESOLVED: session-scoped, resets to OFF on fresh page load.** Persists
across SPA navigation within a session; a full reload (F5) resets it to OFF. Matches the
visualizer-control-state precedent; no cookie round-trip.
- **OQ6 — Theater on the home hero / NowPlaying panel. RESOLVED: detail-pages-only for v1.** The three
Release Detail views are the scope; the home hero's `WaveformVisualizerControlPopover` does not get a
Theater affordance in this phase.
---
## 10. Acceptance criteria
1. On each of `/cuts/{key}`, `/sessions/{key}`, `/mixes/{key}`, a Theater toggle icon button renders
immediately to the left of the lava-lamp popover icon in the top action row.
2. The Theater button is **absent** when both `LavaEnabled` and `WaveformEnabled` are false; it **appears**
when either is true. It is disabled (inert) during prerender / before interactive.
3. Toggling Theater **ON** removes the release content from the render (header/meta/track-list/blurb,
and the hero overlay on Session/Mix) via `@if`, leaving the visualizer unobstructed plus the top
action row (back, lava, theater) and the player bar.
4. In Theater Mode the player bar **grows** and surfaces, for the current playing track's release:
cover art, release title (linked to the release detail page), and a release-mode share affordance.
5. Toggling Theater **OFF** restores the page byte-for-byte to its non-Theater appearance.
6. Behavior is **identical across all three mediums** — same button, same placement, same visibility
rule, same bar enlargement, same default (OFF on load).
7. **Light and dark both correct with zero new dark overrides:** the toggle glyph and the bar's share
glyph are green-accent in both themes via `.dd-accent-icon`; the enlarged bar's text/surface bind
existing theme-aware aliases; the cover thumbnail uses the existing detail-cover class.
8. No API / data / schema change. No CMS change. The enlarged bar reads only `CurrentTrack.Release`
fields the DTO already carries.
9. Theater Mode persists across SPA navigation within a session and resets to OFF on a fresh page load
(OQ5, confirmed).
---
## 11. What this is NOT (scope guards)
- **Not** a fullscreen API call. Theater Mode hides page content; it does not request browser
fullscreen. (A future enhancement could pair it with the Fullscreen API — note, don't build.)
- **Not** a visualizer behavior change. The renderer, the bridge, the control dials, and the read-only
contract are all untouched. Theater Mode only changes *what page chrome is shown around* the
visualizer.
- **Not** a player/queue change. The streaming seam, the queue engine, and the bar's transport controls
are untouched; only the bar's *identity presentation* grows.
- **Not** a CMS or embed-player feature. The embed (`FramePlayer` / Fixed bar mode) is out of scope —
Theater Mode is for the docked detail-page experience.
---
## 12. Borrowed precedent
- **Media-player "theater mode" / "cinema mode"** (YouTube's theater toggle, Twitch's theater mode) —
the direct namesake: collapse the surrounding page chrome to let the media fill more space, one toggle,
reversible. The transplant here is that the "media" is the visualizer and the "chrome" is the release
page.
- **The visualizer-control popover idiom** (`WaveformVisualizerControlPopover`) — the toggle button's
placement, `.dd-accent-icon` treatment, `IsInteractive` gating, and on/off visual state are lifted
directly from the lava-lamp button it sits beside.
- **`WaveformVisualizerControlState`'s observer seam** — the state-holder + `Changed`-event decoupling
is the established pattern for "one piece of state, several components react"; Theater Mode reuses its
exact shape (and, per Option A, possibly its exact object).