324 lines
23 KiB
Markdown
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).
|