feat(public): add Theater Mode to release detail pages

Toggle left of the lava popover hides release content so the visualizer fills
the surface; player bar grows to carry the playing release's cover, title, and
share. State on WaveformVisualizerControlState; pages and bar observe it.
This commit is contained in:
daniel-c-harvey
2026-06-20 21:51:30 -04:00
parent 021801999c
commit fa01b9c8e0
12 changed files with 286 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;
}