Merge p10-w4-popover-knobs into dev (Phase 10 Wave 4: lava-lamp popover, RadialKnob controls, wider Mix detail body)

This commit is contained in:
daniel-c-harvey
2026-06-16 00:48:12 -04:00
7 changed files with 148 additions and 78 deletions
@@ -2,71 +2,71 @@
@using DeepDrftPublic.Client.Services
@inject MixVisualizerControlState ControlState
@* The Mix visualizer controls row (Phase 10, Wave 2). Four continuous sliders — resolution,
bubblyness, detach, color-shift speed — placed above the mix details and below the back button.
This component owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState
and raises its Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event
and pushes the 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). *@
@* The Mix visualizer controls (Phase 10, Wave 4). Four continuous RadialKnobs — resolution,
bubblyness, detach, color-shift speed — laid out in a row. This component lives inside the
lava-lamp popover on the Mix detail page (Wave 4 moved it out of the always-visible TopContent row).
It owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState and raises its
Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event and pushes the
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-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" />
<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 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" />
<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 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" />
<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 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" />
<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>
@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.
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.
A horizontal row of four icon+slider controls. On narrow viewports it wraps to keep all four
present (spec §3b: wrap is the chosen mobile behaviour — none may drop). */
/* The controls live inside the lava-lamp popover (Wave 4). Four knob+icon stacks laid out in a row.
On a narrow viewport the row wraps to 2×2 so all four stay reachable (spec §7d: none may drop). */
.mix-visualizer-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.5rem;
margin: 0.5rem 0 1.5rem;
align-items: flex-start;
justify-content: center;
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
so the four read as a tidy row rather than stretching unevenly. */
/* One control: a RadialKnob with its Material icon as a caption underneath. RadialKnob has no icon
slot, so the icon rides adjacent (spec §7e). Center the pair so the four read as a tidy row. */
.mix-visualizer-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex: 1 1 180px;
min-width: 160px;
max-width: 260px;
gap: 0.35rem;
}
.mix-visualizer-control-icon {
flex: 0 0 auto;
/* The caption icon is a MudIcon (a Razor component), so Blazor CSS isolation does not stamp the scope
attribute onto its element — reach it with ::deep. */
.mix-visualizer-control ::deep .mix-visualizer-control-icon {
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">
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; @BackLabel
</MudLink>
@* Back link top-left, optional medium action top-right, on one SpaceBetween row. The action slot
stays null for media that don't supply it (Track/Session), so they render the back link alone. *@
<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
@@ -31,6 +31,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// </summary>
[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>
/// Optional replacement for the header region (masthead + play affordance). When null, the
/// scaffold renders its default masthead+play row wired to <see cref="PlayTrack"/>. A composer
+37 -6
View File
@@ -40,18 +40,40 @@ else
<MixWaveformVisualizer ReleaseId="@release.Id" TrackId="@ViewModel.Track?.Id" />
<div class="mix-detail-foreground">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@ViewModel.Track"
BackHref="/mixes"
BackLabel="All mixes"
ShowMeta="@(hasGenre || hasDate)">
<TopContent>
@* The four visualizer controls — resolution, bubblyness, detach, color-shift speed —
in a row below the back button and above the masthead (spec §3). They mutate the
shared MixVisualizerControlState; the backdrop bridge above pushes the uniforms. *@
<MixVisualizerControls />
</TopContent>
<TopRightAction>
@* Lava-lamp button top-right, across from the back link. Toggles a popover holding the
four visualizer knobs (spec §7c/§7d). The controls themselves are unchanged — they
mutate the shared MixVisualizerControlState; the backdrop bridge pushes the uniforms.
The popover only progressively-discloses them off the always-visible row. *@
<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>
<div class="mix-detail-cover">
@if (!string.IsNullOrEmpty(release.ImagePath))
@@ -85,9 +107,18 @@ else
}
</MetaContent>
</ReleaseDetailScaffold>
</MudContainer>
</div>
}
@code {
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;
}
/* 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 {
display: inline-flex;
align-items: center;
@@ -353,6 +364,15 @@ h2, h3, h4, h5, h6,
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. */
.deepdrft-share-embed-field {
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"/>
</svg>
""";
/// <summary>
/// Lava lamp - the Mix visualizer settings glyph. Inner path/shape markup for MudBlazor's Icon= slot
/// (no outer &lt;svg&gt; wrapper — MudBlazor supplies that). 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; coordinates are in the 24×24 space
/// that matches MudBlazor's viewBox="0 0 24 24" wrapper.
/// </summary>
public const string LavaLamp = """
<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"/>
""";
}