From a6d25344b4a2c5dc18f8922e346d65be94b8093f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 16 Jun 2026 20:53:25 -0400 Subject: [PATCH] feat(mix-detail): extract shared ReleaseHeroOverlay; Mix cover becomes overlaid 600px square hero (Direction B) --- .../Controls/ReleaseDetailScaffold.razor | 43 +++-- .../Controls/ReleaseDetailScaffold.razor.cs | 8 + .../Controls/ReleaseHeroOverlay.razor | 110 +++++++++++ .../Controls/ReleaseHeroOverlay.razor.css | 178 ++++++++++++++++++ DeepDrftPublic.Client/Pages/MixDetail.razor | 88 +++++---- .../Pages/MixDetail.razor.css | 16 +- .../Pages/SessionDetail.razor | 89 ++------- .../Pages/SessionDetail.razor.css | 168 +---------------- 8 files changed, 399 insertions(+), 301 deletions(-) create mode 100644 DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor create mode 100644 DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor.css diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor index d882b10..18ebd9d 100644 --- a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor +++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor @@ -25,27 +25,32 @@ @* The header region. A composer that wants the default masthead+play row supplies nothing; one that needs a different arrangement (e.g. the Cut album's left-meta / right-cover split) supplies - its own Header fragment. Layout variance rides this slot, never a boolean flag (Phase 9 §5.3). *@ - @if (Header is not null) + its own Header fragment. Layout variance rides this slot, never a boolean flag (Phase 9 §5.3). + ShowHeader (a gate, not a layout flag) suppresses the region entirely for composers that carry + title/artist/play elsewhere — Mix overlays them on its hero. *@ + @if (ShowHeader) { - @Header - } - else - { - -
- @Title - @Artist -
+ @if (Header is not null) + { + @Header + } + else + { + +
+ @Title + @Artist +
- @* Play only makes sense once a playable track is resolved. *@ - @if (Track is not null) - { - - - - } -
+ @* Play only makes sense once a playable track is resolved. *@ + @if (Track is not null) + { + + + + } +
+ } } @Hero diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs index c785dc0..e5dd727 100644 --- a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs +++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs @@ -60,6 +60,14 @@ public partial class ReleaseDetailScaffold : ComponentBase /// Optional medium-specific metadata block, rendered under a divider when present. [Parameter] public RenderFragment? MetaContent { get; set; } + /// + /// Gate for the header region (masthead + play, or a custom ). A composer that + /// carries title/artist/play elsewhere — e.g. Mix overlays them on its hero — sets this false to + /// suppress the duplicate. A gate, not a layout flag, so it is slot-consistent with + /// / (Phase 9 §5.3). Defaults to shown. + /// + [Parameter] public bool ShowHeader { get; set; } = true; + /// /// Gate for the metadata block. Lets a consumer supply a fragment but /// suppress the divider + block when its data is empty (slot fragments cannot be conditionally diff --git a/DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor b/DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor new file mode 100644 index 0000000..1bf2638 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor @@ -0,0 +1,110 @@ +@namespace DeepDrftPublic.Client.Controls + +@* Shared background-image hero with all release metadata overlaid: top row (genre/date + share), + bottom row (optional cover thumb + title/artist + play). Single source of truth for the overlay + composition consumed by both Session and Mix detail. Purely presentational — owns no data fetch + and no player wiring; play/share ride in as slots so each page keeps its own toggle. Per-page + aspect/sizing variance rides the Class parameter (e.g. Mix's square `mix-hero`), never a fork. *@ + +@{ + var hasGenre = !string.IsNullOrEmpty(Genre); + var hasDate = ReleaseDate is not null; + // Show the cover thumbnail only when it differs from the hero background — otherwise it would + // duplicate the same image. Mix passes CoverThumbKey=null, so this is false there for free. + var showCover = !string.IsNullOrEmpty(CoverThumbKey) && CoverThumbKey != HeroImageKey; +} + +@* The hero is the positioning context for every overlay row; the gradient shim and the + top/bottom overlays are absolutely positioned children of this wrapper. *@ +
+ @if (!string.IsNullOrEmpty(HeroImageKey)) + { +
+ } + else + { +
+ +
+ } + + @* Darkening shim so overlaid text/controls stay legible over any image. *@ +
+ + @* Top overlay: secondary details (genre, release date) and the share affordance. *@ +
+ + @if (hasGenre) + { + + @Genre + + } + @if (hasDate) + { +
+ Released + @ReleaseDate!.Value.ToString("MMMM yyyy") +
+ } +
+ @if (ShareContent is not null) + { +
+ @ShareContent +
+ } +
+ + @* Bottom overlay: cover thumbnail, title/artist, and the play affordance in one row. *@ +
+ + @if (showCover) + { +
+
+
+ } +
+
@Title
+
@Artist
+
+ @if (PlayContent is not null) + { +
+ @PlayContent +
+ } +
+
+
+ +@code { + /// Background image entry key. Null renders the placeholder treatment. + [Parameter] public string? HeroImageKey { get; set; } + + /// Material icon for the no-image placeholder (Session: Piano; Mix: Album). + [Parameter] public required string PlaceholderIcon { get; set; } + + /// + /// Optional small cover thumbnail in the bottom row. Shown only when it differs from + /// (otherwise it would duplicate the background). Mix passes null. + /// + [Parameter] public string? CoverThumbKey { get; set; } + + [Parameter] public required string Title { get; set; } + [Parameter] public string? Artist { get; set; } + [Parameter] public string? Genre { get; set; } + [Parameter] public DateOnly? ReleaseDate { get; set; } + + /// Share affordance slot — each page passes its own SharePopover with the right params. + [Parameter] public RenderFragment? ShareContent { get; set; } + + /// Play affordance slot — each page passes its PlayStateIcon wired to its own toggle. + [Parameter] public RenderFragment? PlayContent { get; set; } + + /// Extra class for per-page aspect/sizing variance (e.g. Mix's square `mix-hero`). + [Parameter] public string? Class { get; set; } +} diff --git a/DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor.css b/DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor.css new file mode 100644 index 0000000..3840a35 --- /dev/null +++ b/DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor.css @@ -0,0 +1,178 @@ +/* Background-image hero with the release detail overlaid directly on top, themed to match the + NowPlaying glassmorphic family. The overlay shell (.release-hero, shim, top/bottom rows) is + plain
s; per-page aspect/sizing variance rides an extra class (e.g. .mix-hero) layered on + .release-hero. The default aspect here is Sessions' wide hero. */ + +/* Positioning context for every overlay. A tall, dominant frame rather than the 16:9 strip. */ +.release-hero { + position: relative; + width: 100%; + aspect-ratio: 16 / 10; + max-height: 70vh; + min-height: 420px; + margin-top: 1rem; + overflow: hidden; + border-radius: 8px; + box-shadow: 0 12px 40px color-mix(in srgb, var(--mud-palette-text-secondary) 22%, transparent); +} + +/* The background-image surface and placeholder are plain
s, so no ::deep is needed here. */ +.release-hero-img { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +.release-hero-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--mud-palette-surface); +} + +::deep .release-hero-placeholder .mud-icon-root { + font-size: 120px; +} + +/* Darkening gradient shim: stronger at the bottom (under the title/play row) and lighter toward + the middle, with a top darken so the genre/share overlay stays legible too. */ +.release-hero-shim { + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(to bottom, + rgba(0, 0, 0, 0.55) 0%, + rgba(0, 0, 0, 0.15) 28%, + rgba(0, 0, 0, 0.15) 55%, + rgba(0, 0, 0, 0.75) 100%); +} + +/* --- Top overlay: genre / date / share --- */ +.release-hero-top { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 2; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem 1.5rem; +} + +.release-overlay-date { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.release-overlay-label { + font-family: var(--deepdrft-font-mono); + font-size: 0.6rem; + letter-spacing: 0.25em; + text-transform: uppercase; + color: var(--deepdrft-green-accent); +} + +.release-overlay-value { + font-family: var(--deepdrft-font-body); + font-size: 0.8rem; + color: var(--deepdrft-white); +} + +/* Genre chip themed to the glassmorphic NowPlaying surface. The class lands on MudChip's native + .mud-chip output, so ::deep is required to reach it. */ +::deep .release-overlay-chip.mud-chip { + background: rgba(250, 250, 248, 0.06); + border: 1px solid rgba(250, 250, 248, 0.12); + backdrop-filter: blur(8px); + color: var(--deepdrft-white); + font-family: var(--deepdrft-font-mono); + letter-spacing: 0.12em; +} + +/* --- Bottom overlay: cover thumb / title / play --- */ +.release-hero-bottom { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 2; + padding: 1.5rem; +} + +.release-cover-thumb { + flex: 0 0 auto; + width: 96px; + height: 96px; + overflow: hidden; + border: 1px solid rgba(250, 250, 248, 0.12); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); +} + +/* The cover-thumb art surface is a plain
, so no ::deep is needed. */ +.release-cover-thumb .deepdrft-track-detail-cover-art { + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +.release-hero-titles { + flex: 1 1 auto; + min-width: 0; +} + +.release-overlay-title { + font-family: var(--deepdrft-font-display); + font-size: clamp(1.75rem, 4vw, 2.75rem); + font-weight: 400; + line-height: 1.1; + color: var(--deepdrft-white); + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); +} + +.release-overlay-artist { + font-family: var(--deepdrft-font-body); + font-size: 0.85rem; + letter-spacing: 0.08em; + color: rgba(250, 250, 248, 0.7); + margin-top: 0.35rem; +} + +.release-hero-play { + flex: 0 0 auto; +} + +/* The play affordance and share button sit over a dark image — force their icon glyphs to the + light theme color regardless of MudBlazor's Secondary palette. Both PlayStateIcon and + SharePopover render MudIconButton / MudProgressCircular internals, so ::deep is required. */ +::deep .release-hero-play .mud-icon-button, +::deep .release-hero-play .mud-progress-circular, +::deep .release-hero-share .mud-icon-button { + color: var(--deepdrft-white); +} + +@media (max-width: 599.98px) { + .release-hero { + aspect-ratio: 3 / 4; + min-height: 380px; + } + + /* release-hero-bottom-row rides on MudStack's native output div, so ::deep is required. */ + ::deep .release-hero-bottom-row { + flex-wrap: wrap; + } + + .release-cover-thumb { + width: 72px; + height: 72px; + } +} diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor index 386b5fd..5041869 100644 --- a/DeepDrftPublic.Client/Pages/MixDetail.razor +++ b/DeepDrftPublic.Client/Pages/MixDetail.razor @@ -1,5 +1,6 @@ @page "/mixes/{EntryKey}" @using DeepDrftPublic.Client.Controls +@using DeepDrftPublic.Client.Services @inherits ReleaseDetailBase @(ViewModel.Release?.Title ?? "Mix") - DeepDrft @@ -31,8 +32,6 @@ else if (ViewModel.NotFound || ViewModel.Release is null) else { var release = ViewModel.Release; - var hasGenre = release.Genre is not null; - var hasDate = release.ReleaseDate is not null; @* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to @@ -41,12 +40,17 @@ else
+ @* 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 + default header and meta regions are suppressed (ShowHeader/ShowMeta=false) and the share + row stays off (ShowShareRow=false). *@ @* The seven-knob band lives in its own full-width area below the back/lamp top row. @@ -73,43 +77,28 @@ else -
- @if (!string.IsNullOrEmpty(release.ImagePath)) - { - - } - else - { - - - - } -
+ @* 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. *@ + + + @* Release-mode share: copies the canonical /mixes/{entryKey} URL, not a single track (§3b). *@ + + + + @if (ViewModel.Track is not null) + { + + } + +
- - @if (hasGenre) - { -
- - @release.Genre - -
- } - @if (hasDate) - { -
- Released - @release.ReleaseDate!.Value.ToString("MMMM yyyy") -
- } -
- - @* Release-mode share: copies the canonical /mixes/{entryKey} URL, not a single track (§3b). *@ -
- -
-
@@ -118,6 +107,27 @@ else @code { protected override string PersistKey => "mix-detail"; + [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } + + // The hero now carries the play affordance (the scaffold's header is suppressed), so the + // play-toggle is wired here directly — mirroring SessionDetail. Toggle if this track is already + // active, otherwise start a fresh stream. + private async Task PlayTrack() + { + var track = ViewModel.Track; + if (track is null || PlayerService is null) return; + + var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id; + if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused)) + { + await PlayerService.TogglePlayPause(); + } + else + { + await PlayerService.SelectTrackStreaming(track); + } + } + // Lava-lamp knob-band visibility. Pure presentation over MixVisualizerControlState — gates whether // the seven-knob MixVisualizerControls is rendered into the TopContent band; toggling it touches no // control value or bridge push. The lava-lamp button's filled/outline glyph is driven off this flag. diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor.css b/DeepDrftPublic.Client/Pages/MixDetail.razor.css index 0f0b971..1a3c271 100644 --- a/DeepDrftPublic.Client/Pages/MixDetail.razor.css +++ b/DeepDrftPublic.Client/Pages/MixDetail.razor.css @@ -4,12 +4,14 @@ z-index: 1; } -/* Medium square cover — deliberately smaller than the 360px cut cover so the - waveform backdrop keeps room. The placeholder/art MudPaper fills this frame. */ -.mix-detail-cover { +/* Mix's per-page hero variance: a centered medium square, overriding ReleaseHeroOverlay's default + wide (16/10) aspect. The cover art is square album art, and a smaller square frees the surrounding + canvas for the lava-lamp visualizer. The mix-hero class lands on the overlay component's root + .release-hero
(a child Razor component's native output), so ::deep is required to reach it. */ +::deep .release-hero.mix-hero { aspect-ratio: 1 / 1; - max-width: 220px; - margin: 0 auto 2rem; - overflow: hidden; - box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent); + max-width: 600px; + min-height: 0; + max-height: none; + margin-inline: auto; } diff --git a/DeepDrftPublic.Client/Pages/SessionDetail.razor b/DeepDrftPublic.Client/Pages/SessionDetail.razor index 5862b9d..f48a90c 100644 --- a/DeepDrftPublic.Client/Pages/SessionDetail.razor +++ b/DeepDrftPublic.Client/Pages/SessionDetail.razor @@ -39,12 +39,6 @@ else var heroKey = release.SessionMetadata?.HeroImageEntryKey; // Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder. var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath; - // Show the cover thumbnail only when it differs from what the hero displays. When there is no - // dedicated hero, the hero already falls back to release.ImagePath — rendering the cover too - // would duplicate the same image. - var showCover = !string.IsNullOrEmpty(release.ImagePath) && release.ImagePath != heroImage; - var hasGenre = release.Genre is not null; - var hasDate = release.ReleaseDate is not null; @@ -52,70 +46,27 @@ else ← All sessions - @* The hero is the positioning context for every overlay row; the gradient shim and the - top/bottom overlays are absolutely positioned children of this wrapper. *@ -
- @if (!string.IsNullOrEmpty(heroImage)) - { - - } - else - { - - - - } - - @* Darkening shim so overlaid text/controls stay legible over any image. *@ -
- - @* Top overlay: secondary details (genre, release date) and the share affordance. *@ -
- - @if (hasGenre) - { - - @release.Genre - - } - @if (hasDate) - { -
- Released - @release.ReleaseDate!.Value.ToString("MMMM yyyy") -
- } -
+ @* 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. *@ + + @* Release-mode share: copies the canonical /sessions/{entryKey} URL, not a single track (§3b). *@ -
- -
-
- - @* Bottom overlay: cover thumbnail, title/artist, and the play affordance in one row. *@ -
- - @if (showCover) - { -
- -
- } -
-
@release.Title
-
@release.Artist
-
- @if (ViewModel.Track is not null) - { -
- -
- } -
-
-
+ + + + @if (ViewModel.Track is not null) + { + + } + +
} diff --git a/DeepDrftPublic.Client/Pages/SessionDetail.razor.css b/DeepDrftPublic.Client/Pages/SessionDetail.razor.css index 9bbe881..53b2c50 100644 --- a/DeepDrftPublic.Client/Pages/SessionDetail.razor.css +++ b/DeepDrftPublic.Client/Pages/SessionDetail.razor.css @@ -1,174 +1,8 @@ /* Session detail is hero-dominant: a large background image with the detail components overlaid - directly on top, themed to match the NowPlaying glassmorphic family. The page widens to the + directly on top (the overlay composition lives in ReleaseHeroOverlay). The page widens to the Large container (set in markup) rather than the shared 760px detail column. */ ::deep .session-detail-page { padding-top: 2rem; padding-bottom: 4rem; } - -/* Positioning context for every overlay. A tall, dominant frame rather than the 16:9 strip. */ -.session-hero { - position: relative; - width: 100%; - aspect-ratio: 16 / 10; - max-height: 70vh; - min-height: 420px; - margin-top: 1rem; - overflow: hidden; - border-radius: 8px; - box-shadow: 0 12px 40px color-mix(in srgb, var(--mud-palette-text-secondary) 22%, transparent); -} - -/* session-hero-img / session-hero-placeholder ride on MudPaper (child Razor component); - the class lands on MudPaper's native .mud-paper output, so ::deep pierces the component boundary. */ -::deep .session-hero-img { - position: absolute; - inset: 0; - background-size: cover; - background-position: center; - background-repeat: no-repeat; -} - -::deep .session-hero-placeholder { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background-color: var(--mud-palette-surface); -} - -::deep .session-hero-placeholder .mud-icon-root { - font-size: 120px; -} - -/* Darkening gradient shim: stronger at the bottom (under the title/play row) and lighter toward - the middle, with a top darken so the genre/share overlay stays legible too. */ -.session-hero-shim { - position: absolute; - inset: 0; - pointer-events: none; - background: - linear-gradient(to bottom, - rgba(0, 0, 0, 0.55) 0%, - rgba(0, 0, 0, 0.15) 28%, - rgba(0, 0, 0, 0.15) 55%, - rgba(0, 0, 0, 0.75) 100%); -} - -/* --- Top overlay: genre / date / share --- */ -.session-hero-top { - position: absolute; - top: 0; - left: 0; - right: 0; - z-index: 2; - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - padding: 1.25rem 1.5rem; -} - -.session-overlay-date { - display: flex; - flex-direction: column; - gap: 0.1rem; -} - -.session-overlay-label { - font-family: var(--deepdrft-font-mono); - font-size: 0.6rem; - letter-spacing: 0.25em; - text-transform: uppercase; - color: var(--deepdrft-green-accent); -} - -.session-overlay-value { - font-family: var(--deepdrft-font-body); - font-size: 0.8rem; - color: var(--deepdrft-white); -} - -/* Genre chip themed to the glassmorphic NowPlaying surface. The class lands on MudChip's native - .mud-chip output, so ::deep is required to reach it. */ -::deep .session-overlay-chip.mud-chip { - background: rgba(250, 250, 248, 0.06); - border: 1px solid rgba(250, 250, 248, 0.12); - backdrop-filter: blur(8px); - color: var(--deepdrft-white); - font-family: var(--deepdrft-font-mono); - letter-spacing: 0.12em; -} - -/* --- Bottom overlay: cover thumb / title / play --- */ -.session-hero-bottom { - position: absolute; - bottom: 0; - left: 0; - right: 0; - z-index: 2; - padding: 1.5rem; -} - -.session-cover-thumb { - flex: 0 0 auto; - width: 96px; - height: 96px; - overflow: hidden; - border: 1px solid rgba(250, 250, 248, 0.12); - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); -} - -.session-hero-titles { - flex: 1 1 auto; - min-width: 0; -} - -.session-overlay-title { - font-family: var(--deepdrft-font-display); - font-size: clamp(1.75rem, 4vw, 2.75rem); - font-weight: 400; - line-height: 1.1; - color: var(--deepdrft-white); - text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); -} - -.session-overlay-artist { - font-family: var(--deepdrft-font-body); - font-size: 0.85rem; - letter-spacing: 0.08em; - color: rgba(250, 250, 248, 0.7); - margin-top: 0.35rem; -} - -.session-hero-play { - flex: 0 0 auto; -} - -/* The play affordance and share button sit over a dark image — force their icon glyphs to the - light theme color regardless of MudBlazor's Secondary palette. Both PlayStateIcon and - SharePopover render MudIconButton / MudProgressCircular internals, so ::deep is required. */ -::deep .session-hero-play .mud-icon-button, -::deep .session-hero-play .mud-progress-circular, -::deep .session-hero-share .mud-icon-button { - color: var(--deepdrft-white); -} - -@media (max-width: 599.98px) { - .session-hero { - aspect-ratio: 3 / 4; - min-height: 380px; - } - - /* session-hero-bottom-row rides on MudStack's native output div, so ::deep is required. */ - ::deep .session-hero-bottom-row { - flex-wrap: wrap; - } - - .session-cover-thumb { - width: 72px; - height: 72px; - } -}