diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor index bdd7816..50b380a 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor @@ -10,13 +10,20 @@ 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) + @* Theater Mode "now showing" band (Phase 20 §5/§7, Wave 2 §2). 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. The band stays mounted whenever a release is playing and eases in/out via + 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) { - + var nowShowing = VisualizerControlState.TheaterMode; +
+
+ +
+
}
diff --git a/DeepDrftPublic.Client/Controls/TheaterModeToggle.razor b/DeepDrftPublic.Client/Controls/TheaterModeToggle.razor index c5b6b6b..10789a4 100644 --- a/DeepDrftPublic.Client/Controls/TheaterModeToggle.razor +++ b/DeepDrftPublic.Client/Controls/TheaterModeToggle.razor @@ -10,11 +10,13 @@ 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. *@ + are off (§3.2) — AND when is true. The page supplies Available so the toggle + only appears when this page's release is the one playing (Phase 20 Wave 2 §3): the toggle owns the + 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)) {
@@ -33,6 +35,14 @@ /// Trigger-icon size. Defaults Large to match the lava-lamp popover trigger it sits beside. [Parameter] public Size IconSize { get; set; } = Size.Large; + /// + /// 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 false and shows no toggle. + /// Defaults true so surfaces with no release-scoping (none today) keep the subsystem-only gate. + /// + [Parameter] public bool Available { get; set; } = true; + protected override void OnInitialized() => State.Changed += OnStateChanged; // The toggle's own visibility and active state both key off State, which another observer (or this diff --git a/DeepDrftPublic.Client/Pages/CutDetail.razor b/DeepDrftPublic.Client/Pages/CutDetail.razor index f27a0ae..8d2809b 100644 --- a/DeepDrftPublic.Client/Pages/CutDetail.razor +++ b/DeepDrftPublic.Client/Pages/CutDetail.razor @@ -37,6 +37,10 @@ else var hasYear = release.ReleaseDate is not 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. *@ +
- + @* 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. *@ + @* 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) - { + @* Theater Mode (Phase 20 §4, Wave 2 §2): the release content stays mounted and eases out via + a collapsing wrapper so it does not pop — IsContentHidden collapses it to zero height when + Theater is on AND this Cut is the playing release. OFF eases it back to its normal layout. *@ +
+
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
@@ -127,11 +134,13 @@ else }
- } +
+
- @if (!VisualizerControlState.TheaterMode) - { + @* Theater Mode (Wave 2 §2): eased collapse, mirroring the Header region. *@ +
+
@* Blurb sits between the header and the track-list divider. *@ @@ -162,13 +171,15 @@ else }
} - } +
+
+
} @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; } // Header Play: load the full album into the queue starting at track 0. diff --git a/DeepDrftPublic.Client/Pages/CutDetailBase.cs b/DeepDrftPublic.Client/Pages/CutDetailBase.cs index 60f6143..0c5c7e2 100644 --- a/DeepDrftPublic.Client/Pages/CutDetailBase.cs +++ b/DeepDrftPublic.Client/Pages/CutDetailBase.cs @@ -20,11 +20,41 @@ 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. + // Theater Mode (Phase 20). The page owns the 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; } + // 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 IStreamingPlayerService? _subscribedPlayer; + + /// + /// 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 EntryKey — the canonical public key the routes + /// and use. + /// + protected bool IsThisReleasePlaying => + PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey; + + /// + /// 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. + /// + protected bool IsContentHidden => VisualizerControlState.TheaterMode && IsThisReleasePlaying; + + /// + /// 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. + /// + protected bool ShowTheaterToggle => + (VisualizerControlState.LavaEnabled || VisualizerControlState.WaveformEnabled) && IsThisReleasePlaying; // 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 @@ -40,8 +70,22 @@ public abstract class CutDetailBase : ComponentBase, IDisposable private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged); + private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged); + 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; // 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(); VisualizerControlState.Changed -= OnVisualizerStateChanged; + if (_subscribedPlayer is not null) + { + _subscribedPlayer.StateChanged -= OnPlayerStateChanged; + _subscribedPlayer = null; + } } // JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer. diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor index 7a13b6e..1800076 100644 --- a/DeepDrftPublic.Client/Pages/MixDetail.razor +++ b/DeepDrftPublic.Client/Pages/MixDetail.razor @@ -41,7 +41,7 @@ else TrackId="@ViewModel.Track?.Id" TrackEntryKey="@ViewModel.Track?.EntryKey" /> -
+
@* 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 @@ -60,7 +60,9 @@ else 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. *@
- + @* 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. *@ + @* 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 @@ -69,10 +71,11 @@ else
- @* 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) - { + @* Theater Mode (Phase 20 §4, Wave 2 §2): the hero overlay stays mounted and eases out via + a collapsing wrapper so it does not pop — collapsed to zero height when Theater is on AND + this Mix is the playing release. OFF eases the full-bleed visualizer back behind the hero. *@ +
+
@* 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. *@ @@ -96,14 +99,17 @@ else } - } +
+
- @if (!VisualizerControlState.TheaterMode) - { + @* Theater Mode (Wave 2 §2): eased collapse, mirroring the Hero region. *@ +
+
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@ - } +
+
@@ -113,7 +119,7 @@ else @code { 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; } // The hero now carries the play affordance (the scaffold's header is suppressed), so the diff --git a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs index 47e5e55..d528125 100644 --- a/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs +++ b/DeepDrftPublic.Client/Pages/ReleaseDetailBase.cs @@ -18,11 +18,41 @@ 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. + // Theater Mode (Phase 20). The page owns the 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; } + // 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 IStreamingPlayerService? _subscribedPlayer; + + /// + /// 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 EntryKey — the canonical public key the routes + /// and use. + /// + protected bool IsThisReleasePlaying => + PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey; + + /// + /// 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. + /// + protected bool IsContentHidden => VisualizerControlState.TheaterMode && IsThisReleasePlaying; + + /// + /// 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. + /// + protected bool ShowTheaterToggle => + (VisualizerControlState.LavaEnabled || VisualizerControlState.WaveformEnabled) && IsThisReleasePlaying; // 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 @@ -42,8 +72,22 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged); + private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged); + 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 // same-template navigations, so the load decision must live here, not in // OnInitialized (which fires once per instance). @@ -83,6 +127,11 @@ public abstract class ReleaseDetailBase : ComponentBase, IDisposable { _persistingSubscription.Dispose(); VisualizerControlState.Changed -= OnVisualizerStateChanged; + if (_subscribedPlayer is not null) + { + _subscribedPlayer.StateChanged -= OnPlayerStateChanged; + _subscribedPlayer = null; + } } // JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer. diff --git a/DeepDrftPublic.Client/Pages/SessionDetail.razor b/DeepDrftPublic.Client/Pages/SessionDetail.razor index e2b8c04..dd954ae 100644 --- a/DeepDrftPublic.Client/Pages/SessionDetail.razor +++ b/DeepDrftPublic.Client/Pages/SessionDetail.razor @@ -49,7 +49,7 @@ else TrackId="@ViewModel.Track?.Id" TrackEntryKey="@ViewModel.Track?.EntryKey" /> - +
@@ -59,18 +59,20 @@ else @* 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). *@
- + @* 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. *@ + @* 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) - { + @* Theater Mode (Phase 20 §4, Wave 2 §2): the hero overlay + blurb stay mounted and ease out via a + collapsing wrapper so they do not pop — collapsed to zero height when Theater is on AND this + Session is the playing release. The top row above stays. OFF eases this region back in. *@ +
+
@* 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. *@ @@ -96,7 +98,8 @@ else - } +
+
} @@ -104,7 +107,7 @@ else @code { 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; } // Mirrors the play-toggle wiring the shared scaffold owns. Session detail composes the player diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css index e4befc1..f2c3b5c 100644 --- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css +++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css @@ -360,6 +360,66 @@ h2, h3, h4, h5, h6, 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 { display: flex; flex-direction: row;