feat(mix): lava-lamp popover with RadialKnob controls + wider Mix detail body (P10 W4)
This commit is contained in:
@@ -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">
|
||||
← @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">
|
||||
← @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>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
|
||||
[Parameter] public RenderFragment? Hero { get; set; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. 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>
|
||||
""";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user