Files
deepdrft/product-notes/phase-20-theater-mode.md
T

324 lines
23 KiB
Markdown

# 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).