Phase 10 reframe R4: seven-knob inline visualizer controls, always-on lava loop, filled lava-lamp icon

This commit is contained in:
daniel-c-harvey
2026-06-16 17:17:14 -04:00
parent fe28573b68
commit 41ac7a5a93
9 changed files with 518 additions and 343 deletions
@@ -2,96 +2,144 @@
@using DeepDrftPublic.Client.Services
@inject MixVisualizerControlState ControlState
@* 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).
@* The Mix visualizer controls (lava reframe Wave R4). SEVEN continuous RadialKnobs — scroll speed,
gradient rotation speed, lava gravity, lava heat, blob density, collision strength, waveform width —
each its own dedicated control with a Material-icon caption (no more R2 temp-remapping: no knob
caption misrepresents its function). The bar lives INLINE in the mix-detail controls area and
ANIMATES open/closed in place via CSS transition off the @Expanded flag — it reads as the controls
collapsing/expanding, NOT a floating popover/drawer (§7b).
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).
affected dial 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 (read-only contract §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. *@
RadialKnob has no icon slot (its Label renders as SVG text) and no aria attribute-capture, so each
control's Material icon rides beside its knob as an adjacent MudIcon caption and the accessible name
rides on the wrapping group div (§7d). HoldValue stays false so the knobs are live — ValueChanged
fires continuously during drag, preserving the Changed/NotifyChanged seam.
<div class="mix-visualizer-controls">
Aesthetic: the bar matches the session-hero NowPlaying overlay (§7e) — a translucent dark glass
surface with overlay-label captions and Color.Secondary accents, so it reads as of-a-piece with the
hero rather than a generic MudBlazor panel. *@
@* 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).
R2 TEMP: this knob is repurposed from resolution/zoom to WAVEFORM WIDTH for in-browser lava
testing (scroll speed isn't critical for evaluating the lava). The on-screen icon still reads
ZoomIn; R4 redraws the controls and restores the resolution mapping. *@
<div class="mix-visualizer-control" role="group" aria-label="Waveform width (R2 temp: on the resolution knob)">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
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" />
<div class="mix-visualizer-controls-bar @(Expanded ? "is-expanded" : "")" aria-hidden="@(!Expanded)">
<div class="mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
<RadialKnob Value="@ControlState.ScrollSpeed"
ValueChanged="@OnScrollSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<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" />
<div class="mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
<RadialKnob Value="@ControlState.GradientRotationSpeed"
ValueChanged="@OnGradientRotationSpeedChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Lava gravity">
<RadialKnob Value="@ControlState.LavaGravity"
ValueChanged="@OnLavaGravityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Lava heat">
<RadialKnob Value="@ControlState.LavaHeat"
ValueChanged="@OnLavaHeatChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Blob density and size">
<RadialKnob Value="@ControlState.BlobDensity"
ValueChanged="@OnBlobDensityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<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" />
<div class="mix-visualizer-control" role="group" aria-label="Collision strength">
<RadialKnob Value="@ControlState.CollisionStrength"
ValueChanged="@OnCollisionStrengthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<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" />
<div class="mix-visualizer-control" role="group" aria-label="Waveform width">
<RadialKnob Value="@ControlState.WaveformWidth"
ValueChanged="@OnWaveformWidthChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Secondary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
</div>
@code {
// R2 TEMP: the resolution knob is repurposed to WAVEFORM WIDTH (already normalized [0,1], binds
// directly). R4 restores the log zoom mapping (MixZoomMapping) and gives width its own knob.
/// <summary>
/// Whether the knob bar is expanded. Owned by the host page (the lava-lamp toggle button flips it);
/// drives the CSS open/close transition. When false the bar collapses to zero size in place.
/// </summary>
[Parameter] public bool Expanded { get; set; }
// Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and
// pushes the affected dial. All values are already normalized [0,1]; the bridge maps scroll speed
// to a visible time-span and routes the rest straight to the lava/colour dials.
private void OnScrollSpeedChanged(double value)
{
ControlState.ScrollSpeed = value;
ControlState.NotifyChanged();
}
private void OnGradientRotationSpeedChanged(double value)
{
ControlState.GradientRotationSpeed = value;
ControlState.NotifyChanged();
}
private void OnLavaGravityChanged(double value)
{
ControlState.LavaGravity = value;
ControlState.NotifyChanged();
}
private void OnLavaHeatChanged(double value)
{
ControlState.LavaHeat = value;
ControlState.NotifyChanged();
}
private void OnBlobDensityChanged(double value)
{
ControlState.BlobDensity = value;
ControlState.NotifyChanged();
}
private void OnCollisionStrengthChanged(double value)
{
ControlState.CollisionStrength = value;
ControlState.NotifyChanged();
}
private void OnWaveformWidthChanged(double value)
{
ControlState.WaveformWidth = value;
ControlState.NotifyChanged();
}
private void OnBubblynessChanged(double value)
{
ControlState.Bubblyness = value;
ControlState.NotifyChanged();
}
private void OnDetachChanged(double value)
{
ControlState.Detach = value;
ControlState.NotifyChanged();
}
private void OnColorShiftSpeedChanged(double value)
{
ControlState.ColorShiftSpeed = value;
ControlState.NotifyChanged();
}
}
@@ -1,25 +1,65 @@
/* 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 {
/* The seven-knob bar lives INLINE in the mix-detail controls area and animates open/closed in place
(lava reframe §7b) — NOT a popover or drawer. Collapsed, it has zero size and is fully transparent;
the @Expanded flag (mirrored to the .is-expanded class) transitions it open. We animate max-width +
max-height + opacity + transform together so the bar reads as the controls growing in place rather
than a panel popping in. Closed state is pointer-events:none + visibility:hidden so collapsed knobs
are not focusable or hit-testable. */
.mix-visualizer-controls-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 1rem 1.25rem;
padding: 0.25rem;
justify-content: flex-end;
gap: 0.85rem 1rem;
/* Collapsed: zero footprint, slid up toward the toggle, transparent. */
max-width: 0;
max-height: 0;
opacity: 0;
transform: translateY(-8px);
overflow: hidden;
visibility: hidden;
pointer-events: none;
/* NowPlaying glass surface (§7e): translucent dark shim, soft blur, rounded, secondary-tinted
hairline — matches the session-hero overlay family. Padding animates in with the size. */
padding: 0;
border-radius: 10px;
background: rgba(13, 27, 42, 0.55);
border: 1px solid color-mix(in srgb, var(--mud-palette-secondary) 22%, transparent);
backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
transition:
max-width 0.32s cubic-bezier(0.22, 0.61, 0.36, 1),
max-height 0.32s cubic-bezier(0.22, 0.61, 0.36, 1),
opacity 0.24s ease,
padding 0.32s cubic-bezier(0.22, 0.61, 0.36, 1),
transform 0.32s cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* 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-controls-bar.is-expanded {
max-width: 640px;
max-height: 420px;
opacity: 1;
transform: translateY(0);
visibility: visible;
pointer-events: auto;
padding: 0.85rem 1rem;
}
/* One control: a RadialKnob with its Material icon caption underneath. RadialKnob has no icon slot, so
the icon rides adjacent (§7d). Center the pair so the seven read as a tidy bar. */
.mix-visualizer-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
gap: 0.3rem;
}
/* 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. */
attribute onto its element — reach it with ::deep. Tinted to the secondary accent and the
overlay-label opacity so it matches the session-hero NowPlaying captions (§7e). */
.mix-visualizer-control ::deep .mix-visualizer-control-icon {
opacity: 0.7;
color: var(--mud-palette-secondary);
opacity: 0.78;
}
@@ -58,9 +58,9 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
// `[MixVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
// which upstream link is broken when the ribbon stays blank — set false once confirmed healthy.
// ON for the Phase 10 reframe Wave R2 lava test (matches the JS-side DEBUG in
// MixVisualizer.ts). Daniel evaluates the physics in-browser; flip back to false at
// reframe close along with the JS flag.
// ON for the Phase 10 reframe Wave R4 controls test (matches the JS-side DEBUG in
// MixVisualizer.ts). Daniel evaluates the seven-knob bar + pause behavior in-browser; flip back to
// false at reframe close along with the JS flag.
private static readonly bool Debug = true;
private const string Tag = "[MixVisualizer]";
@@ -176,9 +176,9 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
return;
}
// Seed the module with the current state now that it exists. All four control values
// Seed the module with the current state now that it exists. All seven control values
// come from the shared (session-persisted) state, so a mix opened mid-session seeds the
// module with the slider positions the listener left them at.
// module with the knob positions the listener left them at.
await PushControlsAsync();
await PushDatumAsync();
await PushPlaybackAsync();
@@ -190,10 +190,10 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
await PushThemeIfChangedAsync();
}
// The controls row mutated a slider on the shared state and raised Changed. Push all four control
// values (cheap scalar interop). In the Phase 10 reframe Wave R2, three of them are re-routed to
// the lava physics inside the JS handle (setBubblyness→gravity, setDetach→heat,
// setColorShiftSpeed→collision) — see MixVisualizer.ts; the bridge contract is unchanged.
// The controls bar mutated a knob on the shared state and raised Changed. Push all seven control
// values (cheap scalar interop). Each control now drives its own dedicated dial in the JS handle
// (lava reframe Wave R4) — scroll speed → visible-time-span, plus the six lava/colour dials; see
// PushControlsAsync. The bridge stays the sole owner of the JS module handle.
private void OnControlStateChanged() => InvokeAsync(async () =>
{
await PushControlsAsync();
@@ -202,20 +202,29 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary>
/// Push the control values to the module from the shared state. Used to seed on first render and
/// to re-push when the controls row signals a change. In the Phase 10 reframe Wave R2 the four
/// live controls are routed to the lava physics by the JS handle (see MixVisualizer.ts):
/// Bubblyness→gravity, Detach→heat, ColorShiftSpeed→collision, and the repurposed resolution knob
/// (WaveformWidth)→waveform width. VisibleSeconds is still seeded once via setZoom so the window
/// holds at its default; the controls row no longer mutates it this wave. Bridge contract unchanged.
/// Push the seven control values to the module from the shared state. Used to seed on first render
/// and to re-push when the controls bar signals a change (lava reframe Wave R4). Each value is its
/// own dedicated dial now — no more R2 temp-remapping:
/// <list type="bullet">
/// <item>scroll speed [0,1] is mapped to a visible time-span via <see cref="MixZoomMapping"/> and
/// pushed through <c>setScrollSpeed</c> (higher speed → tighter window → faster scroll);</item>
/// <item>gradient rotation speed → <c>setGradientRotationSpeed</c> (inert until Wave R3);</item>
/// <item>gravity / heat / blob density / collision strength → their dedicated lava-physics dials;</item>
/// <item>waveform width → the ribbon-extent uniform.</item>
/// </list>
/// </summary>
private async Task PushControlsAsync()
{
if (_handle is null) return;
await _handle.InvokeVoidAsync("setZoom", ControlState.VisibleSeconds);
await _handle.InvokeVoidAsync("setBubblyness", ControlState.Bubblyness);
await _handle.InvokeVoidAsync("setDetach", ControlState.Detach);
await _handle.InvokeVoidAsync("setColorShiftSpeed", ControlState.ColorShiftSpeed);
// Scroll speed is a normalized [0,1] axis; map it to the visible time-span the renderer scrolls
// through. The log map keeps the even-to-the-hand feel the old zoom slider had.
var visibleSeconds = MixZoomMapping.FractionToSeconds(ControlState.ScrollSpeed);
await _handle.InvokeVoidAsync("setScrollSpeed", visibleSeconds);
await _handle.InvokeVoidAsync("setGradientRotationSpeed", ControlState.GradientRotationSpeed);
await _handle.InvokeVoidAsync("setLavaGravity", ControlState.LavaGravity);
await _handle.InvokeVoidAsync("setLavaHeat", ControlState.LavaHeat);
await _handle.InvokeVoidAsync("setBlobDensity", ControlState.BlobDensity);
await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength);
await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
}
+23 -30
View File
@@ -48,31 +48,25 @@ else
BackLabel="All mixes"
ShowMeta="@(hasGenre || hasDate)">
<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>
@* Lava-lamp button top-right, across from the back link. Toggles the INLINE seven-knob
control bar that animates open/closed in place below it (lava reframe §7b) — not a
popover or drawer. The icon swaps to its FILLED variant while the bar is expanded
(§7f / Part B). The controls bar mutates the shared MixVisualizerControlState; the
backdrop bridge pushes the dials. A knob drag does not collapse the bar — the toggle
only flips on this button's click, never on a drag landing in the bar. *@
<div class="mix-visualizer-controls-anchor">
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_controlsExpanded ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="Size.Large"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@ToggleSettings"
aria-label="Visualizer settings"
aria-expanded="@_controlsExpanded" />
</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>
<MixVisualizerControls Expanded="@_controlsExpanded" />
</div>
</TopRightAction>
<Hero>
<div class="mix-detail-cover">
@@ -114,11 +108,10 @@ else
@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;
// Lava-lamp inline knob-bar expanded state. Pure presentation over MixVisualizerControlState — the
// bar discloses the seven knobs and animates open/closed; toggling it touches no control value or
// bridge push. The lava-lamp button's filled/outline glyph is driven off this same flag.
private bool _controlsExpanded;
private void ToggleSettings() => _settingsOpen = !_settingsOpen;
private void CloseSettings() => _settingsOpen = false;
private void ToggleSettings() => _controlsExpanded = !_controlsExpanded;
}
@@ -4,6 +4,28 @@
z-index: 1;
}
/* The lava-lamp toggle + its inline knob-bar. The anchor stacks the button over the bar and lets the
bar grow downward/leftward in place when expanded, without shoving the masthead. The bar itself is
absolutely positioned under the button (top-right of the detail body), so its open/close animation
reads as the controls growing out from the icon rather than reflowing the page (lava reframe §7b). */
.mix-visualizer-controls-anchor {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-end;
}
/* MixVisualizerControls renders the .mix-visualizer-controls-bar as its single root. It is a child
Razor component, so its scope attribute is not stamped here — reach the bar with ::deep to position
it as a floating-but-inline element anchored to the toggle's bottom-right. */
.mix-visualizer-controls-anchor ::deep .mix-visualizer-controls-bar {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 3;
transform-origin: top right;
}
/* 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 {
@@ -1,89 +1,103 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Holds the Mix visualizer's four continuous-control positions for the lifetime of the WASM app
/// Holds the Mix visualizer's seven continuous-control positions for the lifetime of the WASM app
/// instance. Scoped in DI, so it lives across SPA navigations within one listening session — open a
/// second mix and the sliders keep where you left them — but a fresh page load (F5) constructs a new
/// second mix and the knobs keep where you left them — but a fresh page load (F5) constructs a new
/// instance, resetting to defaults. That matches the spec's "persist within session, reset on fresh
/// load" without any cookie/localStorage round-trip (see mix-visualizer-webgl-renderer §3c).
/// load" without any cookie/localStorage round-trip (lava reframe §7c).
///
/// One state object, four properties — not four sibling holders (Daniel's decided shape, spec §3c).
/// Each C#-side default mirrors a TS-side tuning anchor in MixVisualizer.ts; keep the two in sync, as
/// the existing <c>DefaultVisibleSeconds</c> / <c>DEFAULT_VISIBLE_SECONDS</c> pair does.
/// One state object, seven properties — not seven sibling holders, and (deliberately) NO constructor
/// parameters: this is a plain scoped value holder, so widening it from four to seven properties adds
/// fields + defaults only and never forces a consumer constructor to grow. Each C#-side default mirrors
/// a TS-side tuning anchor in MixVisualizer.ts; keep the two in sync, as the existing
/// <c>DefaultVisibleSeconds</c> / <c>DEFAULT_VISIBLE_SECONDS</c> pair does.
///
/// <para>
/// <see cref="Changed"/> is the decoupling seam between the controls row and the visualizer bridge.
/// <see cref="Changed"/> is the decoupling seam between the controls bar and the visualizer bridge.
/// The controls component (a sibling of the backdrop in the page tree) only mutates this shared state
/// and raises <see cref="Changed"/>; the bridge component (<c>MixWaveformVisualizer</c>) subscribes
/// and pushes the affected uniform to the JS module. This keeps the JS module handle single-owned by
/// the bridge — no handle sharing, no service-locator, no cross-component interop.
/// and pushes the affected uniform/dial to the JS module. This keeps the JS module handle single-owned
/// by the bridge — no handle sharing, no service-locator, no cross-component interop.
/// </para>
/// </summary>
public sealed class MixVisualizerControlState
{
/// <summary>
/// Default opening window. Mirrors <c>DEFAULT_VISIBLE_SECONDS</c> in MixVisualizer.ts; keep the
/// two in sync (the TS owns the rendering anchors, this owns the C#-side session default).
/// </summary>
public const double DefaultVisibleSeconds = 10.0;
// R2 TEMP (Phase 10 reframe Wave R2): the FOUR controls below are re-routed to the new
// lava physics for Daniel's in-browser test — the JS handle setters map them as:
// Bubblyness → lava GRAVITY, Detach → lava HEAT, ColorShiftSpeed → COLLISION STRENGTH,
// Resolution (VisibleSeconds knob) → WAVEFORM WIDTH (see MixVisualizerControls.razor).
// The defaults are tuned to Daniel's sweet spot (~20% gravity, ~100% heat). Wave R4
// replaces this with the proper seven-knob set + its own typed properties. Keep these
// mirrored to the DEFAULT_* anchors in MixVisualizer.ts, as the existing sync discipline.
// ── The seven control defaults (lava reframe §7a). Each mirrors a DEFAULT_* anchor in
// MixVisualizer.ts; keep the two in sync, as the existing default-sync discipline requires.
// Feel-anchors only — Daniel tunes on screen; the ~20% gravity / ~100% heat pair is his stated
// sweet spot (§4c).
/// <summary>
/// Default GRAVITY dial (R2 temp; was bulge). Mirrors <c>DEFAULT_BUBBLYNESS</c> in MixVisualizer.ts.
/// Normalized [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast. Tuned to Daniel's
/// ~20% sweet spot so the wax is buoyant-dominated and flows.
/// Default scroll-speed dial. Mirrors <c>DEFAULT_SCROLL_SPEED</c> in MixVisualizer.ts. Normalized
/// [0,1] → mapped to the visible time-span via <see cref="MixZoomMapping"/> (0 = slow/wide window,
/// 1 = fast/tight window). Opens mid-range.
/// </summary>
public const double DefaultBubblyness = 0.2;
public const double DefaultScrollSpeed = 0.5;
/// <summary>
/// Default HEAT dial (R2 temp; was detach). Mirrors <c>DEFAULT_DETACH</c> in MixVisualizer.ts.
/// Normalized [0,1]; 0 = wax rests at the bottom (collision-only), 1 = lots of small turbulent
/// bubbles. Tuned to Daniel's ~100% sweet spot.
/// Default gradient-rotation-speed dial. Mirrors <c>DEFAULT_GRADIENT_ROTATION_SPEED</c> in
/// MixVisualizer.ts. Normalized [0,1] → slow→fast anchor-rotation. INERT until Wave R3 builds the
/// OKLab three-colour gradient that consumes it.
/// </summary>
public const double DefaultDetach = 1.0;
public const double DefaultGradientRotationSpeed = 0.3;
/// <summary>
/// Default COLLISION-STRENGTH dial (R2 temp; was color-shift). Mirrors
/// <c>DEFAULT_COLOR_SHIFT_SPEED</c> in MixVisualizer.ts. Normalized [0,1]; 0 = soft mush,
/// 1 = hard elastic throw.
/// Default lava-gravity dial. Mirrors <c>DEFAULT_LAVA_GRAVITY</c> in MixVisualizer.ts. Normalized
/// [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast. Tuned to Daniel's ~20% sweet spot.
/// </summary>
public const double DefaultColorShiftSpeed = 0.5;
public const double DefaultLavaGravity = 0.2;
/// <summary>
/// Default WAVEFORM-WIDTH dial (R2 temp; routed to the resolution/zoom knob this wave). Mirrors
/// <c>DEFAULT_WAVEFORM_WIDTH</c> in MixVisualizer.ts. Normalized [0,1]; 1 = full ribbon width
/// (prior look), lower narrows the band so the lava gets more room. Opens at full width.
/// Default lava-heat dial. Mirrors <c>DEFAULT_LAVA_HEAT</c> in MixVisualizer.ts. Normalized [0,1];
/// 0 = wax rests at the bottom (collision-only), 1 = many small turbulent rising bubbles. Tuned to
/// Daniel's ~100% sweet spot.
/// </summary>
public const double DefaultWaveformWidth = 1.0;
/// <summary>Visible time-span in seconds (the resolution/zoom control). Reused as-is from 8.K.</summary>
public double VisibleSeconds { get; set; } = DefaultVisibleSeconds;
/// <summary>Bulge amount, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double Bubblyness { get; set; } = DefaultBubblyness;
/// <summary>Lava-lamp detachment, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double Detach { get; set; } = DefaultDetach;
/// <summary>Gradient-morph rate, normalized [0,1]. Inert until Wave 3 consumes the uniform.</summary>
public double ColorShiftSpeed { get; set; } = DefaultColorShiftSpeed;
public const double DefaultLavaHeat = 1.0;
/// <summary>
/// Waveform width, normalized [0,1]. R2 TEMP: routed to the resolution/zoom knob for in-browser
/// testing (Wave R4 gives it its own knob and restores the resolution knob to VisibleSeconds).
/// Default blob-density dial. Mirrors <c>DEFAULT_BLOB_DENSITY</c> in MixVisualizer.ts. Normalized
/// [0,1]; 0 = a few large lazy blobs, 1 = many smaller active blobs.
/// </summary>
public const double DefaultBlobDensity = 0.4;
/// <summary>
/// Default collision-strength dial. Mirrors <c>DEFAULT_COLLISION_STRENGTH</c> in MixVisualizer.ts.
/// Normalized [0,1]; 0 = soft mush, 1 = hard elastic up-and-out throw.
/// </summary>
public const double DefaultCollisionStrength = 0.5;
/// <summary>
/// Default waveform-width dial. Mirrors <c>DEFAULT_WAVEFORM_WIDTH</c> in MixVisualizer.ts.
/// Normalized [0,1]; 1 = full ribbon width, lower narrows the band so the lava gets more room.
/// </summary>
public const double DefaultWaveformWidth = 0.6;
/// <summary>Apparent bottom-to-top scroll rate, normalized [0,1]. Bridge maps it to a visible
/// time-span via <see cref="MixZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
/// <summary>Gradient anchor-rotation rate, normalized [0,1]. Inert until Wave R3 consumes it.</summary>
public double GradientRotationSpeed { get; set; } = DefaultGradientRotationSpeed;
/// <summary>Downward force on the wax, normalized [0,1].</summary>
public double LavaGravity { get; set; } = DefaultLavaGravity;
/// <summary>Energy into the lava system, normalized [0,1]. 0 = rest-at-bottom, 1 = roiling.</summary>
public double LavaHeat { get; set; } = DefaultLavaHeat;
/// <summary>Amount of wax (blob count/size), normalized [0,1].</summary>
public double BlobDensity { get; set; } = DefaultBlobDensity;
/// <summary>Collision hardness, normalized [0,1]. 0 = soft mush, 1 = hard up-and-out throw.</summary>
public double CollisionStrength { get; set; } = DefaultCollisionStrength;
/// <summary>Waveform-band horizontal extent, normalized [0,1]. Narrowing clears room for the lava.</summary>
public double WaveformWidth { get; set; } = DefaultWaveformWidth;
/// <summary>
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
/// affected uniform(s). Mutators set the property then raise this; subscribers re-read the values.
/// affected dial(s). Mutators set the property then raise this; subscribers re-read the values.
/// </summary>
public event Action? Changed;
+191 -151
View File
@@ -27,11 +27,17 @@
* gradient is Wave R3. No glass, no screen-space noise (removed in R1).
*
* The Blazor component owns the canvas element and the inputs (datum, playback,
* zoom, theme, the control dials); this module owns the requestAnimationFrame loop,
* scroll speed, theme, the control dials); this module owns the requestAnimationFrame loop,
* the physics step, and all the GL math. The component drives it through the handle
* returned by `create`. The handle SHAPE is unchanged from Phase 10 — the three
* effect setters are temporarily re-routed to the lava params for this wave (see
* their definitions); Wave R4 gives them proper names + a six-knob UI.
* returned by `create`. As of Wave R4 the handle exposes SEVEN dedicated control setters
* (setScrollSpeed / setGradientRotationSpeed / setLavaGravity / setLavaHeat / setBlobDensity /
* setCollisionStrength / setWaveformWidth) — the R2 temp-remapping is gone. Gradient rotation is
* stored but inert until Wave R3 builds the OKLab gradient.
*
* PAUSE BEHAVIOR (Wave R4 Part C): the rAF loop runs CONTINUOUSLY while the component is alive and
* the tab is visible — it is no longer gated on playback. The fluid sim keeps convecting while audio
* is paused; only the waveform scroll/playhead freezes (effectivePlayhead() holds the static pushed
* position while !isPlaying). The loop stops only on tab-hidden (visibilitychange) and dispose.
*/
// ── Tuning anchors (see spec §B). These are the load-bearing constants. ──────────
@@ -51,47 +57,51 @@ export const DEFAULT_VISIBLE_SECONDS = 10;
// ── Control tuning anchors. These mirror the C#-side defaults in ──────────────────
// MixVisualizerControlState.cs — keep the two in sync, exactly as the
// DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All three are
// normalized [0,1].
// DEFAULT_VISIBLE_SECONDS / DefaultVisibleSeconds pair is kept in sync. All seven are
// normalized [0,1] (scroll speed is mapped to a visible time-span on the C# side before it
// reaches setScrollSpeed; it arrives here already in seconds).
//
// R2 TEMPORARY RE-WIRING (Wave R4 replaces this with the proper seven-knob set):
// the FOUR existing control knobs are re-purposed to drive the new lava physics so
// Daniel can feel the system in-browser this wave. The knob NAMES on screen still say
// the old thing; the SETTERS below route them to the new physics params. Mapping:
// • "Detach" knob (Air icon) → lava HEAT (setDetach)
// • "Bubblyness" knob (BubbleChart) → lava GRAVITY (setBubblyness)
// • "Color-shift" knob (Palette) → COLLISION STRENGTH (setColorShiftSpeed)
// • "Resolution" knob (ZoomIn) → WAVEFORM WIDTH (setWaveformWidth) ← R2 NEW
// The resolution/zoom knob is repurposed because scroll speed is not critical for
// evaluating the lava: the controls row no longer mutates VisibleSeconds, so the window
// holds at DEFAULT_VISIBLE_SECONDS (setZoom is still seeded once with that default).
// Blob DENSITY has no live knob this wave; it sits at
// DEFAULT_BLOB_DENSITY (R4 adds it). The defaults below are tuned to Daniel's sweet spot
// (~20% gravity, ~100% heat) so the lava looks ALIVE and fluid on open — he then tunes
// on screen. ALL of this temp wiring is removed in R4 for the real knob set.
// Wave R4 — the SEVEN dedicated controls. Each knob drives its own physics/colour dial; the
// R2 temporary remapping (where four knobs masqueraded as other things) is gone. Mapping:
// • Scroll speed → visible time-span / scroll rate (setScrollSpeed)
// • Gradient rotation speed → colour anchor-rotation rate (setGradientRotationSpeed) — INERT
// until Wave R3 builds the OKLab gradient that consumes it
// • Lava gravity → gravity dial (setLavaGravity)
// • Lava heat → heat dial (setLavaHeat)
// • Blob density/size → density dial (setBlobDensity)
// • Collision strength → collision hardness dial (setCollisionStrength)
// • Waveform width → ribbon half-width uniform (setWaveformWidth)
// The defaults below are Daniel's feel-anchors (~20% gravity, ~100% heat sweet spot, §4c) — he
// tunes on screen from here.
/** Default GRAVITY dial (was bulge). Mirrors C# DefaultBubblyness.
* Tuned to Daniel's R2 sweet spot (~20% gravity): the wax is buoyant-dominated and flows. */
export const DEFAULT_BUBBLYNESS = 0.2;
/** Default GRAVITY dial. Mirrors C# DefaultLavaGravity.
* Tuned to Daniel's sweet spot (~20% gravity): the wax is buoyant-dominated and flows. */
export const DEFAULT_LAVA_GRAVITY = 0.2;
/** Default HEAT dial (was detach). Mirrors C# DefaultDetach.
* Tuned to Daniel's R2 sweet spot (~100% heat): lots of small, lively, turbulent bubbles. */
export const DEFAULT_DETACH = 1.0;
/** Default HEAT dial. Mirrors C# DefaultLavaHeat.
* Tuned to Daniel's sweet spot (~100% heat): lots of small, lively, turbulent bubbles. */
export const DEFAULT_LAVA_HEAT = 1.0;
/** Default COLLISION-STRENGTH dial (was color-shift). Mirrors C# DefaultColorShiftSpeed.
/** Default COLLISION-STRENGTH dial. Mirrors C# DefaultCollisionStrength.
* Mid soft↔hard: elastic enough to throw bubbles up+out, not so hard it reads as marbles. */
export const DEFAULT_COLOR_SHIFT_SPEED = 0.5;
export const DEFAULT_COLLISION_STRENGTH = 0.5;
/** Default blob density (no live knob this wave; R4 exposes it). 0 = few large lazy blobs, 1 = many small. */
/** Default blob density. Mirrors C# DefaultBlobDensity. 0 = few large lazy blobs, 1 = many small. */
export const DEFAULT_BLOB_DENSITY = 0.4;
/**
* Default WAVEFORM-WIDTH dial (R2 TEMP — mapped to the resolution/zoom knob for in-browser
* test; R4 gives it its own knob). 1 = full ribbon width (the prior behaviour); lower values
* narrow the waveform band so the lava fluid gets more room to move on loud songs. Mirrors C#
* DefaultWaveformWidth. Opens at full width so the default look matches the prior ribbon.
* Default GRADIENT-ROTATION-SPEED dial. Mirrors C# DefaultGradientRotationSpeed. Normalized
* [0,1] → slow→fast anchor rotation. INERT until Wave R3 builds the OKLab three-colour gradient
* that consumes it — stored and round-tripped through the handle so the knob persists, but it
* drives nothing this wave (the R2 flat placeholder fill ignores it).
*/
export const DEFAULT_WAVEFORM_WIDTH = 1.0;
export const DEFAULT_GRADIENT_ROTATION_SPEED = 0.3;
/**
* Default WAVEFORM-WIDTH dial. Mirrors C# DefaultWaveformWidth. 1 = full ribbon width; lower
* values narrow the waveform band so the lava fluid gets more room to move on loud songs.
*/
export const DEFAULT_WAVEFORM_WIDTH = 0.6;
/**
* Where the "now" line sits within the window, as a fraction from the top.
@@ -320,9 +330,9 @@ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
// while the runtime fix is being verified through the browser.
// NOTE: ON for this visual-iteration pass (Phase 10 W3 rework). Daniel tests in-browser;
// the resolved navy/moss RGB + FPS lines confirm the fixes. Flip back to false once the
// look is approved.
// NOTE: ON for the Phase 10 reframe Wave R4 controls pass. Daniel tests in-browser; the FPS lines
// (which should hold ~60 even while paused, confirming the continuous-loop power cost is acceptable)
// + the seven-dial lava line confirm the controls + pause fix. Flip back to false at reframe close.
const DEBUG = true;
const TAG = '[MixVisualizer]';
@@ -447,7 +457,8 @@ interface Playback {
* effectivePlayhead (see draw()), anchored on this value.
*/
positionSeconds: number;
/** Whether audio is actively playing — gates the rAF loop so a paused mix stays cool. */
/** Whether audio is actively playing. Gates whether the playhead ADVANCES (scroll) or HOLDS
* (freeze) — NOT whether the rAF loop runs (the loop is continuous now, Part C). */
isPlaying: boolean;
/**
* performance.now() (ms) captured when positionSeconds was pushed. The rAF loop
@@ -462,14 +473,19 @@ interface Playback {
export interface MixVisualizerHandle {
setDatum(samplesBase64: string, durationSeconds: number): void;
setPlayback(positionSeconds: number, isPlaying: boolean): void;
setZoom(visibleSeconds: number): void;
/** [0,1]. R2 TEMP: routes the "Bubblyness" knob to lava GRAVITY (R4 renames). */
setBubblyness(value: number): void;
/** [0,1]. R2 TEMP: routes the "Detach" knob to lava HEAT (R4 renames). */
setDetach(value: number): void;
/** [0,1]. R2 TEMP: routes the "Color-shift" knob to COLLISION STRENGTH (R4 renames). */
setColorShiftSpeed(value: number): void;
/** [0,1]. R2 TEMP: routes the "Resolution"/zoom knob to WAVEFORM WIDTH (R4 gives it its own knob). */
/** Visible time-span in seconds — the scroll-speed control, mapped from [0,1] on the C# side. */
setScrollSpeed(visibleSeconds: number): void;
/** [0,1]. Colour anchor-rotation rate. INERT until Wave R3 (stored + round-tripped only). */
setGradientRotationSpeed(value: number): void;
/** [0,1]. Downward force on the wax. */
setLavaGravity(value: number): void;
/** [0,1]. Energy into the lava system (0 = rest-at-bottom, 1 = roiling). */
setLavaHeat(value: number): void;
/** [0,1]. Amount of wax — blob count/size. */
setBlobDensity(value: number): void;
/** [0,1]. Collision hardness (0 = soft mush, 1 = hard up-and-out throw). */
setCollisionStrength(value: number): void;
/** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
setWaveformWidth(value: number): void;
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
refreshTheme(): void;
@@ -854,10 +870,12 @@ function noopHandle(): MixVisualizerHandle {
return {
setDatum() {},
setPlayback() {},
setZoom() {},
setBubblyness() {},
setDetach() {},
setColorShiftSpeed() {},
setScrollSpeed() {},
setGradientRotationSpeed() {},
setLavaGravity() {},
setLavaHeat() {},
setBlobDensity() {},
setCollisionStrength() {},
setWaveformWidth() {},
refreshTheme() {},
dispose() {},
@@ -937,14 +955,17 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() };
let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
// ── Lava physics control values (the R2 TEMP knob re-mapping — see the control-default
// consts at the top of this file). These are the dials the existing knobs feed, routed
// here by the handle setters. They drive the CPU physics step below, NOT a shader uniform.
let lavaHeat = DEFAULT_DETACH; // "Detach" knob → heat
let lavaGravity = DEFAULT_BUBBLYNESS; // "Bubblyness" knob → gravity
let collisionStrength = DEFAULT_COLOR_SHIFT_SPEED; // "Color-shift" knob → collision hardness
let blobDensity = DEFAULT_BLOB_DENSITY; // no live knob this wave (R4 adds it)
let waveformWidth = DEFAULT_WAVEFORM_WIDTH; // "Resolution" knob → ribbon width (R2 TEMP, R4 own knob)
// ── Lava physics control values (Wave R4 — each its own dedicated knob; see the control-default
// consts at the top of this file). These are the dials the seven knobs feed, routed here by the
// handle setters. The lava dials drive the CPU physics step below; waveformWidth is a shader
// uniform; gradientRotationSpeed is stored but INERT until Wave R3 builds the colour gradient.
let lavaHeat = DEFAULT_LAVA_HEAT;
let lavaGravity = DEFAULT_LAVA_GRAVITY;
let collisionStrength = DEFAULT_COLLISION_STRENGTH;
let blobDensity = DEFAULT_BLOB_DENSITY;
let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
// INERT until Wave R3 — held so the knob round-trips and persists; nothing reads it this wave.
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
/**
* The *authoritative* playhead for this instant: the last pushed position advanced
@@ -1374,11 +1395,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// Wall-clock anchor for the physics dt (separate from the playhead decay clock).
let lastPhysicsMs = performance.now();
// FPS diagnostic (verification aid for the smoothness fix — gated on DEBUG). Counts
// actual rAF callbacks and logs the rate ~once/sec while playing. This distinguishes
// the two failure modes: a rate near the display refresh (~60) with the playhead
// interpolated means motion is smooth; a rate near ~10 would mean the loop is gated
// to the playback pushes instead of free-running. Reset when the loop (re)starts.
// FPS diagnostic (verification aid — gated on DEBUG). Counts actual rAF callbacks and logs the
// rate ~once/sec while the loop runs (which is now continuously, playing or paused — Part C). A
// rate near the display refresh (~60) confirms the continuous loop holds frame rate; a paused-but-
// foregrounded lamp should still read ~60 (the cheap sim + one draw), confirming the power cost of
// running while paused is acceptable. Reset when the loop (re)starts.
let fpsFrameCount = 0;
let fpsWindowStartMs = 0;
@@ -1418,9 +1439,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
const nextCssWidth = box ? box.inlineSize : entry.contentRect.width;
const nextCssHeight = box ? box.blockSize : entry.contentRect.height;
applySize(nextCssWidth, nextCssHeight);
// While idle, draw one still frame reflecting the new size. While playing,
// the running loop will redraw on its next tick — no action needed.
if (!playback.isPlaying) redrawOnce();
// The continuous loop redraws on its next tick. Only force a still frame if the loop is
// stopped (tab hidden) so a resize while hidden is reflected when the tab returns.
if (rafId === null) redrawOnce();
});
resizeObserver.observe(canvas);
@@ -1517,20 +1538,25 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
}
}
// ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ────────────
// ── rAF loop lifecycle (lava reframe Part C: sim animates while paused; only scroll freezes).
//
// DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames
// are scheduled — the GPU is idle. The still slice stays correct via one-shot
// redraws triggered by the handle methods (setZoom/refreshTheme/setDatum) and
// by the ResizeObserver.
// DESIGN (changed in Wave R4): the loop runs whenever the component is ALIVE and the tab is
// VISIBLE — it is NO LONGER gated on playback.isPlaying. A real lava lamp keeps convecting
// regardless of the music, so the fluid sim (physics + render) keeps animating while audio is
// paused; only the waveform SCROLL / playhead freezes. That freeze falls straight out of
// effectivePlayhead(): while !isPlaying it returns the static last-pushed position, so the
// waveform holds at its paused row while the physics dt clock (lastPhysicsMs in draw()) keeps
// advancing the wax. Power-saving is preserved by stopping the loop on tab-hidden (visibilitychange)
// and on dispose — just not merely because audio paused. A foregrounded-but-paused lamp runs only
// the cheap CPU sim + one GL draw per frame, which holds 60 FPS comfortably.
//
// Smoothness (spec §2e / §5.4): the scroll must advance every animation frame, not
// step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating
// the playhead on the wall clock — each frame uploads renderedPlayhead() (= effectivePlayhead()
// + the decaying jitter-correction offset), which advances the last pushed position by real time
// elapsed since the push and blends out any accumulated timing error. (The separate uTimeSeconds
// monotonic clock drives the blob-radius wobble in the shader; the CPU physics uses its own
// wall-clock dt — neither drives the scroll, which is the playhead alone.)
// Smoothness (spec §2e / §5.4): while playing, the scroll must advance every animation frame, not
// step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating the playhead on
// the wall clock — each frame uploads renderedPlayhead() (= effectivePlayhead() + the decaying
// jitter-correction offset), which advances the last pushed position by real time elapsed since the
// push and blends out any accumulated timing error. (The separate uTimeSeconds monotonic clock
// drives the blob-radius wobble in the shader; the CPU physics uses its own wall-clock dt — neither
// drives the scroll, which is the playhead alone, and the playhead is frozen while paused.)
/** Draw one still frame immediately, without scheduling a new rAF. */
function redrawOnce(): void {
@@ -1565,15 +1591,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
}
/**
* The animation loop. Runs only while playing. Each frame draws the scrolling
* waveform at the wall-clock-interpolated playhead (effectivePlayhead, advancing
* smoothly between the ~10 Hz pushes), then reschedules itself — unless playback
* stopped since this frame was queued, in which case it draws one final still
* frame (already done above) and exits the loop.
*
* A backgrounded tab gets rAF throttled by the browser automatically; on top of
* that the loop does not run at all when paused, so a foregrounded-but-paused
* mix burns no frames (spec §E / §5.3).
* The animation loop. Runs continuously while the component is alive and the tab is visible
* (lava reframe Part C) — NOT gated on playback. Each frame advances the wax physics and draws.
* While playing, it draws at the wall-clock-interpolated playhead (effectivePlayhead, advancing
* smoothly between the ~10 Hz pushes); while paused, effectivePlayhead() holds the static pushed
* position so the waveform freezes in place while the lava keeps convecting. It reschedules itself
* every frame; the only things that stop it are dispose() and the tab going hidden (the
* visibilitychange handler calls stopLoop). A backgrounded tab also gets rAF throttled by the
* browser, and we stop the loop entirely when hidden, so a backgrounded lamp burns no frames.
*/
function frame(): void {
if (disposed) {
@@ -1618,21 +1643,36 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
}
}
if (playback.isPlaying) {
rafId = requestAnimationFrame(frame);
} else {
// Playback stopped between queue and now; final still frame drawn above.
rafId = null;
}
// Reschedule unconditionally — the loop runs continuously now (lava reframe Part C); it is
// stopped only by dispose() or the tab going hidden, never by audio pausing.
rafId = requestAnimationFrame(frame);
}
// ── Tab-visibility gating (lava reframe Part C power-saving). ────────────────────
// The loop runs continuously while alive, but a HIDDEN tab should not animate at all
// (the browser throttles rAF anyway, but we stop outright to be sure). On becoming
// visible again we restart the loop; startLoop re-bases the dt clocks so the wax
// doesn't lurch by the whole hidden gap on the first resumed frame.
function onVisibilityChange(): void {
if (disposed) return;
if (document.hidden) {
stopLoop();
} else {
startLoop();
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
// Read the initial size synchronously (one getBoundingClientRect at setup is
// fine — it is the ResizeObserver that must not measure per-frame), then draw a
// still frame so the canvas isn't blank before the first play command.
// fine — it is the ResizeObserver that must not measure per-frame), draw a still
// frame so the canvas isn't blank, then START the continuous loop (Part C: the lava
// animates from the moment the visualizer mounts, paused or playing) — unless the tab
// is already hidden, in which case the visibilitychange handler will start it later.
{
const rect = canvas.getBoundingClientRect();
applySize(rect.width, rect.height);
redrawOnce();
if (!document.hidden) startLoop();
}
/**
@@ -1722,9 +1762,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
datum = null;
}
datum = uploadDatum(samplesBase64, durationSeconds);
// New datum changes what is drawn — refresh the still slice immediately
// when idle. If playing, the running loop picks it up next frame.
if (!playback.isPlaying) redrawOnce();
// New datum changes what is drawn — the continuous loop picks it up next frame. Only force
// a still frame if the loop is stopped (tab hidden) so a datum that arrives while hidden is
// reflected the moment the tab becomes visible-and-draws.
if (rafId === null) redrawOnce();
},
setPlayback(positionSeconds: number, isPlaying: boolean): void {
@@ -1739,7 +1780,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// Anchor the pushed position to wall-clock NOW: the rAF loop interpolates
// forward from here each frame (effectivePlayhead), so the scroll advances
// smoothly between these ~10 Hz pushes.
// smoothly between these ~10 Hz pushes. While paused, effectivePlayhead()
// returns this static position, so the waveform freezes here (Part C) — the
// continuous loop keeps animating the lava, but the scroll holds.
playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() };
// Fold the re-anchor discontinuity into the correction offset so the rendered
@@ -1749,9 +1792,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// authoritative position. When pushes are regular the gap is ~0, so offset is
// ~0 and steady-state matches the prior hard-anchor behaviour exactly.
//
// Only smooth while continuously playing. On a play/pause edge or while idle
// Only smooth while continuously playing. On a play/pause edge or while paused
// we want the exact authoritative position, not a glide from a stale render:
// a resume should land on the real position, and a paused still frame must be
// a resume should land on the real position, and a paused frame must be
// truthful (read-only contract — never show a position the player isn't at).
if (isPlaying && wasPlaying) {
correctionOffset = renderedBefore - effectivePlayhead();
@@ -1759,78 +1802,75 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
correctionOffset = 0;
}
if (isPlaying && !wasPlaying) {
// Transition paused/stopped → playing: start the rAF loop.
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);
startLoop();
} else if (!isPlaying && wasPlaying) {
// Transition playing → paused/stopped: the in-flight frame draws the
// final still position and exits on its own (frame() checks
// playback.isPlaying before rescheduling). We do NOT stopLoop() here —
// that would cancel the in-flight frame before it draws, leaving a
// stale canvas. Let the frame run out.
// NOTE (Part C): we do NOT start/stop the rAF loop on the play/pause edge anymore — the
// loop runs continuously while the tab is visible so the lava keeps convecting when paused.
// The play-state only changes whether effectivePlayhead() advances (scroll) or holds
// (freeze); the loop itself is owned by setup + the visibilitychange handler + dispose.
if (isPlaying !== wasPlaying) {
debugLog(`playback ${isPlaying ? 'resumed' : 'paused'} — position ${positionSeconds.toFixed(2)}s; scroll ${isPlaying ? 'advancing' : 'frozen'}, lava keeps animating.`);
}
// isPlaying unchanged (position-only update): the running loop (if any)
// redraws next frame; nothing to do here.
},
setZoom(seconds: number): void {
// Clamp into the supported span so a stray value can't break the math.
// ── Wave R4 — the seven dedicated control setters. Each routes its value to the one dial it
// drives; no more R2 temp-remapping. The lava loop now runs continuously (see startLoop /
// the visibility handling), so a paused tweak is already picked up by the next frame — but we
// keep a redrawOnce() guard for the rare fully-stopped case (loop not running, e.g. tab
// hidden) so a tweak still lands a still frame when it resumes-and-draws.
// Scroll speed: arrives already mapped to a visible time-span (seconds) on the C# side. Clamp
// into the supported span so a stray value can't break the scroll math.
setScrollSpeed(seconds: number): void {
visibleSeconds = Math.min(MAX_VISIBLE_SECONDS, Math.max(MIN_VISIBLE_SECONDS, seconds));
// While playing, the running rAF loop uploads uVisibleSeconds next frame; while idle the
// loop is stopped (spec §E), so a zoom change must force one still frame here or the new
// span is uploaded only on the next unrelated redraw (theme/datum/resize) — i.e. never.
const idleRedraw = !playback.isPlaying;
debugLog(`setZoom — requested ${seconds.toFixed(3)}s, clamped ${visibleSeconds.toFixed(3)}s; idleRedraw=${idleRedraw} (isPlaying=${playback.isPlaying}).`);
if (idleRedraw) redrawOnce();
debugLog(`setScrollSpeed — visibleSeconds ${visibleSeconds.toFixed(3)}s.`);
if (rafId === null) redrawOnce();
},
// ── R2 TEMPORARY control re-wiring (Wave R4 replaces this with the proper six-knob
// set). The bridge still calls these three setters by their OLD names — the names are
// a Wave-2 artifact and are NOT worth a bridge/contract change just to rename for one
// wave. Each routes its [0,1] value to the lava-physics dial it now drives, so Daniel
// can FEEL heat/gravity/collision in-browser this wave. The on-screen knob captions
// still read the old labels (BubbleChart/Air/Palette) — R4 redraws the controls UI.
// setBubblyness ← "Bubblyness" knob → lava GRAVITY
// setDetach ← "Detach" knob → lava HEAT
// setColorShiftSpeed← "Color-shift" knob → COLLISION STRENGTH
// Idle redraw mirrors setZoom so a paused tweak still updates the still frame.
setBubblyness(value: number): void {
lavaGravity = Math.min(1, Math.max(0, value)); // R2 TEMP → gravity
debugLog(`setGravity (via setBubblyness) → ${lavaGravity.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
// Gradient rotation speed: INERT until Wave R3. Stored so the knob round-trips/persists; the
// R2 flat placeholder fill ignores it, so there is nothing to redraw.
setGradientRotationSpeed(value: number): void {
gradientRotationSpeed = Math.min(1, Math.max(0, value));
debugLog(`setGradientRotationSpeed → ${gradientRotationSpeed.toFixed(3)} (inert until R3).`);
},
setDetach(value: number): void {
lavaHeat = Math.min(1, Math.max(0, value)); // R2 TEMP → heat
debugLog(`setHeat (via setDetach)${lavaHeat.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
setLavaGravity(value: number): void {
lavaGravity = Math.min(1, Math.max(0, value));
debugLog(`setLavaGravity${lavaGravity.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setColorShiftSpeed(value: number): void {
collisionStrength = Math.min(1, Math.max(0, value)); // R2 TEMP → collision hardness
debugLog(`setCollisionStrength (via setColorShiftSpeed)${collisionStrength.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
setLavaHeat(value: number): void {
lavaHeat = Math.min(1, Math.max(0, value));
debugLog(`setLavaHeat${lavaHeat.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setBlobDensity(value: number): void {
blobDensity = Math.min(1, Math.max(0, value));
debugLog(`setBlobDensity → ${blobDensity.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
setCollisionStrength(value: number): void {
collisionStrength = Math.min(1, Math.max(0, value));
debugLog(`setCollisionStrength → ${collisionStrength.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
// R2 TEMP: the resolution/zoom knob is repurposed to the waveform-width param this wave
// (scroll speed isn't critical for evaluating the lava). The bridge calls this with the
// raw knob fraction [0,1]; 1 = full ribbon, lower narrows the band. R4 gives width its
// own knob and restores the resolution knob to setZoom.
setWaveformWidth(value: number): void {
waveformWidth = Math.min(1, Math.max(0, value));
debugLog(`setWaveformWidth (via resolution knob)${waveformWidth.toFixed(3)}.`);
if (!playback.isPlaying) redrawOnce();
debugLog(`setWaveformWidth → ${waveformWidth.toFixed(3)}.`);
if (rafId === null) redrawOnce();
},
refreshTheme(): void {
theme = readTheme();
if (!playback.isPlaying) redrawOnce();
if (rafId === null) redrawOnce();
},
dispose(): void {
disposed = true;
stopLoop();
document.removeEventListener('visibilitychange', onVisibilityChange);
resizeObserver.disconnect();
// Release all GL resources so nothing leaks on navigation (spec §5.11).
if (datum) {
@@ -364,15 +364,6 @@ 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;
+18
View File
@@ -33,4 +33,22 @@ public static class DDIcons
public const string LavaLamp = """
<g transform="scale(0.48)"><path fill="currentColor" d="M21.664062 1C20.224267 1 18.975662 2.037745 18.712891 3.453125 A 1.0001 1.0001 0 0 0 18.710938 3.4667969L14.138672 30.160156C14.020414 30.800846 14.113312 31.464453 14.40625 32.048828L15.382812 34L16.105469 35.447266L18.626953 40.488281C18.777181 40.788737 18.768091 41.142169 18.601562 41.433594L18.587891 41.455078L16.261719 46.052734L16.287109 46.007812C15.549312 47.299441 16.535819 49 18.023438 49L31.976562 49C33.464182 49 34.451628 47.299149 33.712891 46.007812L33.738281 46.052734L31.412109 41.457031L31.400391 41.435547C31.23378 41.143097 31.223214 40.789893 31.373047 40.490234L33.894531 35.447266L35.595703 32.048828L35.595703 32.046875C35.887939 31.462932 35.98142 30.800197 35.863281 30.160156L31.289062 3.4667969 A 1.0001 1.0001 0 0 0 31.287109 3.453125C31.024385 2.0377451 29.777686 1 28.337891 1L21.664062 1 z M 21.664062 3L28.337891 3C28.826095 3 29.231083 3.3377394 29.320312 3.8183594L29.865234 7L20.136719 7L20.681641 3.8164062C20.772037 3.3374317 21.176907 3 21.664062 3 z M 19.792969 9L23.804688 9C23.611137 9.9884222 23.227461 11.137087 22.425781 12.203125C21.573716 13.336163 20.392353 14.295227 18.802734 14.777344L19.792969 9 z M 25.816406 9L30.207031 9L33.337891 27.267578C33.337891 27.267578 33.335938 27.267578 33.335938 27.267578C32.564245 27.077767 31.81046 27.092539 31.123047 27.007812C27.275457 26.533917 25.885411 25.052928 24.652344 23.5C24.03581 22.723536 23.491366 21.900529 22.708984 21.205078C21.926603 20.509628 20.846926 20 19.5 20C18.908925 20 18.354767 20.12187 17.851562 20.333984L18.435547 16.921875C20.926866 16.437188 22.800171 15.030943 24.023438 13.404297C25.113022 11.955415 25.619737 10.408026 25.816406 9 z M 27 15C26.083334 15 25.268559 15.379756 24.751953 15.960938C24.235347 16.542119 24 17.277778 24 18C24 18.722222 24.235347 19.457881 24.751953 20.039062C25.268559 20.620244 26.083334 21 27 21C27.916666 21 28.731441 20.620244 29.248047 20.039062C29.764653 19.457881 30 18.722222 30 18C30 17.277778 29.764653 16.542119 29.248047 15.960938C28.731441 15.379755 27.916666 15 27 15 z M 27 17C27.416666 17 27.601893 17.120244 27.751953 17.289062C27.902014 17.457881 28 17.722222 28 18C28 18.277778 27.90201 18.542119 27.751953 18.710938C27.601893 18.879756 27.416666 19 27 19C26.583334 19 26.398107 18.879756 26.248047 18.710938C26.097986 18.542119 26 18.277778 26 18C26 17.722222 26.097986 17.457881 26.248047 17.289062C26.398107 17.120244 26.583334 17 27 17 z M 19.5 22C20.403074 22 20.863866 22.239669 21.380859 22.699219C21.897853 23.158768 22.41297 23.896605 23.085938 24.744141C24.43187 26.439213 26.540543 28.458083 30.876953 28.992188C31.964402 29.126219 32.872051 29.16627 33.273438 29.351562C33.474131 29.444212 33.563548 29.518411 33.669922 29.722656C33.773897 29.922296 33.874264 30.298504 33.878906 30.917969C33.861094 30.99784 33.843686 31.078426 33.806641 31.152344L32.382812 34L27.298828 34C27.159652 33.665616 27.044757 33.303105 26.992188 32.876953C26.701429 30.51628 25.299037 28.945081 23.773438 28.091797C22.247838 27.238512 20.623079 27 19.5 27C18.146065 27 17.224609 25.858489 17.224609 24.388672C17.224609 22.918855 18.068811 22 19.5 22 z M 16.582031 27.744141C17.325464 28.504516 18.325661 29 19.5 29C20.309921 29 21.681725 29.214175 22.796875 29.837891C23.912025 30.461606 24.79457 31.39172 25.007812 33.123047C25.04516 33.425802 25.107037 33.716105 25.181641 34L17.619141 34L16.195312 31.152344 A 1.0001 1.0001 0 0 0 16.193359 31.152344C16.096299 30.958719 16.065727 30.738748 16.105469 30.523438 A 1.0001 1.0001 0 0 0 16.107422 30.509766L16.582031 27.744141 z M 18.617188 36L31.382812 36L29.583984 39.595703C29.135813 40.492045 29.166715 41.556232 29.662109 42.425781L31.964844 46.978516L31.976562 47L18.023438 47L18.035156 46.978516L20.337891 42.425781C20.835362 41.555206 20.863783 40.491247 20.416016 39.595703L18.617188 36 z"/></g>
""";
/// <summary>
/// Lava lamp — the FILLED variant, shown when the Mix visualizer controls are EXPANDED. Same
/// SVG-Repo glyph as <see cref="LavaLamp"/> (scale(0.48), inner markup only — no outer
/// &lt;svg&gt; wrapper) but the BASE and the WAX BUBBLES are filled solid with theme accents
/// instead of reading as an outline, so the toggle button visibly switches to an "active" state.
///
/// The vessel silhouette stays <c>currentColor</c> so it themes for free (Color.Secondary,
/// light/dark) exactly as the existing icons do. The two accent fills genuinely must be literal
/// hex (an SVG fill in a raw-string const cannot resolve <c>var(--mud-palette-*)</c>): the wax fluid
/// is NAVY and the bubbles are MOSS. These mirror DeepDrftShared.Client.Common.DeepDrftPalettes —
/// navy = PaletteLight.Primary (#17283f), moss = PaletteLight.Secondary (#3D7A68). Same
/// currentColor-where-possible + commented-literal-where-not discipline the gas-lamp flame uses.
/// If the palette navy/moss tokens change, update these two literals to match.
/// </summary>
public const string LavaLampFilled = """
<g transform="scale(0.48)"><path fill="currentColor" d="M21.664062 1C20.224267 1 18.975662 2.037745 18.712891 3.453125 A 1.0001 1.0001 0 0 0 18.710938 3.4667969L14.138672 30.160156C14.020414 30.800846 14.113312 31.464453 14.40625 32.048828L15.382812 34L16.105469 35.447266L18.626953 40.488281C18.777181 40.788737 18.768091 41.142169 18.601562 41.433594L18.587891 41.455078L16.261719 46.052734L16.287109 46.007812C15.549312 47.299441 16.535819 49 18.023438 49L31.976562 49C33.464182 49 34.451628 47.299149 33.712891 46.007812L33.738281 46.052734L31.412109 41.457031L31.400391 41.435547C31.23378 41.143097 31.223214 40.789893 31.373047 40.490234L33.894531 35.447266L35.595703 32.048828L35.595703 32.046875C35.887939 31.462932 35.98142 30.800197 35.863281 30.160156L31.289062 3.4667969 A 1.0001 1.0001 0 0 0 31.287109 3.453125C31.024385 2.0377451 29.777686 1 28.337891 1L21.664062 1 z M 21.664062 3L28.337891 3C28.826095 3 29.231083 3.3377394 29.320312 3.8183594L29.865234 7L20.136719 7L20.681641 3.8164062C20.772037 3.3374317 21.176907 3 21.664062 3 z M 18.617188 36L31.382812 36L29.583984 39.595703C29.135813 40.492045 29.166715 41.556232 29.662109 42.425781L31.964844 46.978516L31.976562 47L18.023438 47L18.035156 46.978516L20.337891 42.425781C20.835362 41.555206 20.863783 40.491247 20.416016 39.595703L18.617188 36 z"/><path fill="#17283f" d="M18.71 9.4L15.6 27.5C15.55 28 15.62 28.5 15.8 28.95L17.0 31.0L18.7 35.0L31.3 35.0L33.0 31.0L34.2 28.95C34.38 28.5 34.45 28 34.4 27.5L31.29 9.4L18.71 9.4 z"/><circle fill="#3D7A68" cx="22" cy="13" r="3"/><circle fill="#3D7A68" cx="28" cy="18.5" r="4"/><circle fill="#3D7A68" cx="22.5" cy="25" r="3.6"/><circle fill="#3D7A68" cx="30" cy="29.5" r="2.6"/></g>
""";
}