feat(mix): lava-lamp popover with RadialKnob controls + wider Mix detail body (P10 W4)

This commit is contained in:
daniel-c-harvey
2026-06-16 00:19:47 -04:00
parent 26d7a05ba4
commit e59271aa00
7 changed files with 148 additions and 78 deletions
@@ -2,71 +2,71 @@
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@inject MixVisualizerControlState ControlState @inject MixVisualizerControlState ControlState
@* The Mix visualizer controls row (Phase 10, Wave 2). Four continuous sliders — resolution, @* The Mix visualizer controls (Phase 10, Wave 4). Four continuous RadialKnobs — resolution,
bubblyness, detach, color-shift speed — placed above the mix details and below the back button. bubblyness, detach, color-shift speed — laid out in a row. This component lives inside the
This component owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState lava-lamp popover on the Mix detail page (Wave 4 moved it out of the always-visible TopContent row).
and raises its Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event It owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState and raises its
and pushes the affected uniform to the WebGL module. That keeps the JS module handle single-owned Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event and pushes the
by the bridge and this component purely presentational. None of these is a seek surface (spec §D). *@ affected uniform to the WebGL module. That keeps the JS module handle single-owned by the bridge and
this component purely presentational. None of these is a seek surface (spec §D).
RadialKnob has no icon slot (its Label renders as SVG text), so each control's Material icon rides
beside its knob as an adjacent MudIcon caption (spec §7e). HoldValue stays false so the knobs are
live — ValueChanged fires continuously during drag, exactly as the sliders fired before, preserving
the "visibly and continuously affects its target" feel and the Changed/NotifyChanged seam. *@
<div class="mix-visualizer-controls"> <div class="mix-visualizer-controls">
<div class="mix-visualizer-control"> @* RadialKnob exposes no aria-label/attribute-capture and is out of scope to modify, so the
accessible name rides on the wrapping group div instead (a plain element accepts it). *@
<div class="mix-visualizer-control" role="group" aria-label="Resolution (visible time-span)">
<RadialKnob Value="@ResolutionFraction"
ValueChanged="@OnResolutionChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ZoomIn" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.ZoomIn" Size="Size.Small" Class="mix-visualizer-control-icon" />
<MudSlider T="double"
Value="@ResolutionFraction"
ValueChanged="@OnResolutionChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Resolution (visible time-span)" />
</div> </div>
<div class="mix-visualizer-control"> <div class="mix-visualizer-control" role="group" aria-label="Bubblyness">
<RadialKnob Value="@ControlState.Bubblyness"
ValueChanged="@OnBubblynessChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="mix-visualizer-control-icon" />
<MudSlider T="double"
Value="@ControlState.Bubblyness"
ValueChanged="@OnBubblynessChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Bubblyness" />
</div> </div>
<div class="mix-visualizer-control"> <div class="mix-visualizer-control" role="group" aria-label="Detach (unleash the lava lamp)">
<RadialKnob Value="@ControlState.Detach"
ValueChanged="@OnDetachChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Air" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.Air" Size="Size.Small" Class="mix-visualizer-control-icon" />
<MudSlider T="double"
Value="@ControlState.Detach"
ValueChanged="@OnDetachChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Detach (unleash the lava lamp)" />
</div> </div>
<div class="mix-visualizer-control"> <div class="mix-visualizer-control" role="group" aria-label="Color-shift speed">
<RadialKnob Value="@ControlState.ColorShiftSpeed"
ValueChanged="@OnColorShiftSpeedChanged"
Min="0"
Max="1"
Step="0.001"
Size="72"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="mix-visualizer-control-icon" /> <MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="mix-visualizer-control-icon" />
<MudSlider T="double"
Value="@ControlState.ColorShiftSpeed"
ValueChanged="@OnColorShiftSpeedChanged"
Min="0"
Max="1"
Step="0.001"
Size="Size.Small"
Color="Color.Primary"
aria-label="Color-shift speed" />
</div> </div>
</div> </div>
@code { @code {
// Resolution rides the log mapping (slider fraction [0,1] ↔ visible seconds); the other three are // Resolution rides the log mapping (knob fraction [0,1] ↔ visible seconds); the other three are
// already normalized [0,1] and bind to their state properties directly. // already normalized [0,1] and bind to their state properties directly.
private double ResolutionFraction => MixZoomMapping.SecondsToFraction(ControlState.VisibleSeconds); private double ResolutionFraction => MixZoomMapping.SecondsToFraction(ControlState.VisibleSeconds);
@@ -1,34 +1,25 @@
/* The controls row sits in the mix-detail foreground, below the back button and above the masthead. /* The controls live inside the lava-lamp popover (Wave 4). Four knob+icon stacks laid out in a row.
A horizontal row of four icon+slider controls. On narrow viewports it wraps to keep all four On a narrow viewport the row wraps to 2×2 so all four stay reachable (spec §7d: none may drop). */
present (spec §3b: wrap is the chosen mobile behaviour — none may drop). */
.mix-visualizer-controls { .mix-visualizer-controls {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: flex-start;
gap: 0.75rem 1.5rem; justify-content: center;
margin: 0.5rem 0 1.5rem; gap: 1rem 1.25rem;
padding: 0.25rem;
} }
/* One control: a compact label icon followed by the slider. The slider gets a fixed-ish track width /* One control: a RadialKnob with its Material icon as a caption underneath. RadialKnob has no icon
so the four read as a tidy row rather than stretching unevenly. */ slot, so the icon rides adjacent (spec §7e). Center the pair so the four read as a tidy row. */
.mix-visualizer-control { .mix-visualizer-control {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.35rem;
flex: 1 1 180px;
min-width: 160px;
max-width: 260px;
} }
.mix-visualizer-control-icon { /* The caption icon is a MudIcon (a Razor component), so Blazor CSS isolation does not stamp the scope
flex: 0 0 auto; attribute onto its element — reach it with ::deep. */
.mix-visualizer-control ::deep .mix-visualizer-control-icon {
opacity: 0.7; opacity: 0.7;
} }
/* MudSlider renders a Razor component, so its root is reached with ::deep (a bare scoped selector
would not be stamped onto the child component's element). Let the slider fill the remaining width
of its control so the icon+slider pair lays out cleanly. */
.mix-visualizer-control ::deep .mud-slider {
flex: 1 1 auto;
margin: 0;
}
@@ -7,9 +7,15 @@
<div class="deepdrft-track-detail-container"> <div class="deepdrft-track-detail-container">
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back"> @* Back link top-left, optional medium action top-right, on one SpaceBetween row. The action slot
&larr; @BackLabel stays null for media that don't supply it (Track/Session), so they render the back link alone. *@
</MudLink> <MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; @BackLabel
</MudLink>
@TopRightAction
</MudStack>
@TopContent @TopContent
@@ -31,6 +31,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// </summary> /// </summary>
[Parameter] public RenderFragment? TopContent { get; set; } [Parameter] public RenderFragment? TopContent { get; set; }
/// <summary>
/// Optional action rendered at the top-right of the container, on the same SpaceBetween row as the
/// back link (back link left, action right). The Mix detail page uses it for the lava-lamp
/// visualizer-settings button; other media leave it null and render the back link alone.
/// </summary>
[Parameter] public RenderFragment? TopRightAction { get; set; }
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary> /// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
[Parameter] public RenderFragment? Hero { get; set; } [Parameter] public RenderFragment? Hero { get; set; }
+37 -6
View File
@@ -40,18 +40,40 @@ else
<MixWaveformVisualizer ReleaseId="@release.Id" TrackId="@ViewModel.Track?.Id" /> <MixWaveformVisualizer ReleaseId="@release.Id" TrackId="@ViewModel.Track?.Id" />
<div class="mix-detail-foreground"> <div class="mix-detail-foreground">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
<ReleaseDetailScaffold Title="@release.Title" <ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist" Artist="@release.Artist"
Track="@ViewModel.Track" Track="@ViewModel.Track"
BackHref="/mixes" BackHref="/mixes"
BackLabel="All mixes" BackLabel="All mixes"
ShowMeta="@(hasGenre || hasDate)"> ShowMeta="@(hasGenre || hasDate)">
<TopContent> <TopRightAction>
@* The four visualizer controls — resolution, bubblyness, detach, color-shift speed — @* Lava-lamp button top-right, across from the back link. Toggles a popover holding the
in a row below the back button and above the masthead (spec §3). They mutate the four visualizer knobs (spec §7c/§7d). The controls themselves are unchanged — they
shared MixVisualizerControlState; the backdrop bridge above pushes the uniforms. *@ mutate the shared MixVisualizerControlState; the backdrop bridge pushes the uniforms.
<MixVisualizerControls /> The popover only progressively-discloses them off the always-visible row. *@
</TopContent> <MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@DDIcons.LavaLamp"
Size="Size.Large"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@ToggleSettings"
aria-label="Visualizer settings" />
</MudTooltip>
@* Outside-click close via MudOverlay (the SharePopover idiom). A knob drag never lands
on this overlay — the knob's own global capture overlay is a child of the popover
content above it — so dragging a knob does not dismiss the popover. *@
<MudOverlay Visible="@_settingsOpen" OnClick="@CloseSettings" AutoClose="true" />
<MudPopover Open="@_settingsOpen"
Fixed="false"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
Class="mix-visualizer-popover">
<MixVisualizerControls />
</MudPopover>
</TopRightAction>
<Hero> <Hero>
<div class="mix-detail-cover"> <div class="mix-detail-cover">
@if (!string.IsNullOrEmpty(release.ImagePath)) @if (!string.IsNullOrEmpty(release.ImagePath))
@@ -85,9 +107,18 @@ else
} }
</MetaContent> </MetaContent>
</ReleaseDetailScaffold> </ReleaseDetailScaffold>
</MudContainer>
</div> </div>
} }
@code { @code {
protected override string PersistKey => "mix-detail"; protected override string PersistKey => "mix-detail";
// Lava-lamp settings popover open state. Pure presentation over MixVisualizerControlState — the
// popover discloses the four knobs; toggling it touches no control value or bridge push.
private bool _settingsOpen;
private void ToggleSettings() => _settingsOpen = !_settingsOpen;
private void CloseSettings() => _settingsOpen = false;
} }
@@ -281,6 +281,17 @@ h2, h3, h4, h5, h6,
padding: 3rem 1.5rem 4rem; padding: 3rem 1.5rem 4rem;
} }
/* Mix detail widens its body to the Sessions detail width (MudContainer Large, ~1280px) by hosting the
scaffold inside a MudContainer Large and neutralizing the scaffold's own 760px cap for that instance.
Both classes are global, so a plain descendant selector reaches the scaffold div without ::deep. The
horizontal gutter is dropped here because the wrapping MudContainer supplies its own. Mix-scoped, so
Track detail (which also uses .deepdrft-track-detail-container) stays at 760px. */
.mix-detail-container .deepdrft-track-detail-container {
max-width: none;
padding-left: 0;
padding-right: 0;
}
.deepdrft-track-detail-back { .deepdrft-track-detail-back {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -353,6 +364,15 @@ h2, h3, h4, h5, h6,
max-width: 360px; max-width: 360px;
} }
/* The lava-lamp visualizer-settings popover (Wave 4). Holds the four RadialKnobs in a row; sized so the
four read clearly with comfortable padding, wrapping to 2×2 on narrow viewports (the inner
.mix-visualizer-controls owns the flex-wrap). MudPopover renders into the popover-provider portal at
the document root, so this is a global class — not component-scoped. */
.mix-visualizer-popover {
padding: 0.75rem;
max-width: 360px;
}
/* Monospace snippet so the iframe markup stays legible inside the readonly field. */ /* Monospace snippet so the iframe markup stays legible inside the readonly field. */
.deepdrft-share-embed-field { .deepdrft-share-embed-field {
flex: 1 1 auto; flex: 1 1 auto;
+15
View File
@@ -22,4 +22,19 @@ public static class DDIcons
<ellipse cx="12" cy="12" rx=".7" ry="1.5" fill="#FFF8E1"/> <ellipse cx="12" cy="12" rx=".7" ry="1.5" fill="#FFF8E1"/>
</svg> </svg>
"""; """;
/// <summary>
/// Lava lamp - the Mix visualizer settings glyph. Tapered base, tall rounded glass vessel, a cap,
/// and three suspended blobs at varied heights. The silhouette is currentColor so it themes with
/// its context (Color.Secondary tint, light/dark). The blobs carry a warm two-tone accent à la the
/// gas-lamp flame so the lamp reads as "lit" at icon size; the silhouette still tints with context.
/// </summary>
public const string LavaLamp = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 1h6l-1.2 3h-3.6zM7.5 21l1.7-15h5.6l1.7 15zM6 21h12v2H6z"/>
<ellipse cx="12" cy="17.5" rx="2.6" ry="2.2" fill="#FF9800"/>
<ellipse cx="10.4" cy="12.5" rx="1.5" ry="1.7" fill="#FFB74D"/>
<ellipse cx="13.3" cy="9.5" rx="1.1" ry="1.3" fill="#FFCA28"/>
</svg>
""";
} }