Merge Theater Mode (Phase 20) into dev

This commit is contained in:
daniel-c-harvey
2026-06-20 22:12:23 -04:00
14 changed files with 392 additions and 18 deletions
@@ -10,6 +10,15 @@ else
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
<MudPaper Elevation="8" Class="player-surface pa-3">
@* 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)
{
<NowShowingPanel Release="CurrentTrack.Release" />
}
<div class="player-layout">
<PlayerTransportZone IsLoaded="IsLoaded"
CanPlay="CanPlay"
@@ -16,12 +16,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
// Theater Mode (Phase 20). Property-injected (no constructor growth) so the bar can read
// TheaterMode to mount the "now showing" band and re-render when the flag flips. The toggle lives on
// the detail pages; the bar only observes — single source, multiple observers (§6).
[Inject] private WaveformVisualizerControlState VisualizerControlState { get; set; } = default!;
private bool _isMinimized = true;
private bool _isSeeking = false;
private double _seekPosition = 0;
private bool _queueOpen = false;
private IStreamingPlayerService? _subscribedService;
private IQueueService? _subscribedQueue;
private bool _subscribedToVisualizerState;
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
// spacer reserves its space. We mirror this element's live height into a CSS
@@ -143,8 +149,19 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
QueueService.QueueChanged += OnQueueChanged;
_subscribedQueue = QueueService;
}
// Theater Mode (Phase 20 §7): re-render the bar when TheaterMode flips so the "now showing" band
// appears/disappears. VisualizerControlState is injected (one stable scoped instance per session),
// so the subscribe is once-only — same idempotent subscribe-here / unsubscribe-on-dispose shape.
if (!_subscribedToVisualizerState)
{
VisualizerControlState.Changed += OnVisualizerStateChanged;
_subscribedToVisualizerState = true;
}
}
private void OnVisualizerStateChanged() => 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
@@ -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;
@@ -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. *@
<div class="now-showing">
<div class="now-showing-cover">
@if (!string.IsNullOrEmpty(Release.ImagePath))
{
<div class="deepdrft-track-detail-cover-art now-showing-cover-art"
style="@($"background-image: url('api/image/{Uri.EscapeDataString(Release.ImagePath)}');")"></div>
}
else
{
<div class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary now-showing-cover-placeholder">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</div>
}
</div>
<a href="@ReleaseRoutes.DetailHref(Release)" class="now-showing-title-link">
<MudText Typo="Typo.subtitle2" Class="now-showing-title text-truncate">
@Release.Title
</MudText>
</a>
<div class="dd-accent-icon now-showing-share">
<SharePopover ReleaseEntryKey="@Release.EntryKey" ReleaseMedium="@Release.Medium" />
</div>
</div>
@code {
/// <summary>The current playing track's release. Non-null by the bar's mount gate.</summary>
[Parameter, EditorRequired] public ReleaseDto Release { get; set; } = default!;
}
@@ -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)
{
<div class="dd-accent-icon">
<MudTooltip Text="@(State.TheaterMode ? "Exit theater mode" : "Theater mode")">
<MudIconButton Icon="@Icons.Material.Filled.Theaters"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Theater mode"
aria-pressed="@State.TheaterMode" />
</MudTooltip>
</div>
}
@code {
/// <summary>Trigger-icon size. Defaults Large to match the lava-lamp popover trigger it sits beside.</summary>
[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;
}
@@ -226,12 +226,14 @@
private void ToggleLava()
{
ControlState.LavaEnabled = !ControlState.LavaEnabled;
ControlState.CoerceTheaterMode();
ControlState.NotifyChanged();
}
private void ToggleWaveform()
{
ControlState.WaveformEnabled = !ControlState.WaveformEnabled;
ControlState.CoerceTheaterMode();
ControlState.NotifyChanged();
}
+17 -3
View File
@@ -54,11 +54,21 @@ else
TrackEntryKey="@firstTrack?.EntryKey" />
</Ambient>
<TopRightAction>
@* 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. *@
<WaveformVisualizerControlPopover />
@* 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. *@
<div class="dd-detail-top-actions">
<TheaterModeToggle />
@* 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. *@
<WaveformVisualizerControlPopover />
</div>
</TopRightAction>
<Header>
@* 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). *@
<div class="cut-detail-header">
<div class="cut-detail-meta">
@@ -117,8 +127,11 @@ else
}
</div>
</div>
}
</Header>
<BodyContent>
@if (!VisualizerControlState.TheaterMode)
{
@* Blurb sits between the header and the track-list divider. *@
<ReleaseDescription Description="@release.Description" />
<MudDivider Class="cut-detail-divider" />
@@ -149,6 +162,7 @@ else
}
</div>
}
}
</BodyContent>
</ReleaseDetailScaffold>
}
+16 -2
View File
@@ -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<TrackDto> Tracks);
+21 -7
View File
@@ -56,13 +56,23 @@ else
ShowMeta="false"
ShowShareRow="false">
<TopRightAction>
@* 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. *@
<WaveformVisualizerControlPopover />
@* 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. *@
<div class="dd-detail-top-actions">
<TheaterModeToggle />
@* 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. *@
<WaveformVisualizerControlPopover />
</div>
</TopRightAction>
<Hero>
@* 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
}
</PlayContent>
</ReleaseHeroOverlay>
}
</Hero>
<BodyContent>
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
<ReleaseDescription Description="@release.Description" />
@if (!VisualizerControlState.TheaterMode)
{
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
<ReleaseDescription Description="@release.Description" />
}
</BodyContent>
</ReleaseDetailScaffold>
</MudContainer>
@@ -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);
@@ -56,11 +56,21 @@ else
&larr; All sessions
</MudLink>
@* 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. *@
<WaveformVisualizerControlPopover />
@* 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). *@
<div class="dd-detail-top-actions">
<TheaterModeToggle />
@* 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. *@
<WaveformVisualizerControlPopover />
</div>
</div>
@* 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
</ReleaseHeroOverlay>
<ReleaseDescription Description="@release.Description" />
}
</MudContainer>
}
@@ -97,6 +97,13 @@ public sealed class WaveformVisualizerControlState
/// </summary>
public const bool DefaultWaveformEnabled = true;
/// <summary>
/// Default Theater-mode state. <c>false</c> 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.
/// </summary>
public const bool DefaultTheaterMode = false;
/// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
/// time-span via <see cref="WaveformZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
@@ -137,12 +144,35 @@ public sealed class WaveformVisualizerControlState
/// </summary>
public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled;
/// <summary>
/// Whether Theater Mode is on (Phase 20). When <c>true</c> the three release-detail pages remove
/// their release content via <c>@if</c> 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 <see cref="Changed"/> seam.
/// Gated for visibility on <see cref="LavaEnabled"/> || <see cref="WaveformEnabled"/> at the toggle.
/// </summary>
public bool TheaterMode { get; set; } = DefaultTheaterMode;
/// <summary>
/// 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
/// <see cref="TheaterMode"/>. Mutators set the property then raise this; subscribers re-read the values.
/// </summary>
public event Action? Changed;
/// <summary>
/// Enforces the Theater-Mode invariant: Theater Mode cannot remain on when both visualizer
/// subsystems are off (there is nothing to go to theater FOR). Call this after mutating
/// <see cref="LavaEnabled"/> or <see cref="WaveformEnabled"/> and before
/// <see cref="NotifyChanged"/> so all observers see a consistent, coerced state in the same
/// <see cref="Changed"/> cycle.
/// </summary>
public void CoerceTheaterMode()
{
if (TheaterMode && !LavaEnabled && !WaveformEnabled)
TheaterMode = false;
}
/// <summary>Raise <see cref="Changed"/>. Called by the controls component after mutating a value.</summary>
public void NotifyChanged() => Changed?.Invoke();
}
@@ -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;
@@ -0,0 +1,91 @@
using DeepDrftPublic.Client.Services;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Theater-Mode auto-exit invariant on <see cref="WaveformVisualizerControlState"/>
/// (Phase 20 bug fix): when both subsystems are disabled, <see cref="WaveformVisualizerControlState.CoerceTheaterMode"/>
/// must force <c>TheaterMode = false</c> so observers never see a stranded-theater state.
/// </summary>
[TestFixture]
public class WaveformVisualizerControlStateTests
{
private WaveformVisualizerControlState _state = null!;
[SetUp]
public void SetUp() => _state = new WaveformVisualizerControlState();
// ── CoerceTheaterMode guard ──
// Both off + Theater on → coerce exits theater.
[Test]
public void CoerceTheaterMode_BothOff_TheaterBecomesFalse()
{
_state.TheaterMode = true;
_state.LavaEnabled = false;
_state.WaveformEnabled = false;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.False);
}
// Lava still on → theater is left alone even if waveform is off.
[Test]
public void CoerceTheaterMode_LavaOnWaveformOff_TheaterPreserved()
{
_state.TheaterMode = true;
_state.LavaEnabled = true;
_state.WaveformEnabled = false;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.True);
}
// Waveform still on → theater is left alone even if lava is off.
[Test]
public void CoerceTheaterMode_WaveformOnLavaOff_TheaterPreserved()
{
_state.TheaterMode = true;
_state.LavaEnabled = false;
_state.WaveformEnabled = true;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.True);
}
// Theater already false + both off → no change (no false-positive write).
[Test]
public void CoerceTheaterMode_TheaterAlreadyFalse_NoChange()
{
_state.TheaterMode = false;
_state.LavaEnabled = false;
_state.WaveformEnabled = false;
_state.CoerceTheaterMode();
Assert.That(_state.TheaterMode, Is.False);
}
// ── Changed event fires once with coerced state visible ──
// Verify that after coercion, the Changed notification carries the already-corrected TheaterMode
// value — all observers see a consistent state in the single Changed cycle.
[Test]
public void NotifyChanged_AfterCoerce_ObserverSeesTheaterFalse()
{
_state.TheaterMode = true;
_state.LavaEnabled = false;
_state.WaveformEnabled = false;
bool? observedTheaterMode = null;
_state.Changed += () => observedTheaterMode = _state.TheaterMode;
_state.CoerceTheaterMode();
_state.NotifyChanged();
Assert.That(observedTheaterMode, Is.False);
}
}