Merge Theater Mode refinements (Phase 20 Wave 2) into dev

This commit is contained in:
daniel-c-harvey
2026-06-21 09:23:56 -04:00
8 changed files with 239 additions and 44 deletions
@@ -10,13 +10,20 @@ else
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container"> <MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
<MudPaper Elevation="8" Class="player-surface pa-3"> <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 @* Theater Mode "now showing" band (Phase 20 §5/§7, Wave 2 §2). Keyed off the playing
release is playing — keyed off the playing track's Release, not off any detail page track's Release, not off any detail page (the bar reaches into no page; §6). The release
(the bar reaches into no page; §6). The release page is hidden in Theater Mode, so the page is hidden in Theater Mode, so the bar carries its identity: cover, linked title,
bar carries its identity: cover, linked title, release share. *@ release share. The band stays mounted whenever a release is playing and eases in/out via
@if (VisualizerControlState.TheaterMode && CurrentTrack?.Release is not null) the shared .dd-theater-collapsible wrapper — collapsed (zero height, faded) unless
Theater is ON — so the bar grows/shrinks smoothly instead of popping. *@
@if (CurrentTrack?.Release is not null)
{ {
<NowShowingPanel Release="CurrentTrack.Release" /> var nowShowing = VisualizerControlState.TheaterMode;
<div class="dd-theater-collapsible @(nowShowing ? null : "dd-theater-collapsed")">
<div class="dd-theater-collapsible-inner">
<NowShowingPanel Release="CurrentTrack.Release" />
</div>
</div>
} }
<div class="player-layout"> <div class="player-layout">
@@ -10,11 +10,13 @@
reaches into no page and no bar — single source, multiple observers (§6). 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 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. are off (§3.2) — AND when <see cref="Available"/> is true. The page supplies Available so the toggle
Active visual state when Theater is ON. .dd-accent-icon gives the green-accent glyph in both themes only appears when this page's release is the one playing (Phase 20 Wave 2 §3): the toggle owns the
with zero new CSS (§8) — same treatment as the lava-lamp trigger it sits beside. *@ subsystem gate; the page owns the release-playing predicate. 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 as the lava-lamp trigger. *@
@if (State.LavaEnabled || State.WaveformEnabled) @if (Available && (State.LavaEnabled || State.WaveformEnabled))
{ {
<div class="dd-accent-icon"> <div class="dd-accent-icon">
<MudTooltip Text="@(State.TheaterMode ? "Exit theater mode" : "Theater mode")"> <MudTooltip Text="@(State.TheaterMode ? "Exit theater mode" : "Theater mode")">
@@ -33,6 +35,14 @@
/// <summary>Trigger-icon size. Defaults Large to match the lava-lamp popover trigger it sits beside.</summary> /// <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; [Parameter] public Size IconSize { get; set; } = Size.Large;
/// <summary>
/// Whether the toggle is available on this surface (Phase 20 Wave 2 §3). The page passes the
/// "this release is the one playing" predicate here; Theater Mode only applies to the playing
/// release, so a detail page whose release is not playing passes <c>false</c> and shows no toggle.
/// Defaults <c>true</c> so surfaces with no release-scoping (none today) keep the subsystem-only gate.
/// </summary>
[Parameter] public bool Available { get; set; } = true;
protected override void OnInitialized() => State.Changed += OnStateChanged; protected override void OnInitialized() => State.Changed += OnStateChanged;
// The toggle's own visibility and active state both key off State, which another observer (or this // The toggle's own visibility and active state both key off State, which another observer (or this
+21 -10
View File
@@ -37,6 +37,10 @@ else
var hasYear = release.ReleaseDate is not null; var hasYear = release.ReleaseDate is not null;
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null; var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
@* Full-screen content body (Phase 20 Wave 2 §1): the scaffold has no Class param, so a thin wrapper
carries the min-height. dd-detail-fill keeps the body >= viewport height (below the nav) so the
ambient visualizer reads full-screen and the site footer is pushed below the fold. *@
<div class="dd-detail-fill">
<ReleaseDetailScaffold Title="@release.Title" <ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist" Artist="@release.Artist"
Track="@firstTrack" Track="@firstTrack"
@@ -58,17 +62,20 @@ else
controls over the experience, not release content, so both stay in Theater Mode (§4/OQ4). 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. *@ Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
<div class="dd-detail-top-actions"> <div class="dd-detail-top-actions">
<TheaterModeToggle /> @* Theater toggle only appears when this Cut is the currently-playing release (Phase 20
Wave 2 §3). ShowTheaterToggle folds in the subsystem gate + the release-playing check. *@
<TheaterModeToggle Available="ShowTheaterToggle" />
@* Lava-lamp icon → popover panel (full parity, §3d-revised). Sits top-right across from the @* 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. *@ back link, clear of the header's own Play/Share affordances below. *@
<WaveformVisualizerControlPopover /> <WaveformVisualizerControlPopover />
</div> </div>
</TopRightAction> </TopRightAction>
<Header> <Header>
@* Theater Mode (Phase 20 §4): the release content is removed from the render — not @* Theater Mode (Phase 20 §4, Wave 2 §2): the release content stays mounted and eases out via
CSS-hidden — so the visualizer fills the surface. OFF restores it byte-for-byte. *@ a collapsing wrapper so it does not pop — IsContentHidden collapses it to zero height when
@if (!VisualizerControlState.TheaterMode) Theater is on AND this Cut is the playing release. OFF eases it back to its normal layout. *@
{ <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@ @* 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-header">
<div class="cut-detail-meta"> <div class="cut-detail-meta">
@@ -127,11 +134,13 @@ else
} }
</div> </div>
</div> </div>
} </div>
</div>
</Header> </Header>
<BodyContent> <BodyContent>
@if (!VisualizerControlState.TheaterMode) @* Theater Mode (Wave 2 §2): eased collapse, mirroring the Header region. *@
{ <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Blurb sits between the header and the track-list divider. *@ @* Blurb sits between the header and the track-list divider. *@
<ReleaseDescription Description="@release.Description" /> <ReleaseDescription Description="@release.Description" />
<MudDivider Class="cut-detail-divider" /> <MudDivider Class="cut-detail-divider" />
@@ -162,13 +171,15 @@ else
} }
</div> </div>
} }
} </div>
</div>
</BodyContent> </BodyContent>
</ReleaseDetailScaffold> </ReleaseDetailScaffold>
</div>
} }
@code { @code {
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } // PlayerService is cascaded by CutDetailBase (used there for the Theater release-playing predicate).
[CascadingParameter] public IQueueService? Queue { get; set; } [CascadingParameter] public IQueueService? Queue { get; set; }
// Header Play: load the full album into the queue starting at track 0. // Header Play: load the full album into the queue starting at track 0.
+51 -2
View File
@@ -20,11 +20,41 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
[Inject] public required CutDetailViewModel ViewModel { get; set; } [Inject] public required CutDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; } [Inject] public required PersistentComponentState PersistentState { get; set; }
// Theater Mode (Phase 20). The page owns the @if (!VisualizerControlState.TheaterMode) content gate, // Theater Mode (Phase 20). The page owns the content gate, so it must re-render when the flag flips
// so it must re-render when the flag flips on the toggle. Property-injected; no constructor growth. // on the toggle. Property-injected; no constructor growth.
[Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; } [Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; }
// Theater Mode is scoped to the currently-playing release (Phase 20 Wave 2 §3). The page observes
// player state so the toggle availability and content gate re-evaluate live when playback starts,
// stops, or moves to a different release. Cascaded by AudioPlayerProvider; no constructor growth.
// The cascade is IsFixed, so the provider's own re-render does not reach this page — the page must
// subscribe to StateChanged to re-render itself (same posture as AudioPlayerBar).
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
private IStreamingPlayerService? _subscribedPlayer;
/// <summary>
/// True when the currently-playing track belongs to this page's release. Theater Mode only applies
/// to the playing release: a detail page whose release is not playing ignores the global flag and
/// shows no toggle. Identity is the release <c>EntryKey</c> — the canonical public key the routes
/// and <see cref="DeepDrftPublic.Client.Common.ReleaseRoutes"/> use.
/// </summary>
protected bool IsThisReleasePlaying =>
PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey;
/// <summary>
/// True when this page's release content should be hidden for Theater Mode — only when Theater is on
/// AND this release is the one playing. Drives the eased collapse of the header/track-list regions.
/// </summary>
protected bool IsContentHidden => VisualizerControlState.TheaterMode && IsThisReleasePlaying;
/// <summary>
/// True when the Theater toggle should be offered on this page: a visualizer subsystem is on AND
/// this page's release is the one playing.
/// </summary>
protected bool ShowTheaterToggle =>
(VisualizerControlState.LavaEnabled || VisualizerControlState.WaveformEnabled) && IsThisReleasePlaying;
// The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g. // The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g.
// /cuts/{a} -> /cuts/{b}) which reuse this component instance and fire OnParametersSet without // /cuts/{a} -> /cuts/{b}) which reuse this component instance and fire OnParametersSet without
@@ -40,8 +70,22 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged); private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// The player cascade is IsFixed, so the provider's re-render does not reach this page; subscribe
// to the StateChanged side-channel to re-render when playback moves between releases. Idempotent
// (reference-guarded) and unsubscribed on dispose — same posture as AudioPlayerBar.
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedPlayer))
{
if (_subscribedPlayer is not null)
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedPlayer = PlayerService;
}
if (_loaded && _loadedKey == EntryKey) return; if (_loaded && _loadedKey == EntryKey) return;
// Capture the key synchronously before any await so a re-entrant call (rapid navigation or a // Capture the key synchronously before any await so a re-entrant call (rapid navigation or a
@@ -75,6 +119,11 @@ public abstract class CutDetailBase : ComponentBase, IDisposable
{ {
_persistingSubscription.Dispose(); _persistingSubscription.Dispose();
VisualizerControlState.Changed -= OnVisualizerStateChanged; VisualizerControlState.Changed -= OnVisualizerStateChanged;
if (_subscribedPlayer is not null)
{
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
_subscribedPlayer = null;
}
} }
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer. // JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
+17 -11
View File
@@ -41,7 +41,7 @@ else
TrackId="@ViewModel.Track?.Id" TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" /> TrackEntryKey="@ViewModel.Track?.EntryKey" />
<div class="mix-detail-foreground"> <div class="mix-detail-foreground dd-detail-fill">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container"> <MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
@* Mix keeps the scaffold solely for the Phase 10 top row (back link | controls | lava-lamp). @* Mix keeps the scaffold solely for the Phase 10 top row (back link | controls | lava-lamp).
Title/artist/genre/date/share/play all move into the overlaid hero, so the scaffold's Title/artist/genre/date/share/play all move into the overlaid hero, so the scaffold's
@@ -60,7 +60,9 @@ else
visible in Theater Mode — controls over the experience, not release content (§4/OQ4). 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. *@ Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
<div class="dd-detail-top-actions"> <div class="dd-detail-top-actions">
<TheaterModeToggle /> @* Theater toggle only appears when this Mix is the currently-playing release
(Phase 20 Wave 2 §3). ShowTheaterToggle folds in the subsystem + release-playing gate. *@
<TheaterModeToggle Available="ShowTheaterToggle" />
@* Lava-lamp icon → popover panel, top-right across from the back link (Phase 12 @* 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 §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 and the popover IS the panel. Mix takes the cleanest anchor case (§8e) — the popover's
@@ -69,10 +71,11 @@ else
</div> </div>
</TopRightAction> </TopRightAction>
<Hero> <Hero>
@* Theater Mode (Phase 20 §4): the hero overlay (Session/Mix release content) is removed @* Theater Mode (Phase 20 §4, Wave 2 §2): the hero overlay stays mounted and eases out via
from the render so the full-bleed visualizer fills the surface. OFF restores it. *@ a collapsing wrapper so it does not pop — collapsed to zero height when Theater is on AND
@if (!VisualizerControlState.TheaterMode) this Mix is the playing release. OFF eases the full-bleed visualizer back behind the hero. *@
{ <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The @* 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 cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
to null). Share and play ride in as slots, matching Sessions. *@ to null). Share and play ride in as slots, matching Sessions. *@
@@ -96,14 +99,17 @@ else
} }
</PlayContent> </PlayContent>
</ReleaseHeroOverlay> </ReleaseHeroOverlay>
} </div>
</div>
</Hero> </Hero>
<BodyContent> <BodyContent>
@if (!VisualizerControlState.TheaterMode) @* Theater Mode (Wave 2 §2): eased collapse, mirroring the Hero region. *@
{ <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@ @* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
<ReleaseDescription Description="@release.Description" /> <ReleaseDescription Description="@release.Description" />
} </div>
</div>
</BodyContent> </BodyContent>
</ReleaseDetailScaffold> </ReleaseDetailScaffold>
</MudContainer> </MudContainer>
@@ -113,7 +119,7 @@ else
@code { @code {
protected override string PersistKey => "mix-detail"; protected override string PersistKey => "mix-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } // PlayerService is cascaded by ReleaseDetailBase (used there for the Theater release-playing predicate).
[CascadingParameter] public IQueueService? Queue { get; set; } [CascadingParameter] public IQueueService? Queue { get; set; }
// The hero now carries the play affordance (the scaffold's header is suppressed), so the // The hero now carries the play affordance (the scaffold's header is suppressed), so the
@@ -18,11 +18,41 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; } [Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; } [Inject] public required PersistentComponentState PersistentState { get; set; }
// Theater Mode (Phase 20). The page owns the @if (!VisualizerControlState.TheaterMode) content gate, // Theater Mode (Phase 20). The page owns the content gate, so it must re-render when the flag flips
// so it must re-render when the flag flips on the toggle. Property-injected; no constructor growth. // on the toggle. Property-injected; no constructor growth.
[Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; } [Inject] public required WaveformVisualizerControlState VisualizerControlState { get; set; }
// Theater Mode is scoped to the currently-playing release (Phase 20 Wave 2 §3). The page observes
// player state so the toggle availability and content gate re-evaluate live when playback starts,
// stops, or moves to a different release. Cascaded by AudioPlayerProvider; no constructor growth.
// The cascade is IsFixed, so the provider's own re-render does not reach this page — the page must
// subscribe to StateChanged to re-render itself (same posture as AudioPlayerBar).
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
private IStreamingPlayerService? _subscribedPlayer;
/// <summary>
/// True when the currently-playing track belongs to this page's release. Theater Mode only applies
/// to the playing release: a detail page whose release is not playing ignores the global flag and
/// shows no toggle. Identity is the release <c>EntryKey</c> — the canonical public key the routes
/// and <see cref="DeepDrftPublic.Client.Common.ReleaseRoutes"/> use.
/// </summary>
protected bool IsThisReleasePlaying =>
PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey;
/// <summary>
/// True when this page's release content should be hidden for Theater Mode — only when Theater is on
/// AND this release is the one playing. Drives the eased collapse of the hero/blurb regions.
/// </summary>
protected bool IsContentHidden => VisualizerControlState.TheaterMode && IsThisReleasePlaying;
/// <summary>
/// True when the Theater toggle should be offered on this page: a visualizer subsystem is on AND
/// this page's release is the one playing.
/// </summary>
protected bool ShowTheaterToggle =>
(VisualizerControlState.LavaEnabled || VisualizerControlState.WaveformEnabled) && IsThisReleasePlaying;
// The release EntryKey the ViewModel currently holds. Tracks param-only navigations (e.g. // The release EntryKey the ViewModel currently holds. Tracks param-only navigations (e.g.
// /mixes/{a} -> /mixes/{b}) which reuse this component instance and fire OnParametersSet // /mixes/{a} -> /mixes/{b}) which reuse this component instance and fire OnParametersSet
@@ -42,8 +72,22 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged); private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// The player cascade is IsFixed, so the provider's re-render does not reach this page; subscribe
// to the StateChanged side-channel to re-render when playback moves between releases. Idempotent
// (reference-guarded) and unsubscribed on dispose — same posture as AudioPlayerBar.
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedPlayer))
{
if (_subscribedPlayer is not null)
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedPlayer = PlayerService;
}
// Re-run whenever the route key changes. Component instances are reused across // Re-run whenever the route key changes. Component instances are reused across
// same-template navigations, so the load decision must live here, not in // same-template navigations, so the load decision must live here, not in
// OnInitialized (which fires once per instance). // OnInitialized (which fires once per instance).
@@ -83,6 +127,11 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable
{ {
_persistingSubscription.Dispose(); _persistingSubscription.Dispose();
VisualizerControlState.Changed -= OnVisualizerStateChanged; VisualizerControlState.Changed -= OnVisualizerStateChanged;
if (_subscribedPlayer is not null)
{
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
_subscribedPlayer = null;
}
} }
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer. // JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
@@ -49,7 +49,7 @@ else
TrackId="@ViewModel.Track?.Id" TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" /> TrackEntryKey="@ViewModel.Track?.EntryKey" />
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page session-detail-foreground"> <MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page session-detail-foreground dd-detail-fill">
<div class="session-detail-top-row"> <div class="session-detail-top-row">
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back"> <MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
@@ -59,18 +59,20 @@ else
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). The whole top @* 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). *@ row (back + theater + lava) stays in Theater Mode — controls, not release content (§4/OQ4). *@
<div class="dd-detail-top-actions"> <div class="dd-detail-top-actions">
<TheaterModeToggle /> @* Theater toggle only appears when this Session is the currently-playing release
(Phase 20 Wave 2 §3). ShowTheaterToggle folds in the subsystem + release-playing gate. *@
<TheaterModeToggle Available="ShowTheaterToggle" />
@* Lava-lamp icon → popover panel (full parity, §3e/§3d-revised). Anchored top-right, clear of @* 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. *@ the hero overlay and the share/play affordances overlaid on the hero below. *@
<WaveformVisualizerControlPopover /> <WaveformVisualizerControlPopover />
</div> </div>
</div> </div>
@* Theater Mode (Phase 20 §4): the hero overlay + blurb (the session's release content) are removed @* Theater Mode (Phase 20 §4, Wave 2 §2): the hero overlay + blurb stay mounted and ease out via a
from the render so the ambient visualizer fills the surface. The top row above stays. OFF collapsing wrapper so they do not pop — collapsed to zero height when Theater is on AND this
restores this region byte-for-byte. *@ Session is the playing release. The top row above stays. OFF eases this region back in. *@
@if (!VisualizerControlState.TheaterMode) <div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
{ <div class="dd-theater-collapsible-inner">
@* The overlay shows the cover thumbnail only when it differs from the resolved hero image — @* 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 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. *@ thumb would duplicate the background. That logic lives in ReleaseHeroOverlay. *@
@@ -96,7 +98,8 @@ else
</ReleaseHeroOverlay> </ReleaseHeroOverlay>
<ReleaseDescription Description="@release.Description" /> <ReleaseDescription Description="@release.Description" />
} </div>
</div>
</MudContainer> </MudContainer>
} }
@@ -104,7 +107,7 @@ else
@code { @code {
protected override string PersistKey => "session-detail"; protected override string PersistKey => "session-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } // PlayerService is cascaded by ReleaseDetailBase (used there for the Theater release-playing predicate).
[CascadingParameter] public IQueueService? Queue { get; set; } [CascadingParameter] public IQueueService? Queue { get; set; }
// Mirrors the play-toggle wiring the shared scaffold owns. Session detail composes the player // Mirrors the play-toggle wiring the shared scaffold owns. Session detail composes the player
@@ -360,6 +360,66 @@ h2, h3, h4, h5, h6,
gap: 0.25rem; gap: 0.25rem;
} }
/* Full-screen detail body (Phase 20 Wave 2 §1). The content body always fills the viewport below the
fixed nav so the ambient/full-bleed visualizer reads as genuinely full-screen and the footer is pushed
below the fold (scroll to reach it) — independent of Theater Mode. Reuses the shared
--deepdrft-nav-height token (88px desktop / 72px mobile) so the clearance tracks the bar across
breakpoints; no new layout token. Applied to each detail page's foreground content container. */
.dd-detail-fill {
min-height: calc(100vh - var(--deepdrft-nav-height, 88px));
}
/* Eased content collapse for Theater Mode (Phase 20 Wave 2 §2). The detail content stays mounted and
collapses smoothly when .dd-theater-collapsed is applied, so toggling Theater eases both directions
instead of popping — when collapsed the content is fully out of the way and the visualizer is
unobstructed. The same pattern drives the player-bar "now showing" band so the bar grows/shrinks
smoothly too.
Technique: grid-template-rows 1fr → 0fr interpolates the REAL content height (no 400vh ceiling
artifact / delayed-start that the old max-height approach had). The direct child receives
overflow:hidden + min-height:0 so it actually clips during the transition (the grid child is the
collapsing unit). visibility:hidden removes all descendants from the tab order and from pointer/
keyboard interaction once collapsed — this fixes the Major accessibility defect where Tab could
reach hidden controls. transition-behavior:allow-discrete makes visibility flip discretely: it
flips to hidden AFTER the ease-out finishes (so the animation plays fully), and flips back to
visible BEFORE the ease-in starts (so content is immediately interactive on the way back in).
The visibility transition duration matches the height ease (0.45s) so allow-discrete has a real
interval to defer against: on collapse the flip to hidden is held until t=0.45s; on reopen it
fires at t=0 (immediately interactive). A 0s duration would fire the flip at t≈0 on collapse,
defeating the deferral and hiding content before the ease-out finishes. */
.dd-theater-collapsible {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
transition: grid-template-rows 0.45s ease, opacity 0.3s ease, visibility 0.45s;
transition-behavior: allow-discrete;
}
/* The single direct child clips itself during the grid-row collapse. min-height:0 overrides the
implicit min-height:auto that would prevent the row from shrinking past the content's intrinsic
height. overflow:hidden clips painted content when the row is partially collapsed. */
.dd-theater-collapsible > * {
overflow: hidden;
min-height: 0;
}
.dd-theater-collapsed {
grid-template-rows: 0fr;
opacity: 0;
/* visibility flips to hidden at the END of the 0.45s ease-out (deferred by allow-discrete);
on reopen it flips back to visible at t=0 so content is immediately interactive. */
visibility: hidden;
}
/* Honor reduced-motion: collapse still happens (it is layout, not decoration) but instantly, matching
the parallax precedent (transition-duration: 0). */
@media (prefers-reduced-motion: reduce) {
.dd-theater-collapsible {
transition-duration: 0ms;
}
}
.deepdrft-track-detail-meta { .deepdrft-track-detail-meta {
display: flex; display: flex;
flex-direction: row; flex-direction: row;