diff --git a/PLAN.md b/PLAN.md index 38d9817..ec206e8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -443,6 +443,59 @@ not the same work; this phase does not satisfy or depend on that one. --- +## Phase 20 — Theater Mode (public Release Detail views) + +A presentation-only feature on the **public listener site** (`DeepDrftPublic` / `DeepDrftPublic.Client`; +**no CMS, no API, no data, no schema change**). On a Release Detail view, a new toggle clears the page +chrome away from the visualizer: hide the release content (`@if`-gated header/meta/track-list/blurb and +the Session/Mix hero overlay) so the lava-lamp + waveform field fills the surface unobstructed, while +the **player bar grows** to carry the release identity the hidden page would otherwise show — cover art, +release title, and a release-mode share. Borrowed namesake: YouTube/Twitch "theater mode" — collapse the +surrounding chrome to let the media (here, the visualizer) take over, one reversible toggle. Full design, +component-by-component placement, the SOLID state seam, theming reuse, acceptance criteria, and open +questions: `product-notes/phase-20-theater-mode.md`. + +**Scope — the three detail views (verified):** `Pages/CutDetail.razor` (scaffold `Ambient` visualizer), +`Pages/SessionDetail.razor` (mounts the visualizer directly — **does not** use `ReleaseDetailScaffold`), +`Pages/MixDetail.razor` (scaffold, full-bleed mode-A visualizer). The feature must behave **identically** +across all three; the Session-doesn't-use-the-scaffold asymmetry is the key constraint the state design +works around. + +**Architectural spine.** **One boolean, multiple observers** (memory: *one source, multiple views*). +Recommended home: **widen `WaveformVisualizerControlState` with a `TheaterMode` flag** (Option A — the +object is already scoped, session-persistent, observed via its `Changed` event, gated on the same +`LavaEnabled || WaveformEnabled` the Theater button reads, and *explicitly designed to widen by adding a +field + default*); the SRP-purist alternative is a dedicated `TheaterModeState` holder (Option B). The +**detail pages own only the content `@if`** (each page gates the fragments it renders, so the scaffold +stays Theater-unaware and Session is covered the same way); the **player bar owns only the enlargement** +(reads `CurrentTrack.Release` — `Title`/`ImagePath`/`EntryKey`/`Medium`, all already on the DTO — and +renders a small `NowShowingPanel` presentational sub-component); the **toggle button owns only the +mutation**. No page reaches into the bar; the bar reaches into no page. + +**Theming (DRY — hard requirement).** The toggle is a `MudIconButton` in `.dd-accent-icon` (green-accent +glyph both themes, zero new CSS — same as the lava-lamp trigger it sits beside); the enlarged bar binds +existing theme-aware aliases (`--deepdrft-page-surface`/`-text`/`-text-muted`), reuses the +`deepdrft-track-detail-cover-art` cover idiom, and wraps the release `SharePopover` in `.dd-accent-icon`. +**No new dark overrides, no new palette `Color`, no new token family.** + +**Button placement + gating.** A new right-side icon button immediately **left of the lava-lamp toggle**, +visible only when `LavaEnabled || WaveformEnabled`, disabled until interactive, with an on/off active +state. Material `Theaters` glyph for v1 (bespoke `DDIcons` deferred — Phase 17 OQ7 precedent). + +**Open questions for Daniel (spec §9) — none block a first cut, but several are genuine product calls:** +(OQ1) Theater icon — Material `Theaters` vs. bespoke (recommend Material now); (OQ2) bar enlargement when +nothing is playing — page's release vs. playing-release-only (recommend playing-only, keeps the seam +clean); (OQ3) state home — Option A widen vs. Option B dedicated holder; (OQ4) back link stays in Theater +(recommend keep — it's navigation chrome); (OQ5) persistence — session-scoped/reset-on-reload vs. cookie +(recommend session-scoped, matches visualizer-state precedent); (OQ6) Theater on the home hero/NowPlaying +panel too, or detail-pages-only (recommend detail-pages-only for v1, as scoped). + +**Status: proposed — awaiting Daniel sign-off on §9 before scoping waves.** Cold-start once signed off; +no dependency on any in-flight phase (Phases 11/16/17 — the player bar, queue, and visualizer it builds +on — are all complete). + +--- + ## Working with this file - **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing. diff --git a/product-notes/phase-20-theater-mode.md b/product-notes/phase-20-theater-mode.md new file mode 100644 index 0000000..3b71417 --- /dev/null +++ b/product-notes/phase-20-theater-mode.md @@ -0,0 +1,327 @@ +# Phase 20 — Theater Mode (public Release Detail views) + +Product spec. Status: **proposed, awaiting Daniel sign-off on the open questions in §9.** +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. + +--- + +## 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` | `` in scaffold's `Ambient` slot (mode B) | `ReleaseDetailScaffold` `TopRightAction` slot | +| SESSIONS| `DeepDrftPublic.Client/Pages/SessionDetail.razor` | `` mounted directly (does **not** use scaffold) | inline in `.session-detail-top-row` | +| MIXES | `DeepDrftPublic.Client/Pages/MixDetail.razor` | `` 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) is the obvious stock choice and reads as +"theater" immediately. 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). **Open question OQ1 (§9).** + +--- + +## 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. **Open question OQ2 (§9):** does Theater Mode surface *the page's* +release in the bar even when not playing (the page knows its release), or only the *playing* release +(simpler, but the bar shows nothing extra until play starts)? Recommendation: **playing-release only** — +keeps the bar a pure function of player state and avoids a detail-page→bar data push that would +re-entangle the two. 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. + +**Steer: Option A.** 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. Option B is the defensible SRP-purist alternative if Daniel wants the visualizer dials kept +pristine. **Open question OQ3 (§9).** 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 (Daniel decisions, not implementation calls) + +- **OQ1 — Theater toggle icon.** Material `Theaters` (film-strip) for v1, or commission a bespoke + `DDIcons` glyph in the hand-rolled house style? *Recommend: Material `Theaters` now, bespoke deferred + (Phase 17 OQ7 precedent).* — **product call.** +- **OQ2 — bar enlargement when nothing is playing.** Surface the *page's* release in the bar even with no + current track (needs a page→bar data path), or only the *playing* release (bar stays a pure function of + player state)? *Recommend: playing-release only.* — **product + architecture call; the recommendation + keeps the SOLID seam clean, so it leans toward an implementation default unless Daniel wants the + page's release shown pre-play.** +- **OQ3 — state home.** Extend `WaveformVisualizerControlState` with `TheaterMode` (Option A, + recommended) or a dedicated `TheaterModeState` holder (Option B, SRP-purist)? — *Structural; + staff-engineer's final call, but Daniel may have a taste here.* +- **OQ4 — does the back link stay in Theater Mode?** Recommendation keeps it (it is navigation chrome, + not release content). Confirm Daniel agrees the top action row (back + lava + theater) is "controls," + not "content." *Recommend: keep.* — **product call, low-stakes.** +- **OQ5 — persistence scope.** Theater Mode follows the visualizer-state convention: persists across SPA + navigation within a session, resets to OFF on a fresh page load (F5). Confirm that is the wanted + behavior (vs. a cookie that remembers Theater across reloads). *Recommend: session-scoped, reset on + reload — matches the visualizer-control-state precedent; no cookie round-trip.* — **product call.** +- **OQ6 — Theater on the home hero / NowPlaying panel?** The `WaveformVisualizerControlPopover` also + appears on the home hero's NowPlaying panel (mode C). This spec scopes Theater Mode to the **three + Release Detail views only** (Daniel's framing). Flag: should the home hero get a Theater affordance + too, or is it deliberately detail-pages-only? *Recommend: detail-pages-only for v1, as scoped.* — + **product call, adjacent.** + +--- + +## 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 + (per OQ5, if 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).