21 KiB
Phase 20 — Theater Mode (public Release Detail views)
Product spec. Status: landed and merged to dev — 2026-06-20 (pending final manual browser/GPU smoke-test on dev). 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.
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— ownsTopRightAction, the content regions (Header/MetaContent/BodyContent/ share-row), and theShowHeader/ShowMeta/ShowShareRowgates.DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor— the lava-lamp icon button unit (MudIconButtonwrapped 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 itsChangedevent. 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
- A new right-side action icon button, positioned immediately to the left of the lava-lamp
toggle (the
WaveformVisualizerControlPopovertrigger), in the same top-right action cluster on each of the three pages. - 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.) - 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.
- 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
ReleaseDescriptionblurb). - 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:
- Cover art —
Track.Release.ImagePathrendered as a thumbnail (thedeepdrft-track-detail-cover-artbackground-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. - Release title —
Track.Release.Title, linking to the release detail page via the existingReleaseRoutes.DetailHref(Track.Release)resolver (the same linkTrackMetaLabelalready builds for the track title). - Share — a release-mode
SharePopoverbound toTrack.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
WaveformVisualizerControlStatewith aTheaterModebool +DefaultTheaterMode = false+ reuse its existingChangedevent. Rationale: Theater Mode is conceptually part of the visualizer experience — it is literally "show only the visualizer," it is gated on the visualizer's ownLavaEnabled || 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 mutatesTheaterModeand callsNotifyChanged(); the pages and the player bar subscribe toChangedand 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
TheaterModeStatescoped 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 readWaveformVisualizerControlState(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 existingShowHeader/ShowMetagates uncomplicated. (Alternative: give the scaffold aShowContent/Theatergate 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.Releaseand 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 ofTrackMetaLabel) underControls/AudioPlayerBar/, rendered byAudioPlayerBar.razoronly whenstate.TheaterMode && CurrentTrack?.Release is not null. It takes the currentTrackDto(or just itsRelease) 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 howQueueList,ReleaseHeroOverlay, andReleaseDescriptionare split out as presentational shells. - Alternative: branch inside
TrackMetaLabelon a newTheaterbool 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-iconcontainer — the exact patternWaveformVisualizerControlPopoveruses 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 (rootCLAUDE.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-mutedfor neutral text/background, never raw source tokens. (The bar already lives inside the themed wrapper, so it is not a portaled-popover case — nobody.deepdrft-theme-darkre-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
SharePopoverwrapped 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-artbackground-image class (and thedeepdrft-gradient-soft-secondaryplaceholder for null images) — the detail pages' existing cover idiom, theme-aware already.
- Surface/background, text, borders bind the player bar's existing surface treatment
(
- No new palette
Colorenum 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. BespokeDDIconsglyph 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
WaveformVisualizerControlStatewith aTheaterModeflag. 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
WaveformVisualizerControlPopoverdoes not get a Theater affordance in this phase.
10. Acceptance criteria
- 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. - The Theater button is absent when both
LavaEnabledandWaveformEnabledare false; it appears when either is true. It is disabled (inert) during prerender / before interactive. - 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. - 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.
- Toggling Theater OFF restores the page byte-for-byte to its non-Theater appearance.
- Behavior is identical across all three mediums — same button, same placement, same visibility rule, same bar enlargement, same default (OFF on load).
- 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. - No API / data / schema change. No CMS change. The enlarged bar reads only
CurrentTrack.Releasefields the DTO already carries. - 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-icontreatment,IsInteractivegating, 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).