diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor
index d533f62..bdd7816 100644
--- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor
+++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor
@@ -10,6 +10,15 @@ else
+ @* Theater Mode "now showing" band (Phase 20 §5/§7). Shown only when Theater is ON and a
+ release is playing — keyed off the playing track's Release, not off any detail page
+ (the bar reaches into no page; §6). The release page is hidden in Theater Mode, so the
+ bar carries its identity: cover, linked title, release share. *@
+ @if (VisualizerControlState.TheaterMode && CurrentTrack?.Release is not null)
+ {
+
+ }
+
InvokeAsync(StateHasChanged);
+
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private void OnQueueChanged()
@@ -402,6 +419,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
_subscribedQueue = null;
}
+ if (_subscribedToVisualizerState)
+ {
+ VisualizerControlState.Changed -= OnVisualizerStateChanged;
+ _subscribedToVisualizerState = false;
+ }
+
if (_spacerModule is not null)
{
try
diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.css b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.css
index 6012175..c859a60 100644
--- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.css
+++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.css
@@ -56,6 +56,54 @@
color: var(--mud-palette-primary);
}
+/* Theater Mode "now showing" band (Phase 20 §5/§7). Sits above the transport layout inside the
+ player surface and lets the bar grow taller to carry the hidden release's identity. The band only
+ renders when Theater is ON, so this geometry is gated by render-inclusion, not a CSS flag — when
+ Theater is OFF the player bar is byte-for-byte its non-Theater self.
+ Colour/surface come from the bar's themed --deepdrft-page-* aliases; no new token, no dark override. */
+::deep .now-showing {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding-bottom: 0.5rem;
+ margin-bottom: 0.5rem;
+ border-bottom: 1px solid var(--deepdrft-page-text-muted);
+ min-width: 0;
+}
+
+/* Fixed cover box — the reused .deepdrft-track-detail-cover-art / -placeholder idioms are height:100%,
+ so the band supplies the square frame they fill. */
+::deep .now-showing-cover {
+ flex: 0 0 auto;
+ width: 44px;
+ height: 44px;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+::deep .now-showing-cover-art,
+::deep .now-showing-cover-placeholder {
+ width: 100%;
+}
+
+::deep .now-showing-cover-placeholder .mud-icon-root {
+ font-size: 24px;
+}
+
+::deep .now-showing-title-link {
+ flex: 1 1 auto;
+ min-width: 0;
+ text-decoration: none;
+}
+
+::deep .now-showing-title {
+ color: var(--deepdrft-page-text);
+}
+
+::deep .now-showing-share {
+ flex: 0 0 auto;
+}
+
/* Minimized floating dock — positioning + hover only; colour from MudFab */
.minimized-dock {
position: fixed;
diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/NowShowingPanel.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/NowShowingPanel.razor
new file mode 100644
index 0000000..6f553ec
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/NowShowingPanel.razor
@@ -0,0 +1,46 @@
+@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
+@using DeepDrftModels.DTOs
+@using DeepDrftPublic.Client.Common
+@using DeepDrftPublic.Client.Controls
+
+@* "Now showing" block surfaced in the player bar when Theater Mode is ON (Phase 20 §5/§7). Theater
+ hides the release page, so the bar carries the release identity the page would have shown: cover art,
+ the release title linked to its detail page, and a release-mode share. Purely presentational — it owns
+ no player logic and no Theater state; AudioPlayerBar mounts it only when state.TheaterMode &&
+ CurrentTrack?.Release is not null, so Release is non-null here.
+
+ Theming is all reuse (§8, zero new CSS): the cover reuses the deepdrft-track-detail-cover-art /
+ -placeholder idiom; the share glyph goes green-accent in both themes via .dd-accent-icon; surface and
+ text come from the bar's own .player-surface and the .now-showing-* classes in the global sheet, which
+ bind the theme-aware --deepdrft-page-* aliases. *@
+
+
+
+ @if (!string.IsNullOrEmpty(Release.ImagePath))
+ {
+
+ }
+ else
+ {
+
+
+
+ }
+
+
+
+
+ @Release.Title
+
+
+
+
+
+
+
+
+@code {
+ /// The current playing track's release. Non-null by the bar's mount gate.
+ [Parameter, EditorRequired] public ReleaseDto Release { get; set; } = default!;
+}
diff --git a/DeepDrftPublic.Client/Controls/TheaterModeToggle.razor b/DeepDrftPublic.Client/Controls/TheaterModeToggle.razor
new file mode 100644
index 0000000..c5b6b6b
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/TheaterModeToggle.razor
@@ -0,0 +1,49 @@
+@namespace DeepDrftPublic.Client.Controls
+@using DeepDrftPublic.Client.Services
+@implements IDisposable
+@inject WaveformVisualizerControlState State
+
+@* Theater-Mode toggle (Phase 20 §3). The single affordance placed identically on all three release
+ detail pages — immediately to the LEFT of the lava-lamp WaveformVisualizerControlPopover trigger.
+ It is purely a mutation surface: tapping it flips State.TheaterMode and raises Changed; the detail
+ pages observe that to gate their content @if, and the player bar observes it to grow. This component
+ reaches into no page and no bar — single source, multiple observers (§6).
+
+ Visible only when the lava OR waveform subsystem is on — there is nothing to go to theater FOR if both
+ are off (§3.2). Disabled until interactive (§3.4), the same prerender guard the lava/Play buttons use.
+ Active visual state when Theater is ON. .dd-accent-icon gives the green-accent glyph in both themes
+ with zero new CSS (§8) — same treatment as the lava-lamp trigger it sits beside. *@
+
+@if (State.LavaEnabled || State.WaveformEnabled)
+{
+
+
+
+
+
+}
+
+@code {
+ /// Trigger-icon size. Defaults Large to match the lava-lamp popover trigger it sits beside.
+ [Parameter] public Size IconSize { get; set; } = Size.Large;
+
+ protected override void OnInitialized() => State.Changed += OnStateChanged;
+
+ // The toggle's own visibility and active state both key off State, which another observer (or this
+ // button) may mutate, so re-render on every Changed — same idempotent posture the visualizer bridge uses.
+ private void OnStateChanged() => InvokeAsync(StateHasChanged);
+
+ private void Toggle()
+ {
+ State.TheaterMode = !State.TheaterMode;
+ State.NotifyChanged();
+ }
+
+ public void Dispose() => State.Changed -= OnStateChanged;
+}
diff --git a/DeepDrftPublic.Client/Pages/CutDetail.razor b/DeepDrftPublic.Client/Pages/CutDetail.razor
index 5a5e014..f27a0ae 100644
--- a/DeepDrftPublic.Client/Pages/CutDetail.razor
+++ b/DeepDrftPublic.Client/Pages/CutDetail.razor
@@ -54,11 +54,21 @@ else
TrackEntryKey="@firstTrack?.EntryKey" />
- @* Lava-lamp icon → popover panel (full parity, §3d-revised). Sits top-right across from the
- back link, clear of the header's own Play/Share affordances below. *@
-
+ @* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both are
+ controls over the experience, not release content, so both stay in Theater Mode (§4/OQ4).
+ Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
+
+
+ @* Lava-lamp icon → popover panel (full parity, §3d-revised). Sits top-right across from the
+ back link, clear of the header's own Play/Share affordances below. *@
+
+
+ @* Theater Mode (Phase 20 §4): the release content is removed from the render — not
+ CSS-hidden — so the visualizer fills the surface. OFF restores it byte-for-byte. *@
+ @if (!VisualizerControlState.TheaterMode)
+ {
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
+ }
+ @if (!VisualizerControlState.TheaterMode)
+ {
@* Blurb sits between the header and the track-list divider. *@
@@ -149,6 +162,7 @@ else
}
}
+ }
}
diff --git a/DeepDrftPublic.Client/Pages/CutDetailBase.cs b/DeepDrftPublic.Client/Pages/CutDetailBase.cs
index 13020fb..60f6143 100644
--- a/DeepDrftPublic.Client/Pages/CutDetailBase.cs
+++ b/DeepDrftPublic.Client/Pages/CutDetailBase.cs
@@ -1,4 +1,5 @@
using DeepDrftModels.DTOs;
+using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
@@ -19,6 +20,10 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
[Inject] public required CutDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
+ // Theater Mode (Phase 20). The page owns the @if (!VisualizerControlState.TheaterMode) content gate,
+ // so it must re-render when the flag flips on the toggle. Property-injected; no constructor growth.
+ [Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; }
+
private PersistingComponentStateSubscription _persistingSubscription;
// The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g.
@@ -28,7 +33,12 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
private bool _loaded;
protected override void OnInitialized()
- => _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
+ {
+ _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
+ VisualizerControlState.Changed += OnVisualizerStateChanged;
+ }
+
+ private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
protected override async Task OnParametersSetAsync()
{
@@ -61,7 +71,11 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
return Task.CompletedTask;
}
- public void Dispose() => _persistingSubscription.Dispose();
+ public void Dispose()
+ {
+ _persistingSubscription.Dispose();
+ VisualizerControlState.Changed -= OnVisualizerStateChanged;
+ }
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList Tracks);
diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor
index 4a7b45f..7a13b6e 100644
--- a/DeepDrftPublic.Client/Pages/MixDetail.razor
+++ b/DeepDrftPublic.Client/Pages/MixDetail.razor
@@ -56,13 +56,23 @@ else
ShowMeta="false"
ShowShareRow="false">
- @* Lava-lamp icon → popover panel, top-right across from the back link (Phase 12
- §3d-revised). Replaces the former inline TopContent knob-bar: the icon IS the toggle
- and the popover IS the panel. Mix takes the cleanest anchor case (§8e) — the popover's
- default bottom-right anchor opens down over the full-bleed field. *@
-
+ @* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both stay
+ visible in Theater Mode — controls over the experience, not release content (§4/OQ4).
+ Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
+
+
+ @* Lava-lamp icon → popover panel, top-right across from the back link (Phase 12
+ §3d-revised). Replaces the former inline TopContent knob-bar: the icon IS the toggle
+ and the popover IS the panel. Mix takes the cleanest anchor case (§8e) — the popover's
+ default bottom-right anchor opens down over the full-bleed field. *@
+
+
+ @* Theater Mode (Phase 20 §4): the hero overlay (Session/Mix release content) is removed
+ from the render so the full-bleed visualizer fills the surface. OFF restores it. *@
+ @if (!VisualizerControlState.TheaterMode)
+ {
@* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The
cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
to null). Share and play ride in as slots, matching Sessions. *@
@@ -86,10 +96,14 @@ else
}
+ }
- @* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
-
+ @if (!VisualizerControlState.TheaterMode)
+ {
+ @* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
+
+ }
diff --git a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs
index 2dd9077..47e5e55 100644
--- a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs
+++ b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs
@@ -1,4 +1,5 @@
using DeepDrftModels.DTOs;
+using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
@@ -17,6 +18,10 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
+ // Theater Mode (Phase 20). The page owns the @if (!VisualizerControlState.TheaterMode) content gate,
+ // so it must re-render when the flag flips on the toggle. Property-injected; no constructor growth.
+ [Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; }
+
private PersistingComponentStateSubscription _persistingSubscription;
// The release EntryKey the ViewModel currently holds. Tracks param-only navigations (e.g.
@@ -30,7 +35,12 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
protected abstract string PersistKey { get; }
protected override void OnInitialized()
- => _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
+ {
+ _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
+ VisualizerControlState.Changed += OnVisualizerStateChanged;
+ }
+
+ private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
protected override async Task OnParametersSetAsync()
{
@@ -69,7 +79,11 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
return Task.CompletedTask;
}
- public void Dispose() => _persistingSubscription.Dispose();
+ public void Dispose()
+ {
+ _persistingSubscription.Dispose();
+ VisualizerControlState.Changed -= OnVisualizerStateChanged;
+ }
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
protected sealed record BridgedDetail(ReleaseDto Release, TrackDto? Track);
diff --git a/DeepDrftPublic.Client/Pages/SessionDetail.razor b/DeepDrftPublic.Client/Pages/SessionDetail.razor
index 79419f5..e2b8c04 100644
--- a/DeepDrftPublic.Client/Pages/SessionDetail.razor
+++ b/DeepDrftPublic.Client/Pages/SessionDetail.razor
@@ -56,11 +56,21 @@ else
← All sessions
- @* Lava-lamp icon → popover panel (full parity, §3e/§3d-revised). Anchored top-right, clear of
- the hero overlay and the share/play affordances overlaid on the hero below. *@
-
+ @* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). The whole top
+ row (back + theater + lava) stays in Theater Mode — controls, not release content (§4/OQ4). *@
+
+
+ @* Lava-lamp icon → popover panel (full parity, §3e/§3d-revised). Anchored top-right, clear of
+ the hero overlay and the share/play affordances overlaid on the hero below. *@
+
+
+ @* Theater Mode (Phase 20 §4): the hero overlay + blurb (the session's release content) are removed
+ from the render so the ambient visualizer fills the surface. The top row above stays. OFF
+ restores this region byte-for-byte. *@
+ @if (!VisualizerControlState.TheaterMode)
+ {
@* The overlay shows the cover thumbnail only when it differs from the resolved hero image —
when there is no dedicated hero, heroImage already falls back to release.ImagePath, so the
thumb would duplicate the background. That logic lives in ReleaseHeroOverlay. *@
@@ -86,6 +96,7 @@ else
+ }
}
diff --git a/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs b/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs
index 36659b9..556bf98 100644
--- a/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs
+++ b/DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs
@@ -97,6 +97,13 @@ public sealed class WaveformVisualizerControlState
///
public const bool DefaultWaveformEnabled = true;
+ ///
+ /// Default Theater-mode state. false so a fresh page load opens with the full release page,
+ /// not the bare visualizer (Phase 20 §4/OQ5). Has no TS-side anchor: Theater Mode is a page-chrome
+ /// presentation flag, not a visualizer dial — the bridge never reads it.
+ ///
+ public const bool DefaultTheaterMode = false;
+
/// Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
/// time-span via ; the standalone resolution/zoom control is gone.
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
@@ -137,9 +144,19 @@ public sealed class WaveformVisualizerControlState
///
public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled;
+ ///
+ /// Whether Theater Mode is on (Phase 20). When true the three release-detail pages remove
+ /// their release content via @if so the visualizer fills the surface, and the player bar
+ /// grows to carry the playing release's identity. Distinct from the visualizer dials: the bridge
+ /// ignores it — the pages and the player bar observe it through the same seam.
+ /// Gated for visibility on || at the toggle.
+ ///
+ public bool TheaterMode { get; set; } = DefaultTheaterMode;
+
///
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
- /// affected dial(s). Mutators set the property then raise this; subscribers re-read the values.
+ /// affected dial(s); the Theater-Mode observers (detail pages, player bar) subscribe to react to
+ /// . Mutators set the property then raise this; subscribers re-read the values.
///
public event Action? Changed;
diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
index 94c42de..e4befc1 100644
--- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
+++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css
@@ -351,6 +351,15 @@ h2, h3, h4, h5, h6,
gap: 0.5rem;
}
+/* Theater toggle + lava-lamp popover cluster on the detail-page top action row (Phase 20 §3). Keeps
+ the two icon affordances adjacent on the right edge rather than letting the SpaceBetween row spread
+ them apart. Shared by Cut/Mix (scaffold TopRightAction) and Session (its own top row). */
+.dd-detail-top-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
.deepdrft-track-detail-meta {
display: flex;
flex-direction: row;