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/Controls/WaveformVisualizerControls.razor b/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor index e8a6285..731ef36 100644 --- a/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor +++ b/DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor @@ -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(); } 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). *@
@@ -117,8 +127,11 @@ else }
+ }
+ @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..c20faa1 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,12 +144,35 @@ 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; + /// + /// 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 + /// or and before + /// so all observers see a consistent, coerced state in the same + /// cycle. + /// + public void CoerceTheaterMode() + { + if (TheaterMode && !LavaEnabled && !WaveformEnabled) + TheaterMode = false; + } + /// Raise . Called by the controls component after mutating a value. public void NotifyChanged() => Changed?.Invoke(); } 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; diff --git a/DeepDrftTests/WaveformVisualizerControlStateTests.cs b/DeepDrftTests/WaveformVisualizerControlStateTests.cs new file mode 100644 index 0000000..2131cc5 --- /dev/null +++ b/DeepDrftTests/WaveformVisualizerControlStateTests.cs @@ -0,0 +1,91 @@ +using DeepDrftPublic.Client.Services; + +namespace DeepDrftTests; + +/// +/// Unit tests for the Theater-Mode auto-exit invariant on +/// (Phase 20 bug fix): when both subsystems are disabled, +/// must force TheaterMode = false so observers never see a stranded-theater state. +/// +[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); + } +}