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

23 KiB

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.csalready 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 artTrack.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 titleTrack.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 (AudioPlayerProviderMainLayout), 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 TheaterModeNotifyChanged().

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