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
|
@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
|
||||||
← @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">
|
||||||
|
← @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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user