feat(now-playing): mount real waveform visualizer in NowPlaying card (mode C) + Fill container-sizing mode

Replace the 20 synthetic bars with a contained WaveformVisualizer driven by the live player, pointed at the current track; add a Fill mode (CSS-only, defaults off) sizing the canvas to its container; place the lava-lamp icon to popover on the card.
This commit is contained in:
daniel-c-harvey
2026-06-17 12:15:49 -04:00
parent 9009f2c8cf
commit 05486a61af
5 changed files with 58 additions and 50 deletions
@@ -8,36 +8,22 @@
: "Select a track to begin")
</div>
@if (Player?.IsLoaded == true)
{
<div class="waveform-bars">
@* 20 bars - approximate the wireframe's varied animation timings *@
<div class="waveform-bar" style="--h-lo:6px;--h-hi:28px;--dur:0.7s;height:14px"></div>
<div class="waveform-bar" style="--h-lo:10px;--h-hi:36px;--dur:0.9s;height:28px"></div>
<div class="waveform-bar" style="--h-lo:4px;--h-hi:20px;--dur:0.65s;height:10px"></div>
<div class="waveform-bar" style="--h-lo:12px;--h-hi:40px;--dur:1.1s;height:36px"></div>
<div class="waveform-bar" style="--h-lo:6px;--h-hi:26px;--dur:0.8s;height:18px"></div>
<div class="waveform-bar" style="--h-lo:8px;--h-hi:32px;--dur:0.75s;height:24px"></div>
<div class="waveform-bar" style="--h-lo:4px;--h-hi:18px;--dur:0.95s;height:8px"></div>
<div class="waveform-bar" style="--h-lo:14px;--h-hi:42px;--dur:1.2s;height:32px"></div>
<div class="waveform-bar" style="--h-lo:6px;--h-hi:22px;--dur:0.68s;height:16px"></div>
<div class="waveform-bar" style="--h-lo:10px;--h-hi:38px;--dur:0.88s;height:30px"></div>
<div class="waveform-bar" style="--h-lo:4px;--h-hi:16px;--dur:0.72s;height:6px"></div>
<div class="waveform-bar" style="--h-lo:8px;--h-hi:30px;--dur:1.0s;height:20px"></div>
<div class="waveform-bar" style="--h-lo:12px;--h-hi:36px;--dur:0.85s;height:26px"></div>
<div class="waveform-bar" style="--h-lo:6px;--h-hi:24px;--dur:0.9s;height:14px"></div>
<div class="waveform-bar" style="--h-lo:10px;--h-hi:34px;--dur:0.78s;height:22px"></div>
<div class="waveform-bar" style="--h-lo:4px;--h-hi:20px;--dur:1.05s;height:12px"></div>
<div class="waveform-bar" style="--h-lo:14px;--h-hi:44px;--dur:0.92s;height:38px"></div>
<div class="waveform-bar" style="--h-lo:6px;--h-hi:26px;--dur:0.7s;height:18px"></div>
<div class="waveform-bar" style="--h-lo:8px;--h-hi:32px;--dur:0.82s;height:22px"></div>
<div class="waveform-bar" style="--h-lo:4px;--h-hi:18px;--dur:1.15s;height:10px"></div>
@* Mode C (§3f, §6c): the real waveform visualizer, contained to this card and driven by the live
cascaded player. Fill="true" sizes the canvas to this positioned box instead of the viewport.
The bridge follows whatever is playing — keyed on the current track via ReleaseEntryKey/TrackId/
TrackEntryKey — so it scrolls to the real signal and sits at-rest when nothing plays. Read-only:
the card visualizes, it never seeks. The lava-lamp popover sits in the corner (full parity, §8e). *@
<div class="np-visualizer">
<WaveformVisualizer Fill="true"
ReleaseEntryKey="@(Player?.CurrentTrack?.Release?.EntryKey ?? string.Empty)"
TrackId="@Player?.CurrentTrack?.Id"
TrackEntryKey="@Player?.CurrentTrack?.EntryKey" />
<div class="np-visualizer-controls">
<WaveformVisualizerControlPopover IconSize="Size.Small"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopLeft" />
</div>
}
else
{
<div class="waveform-placeholder"></div>
}
</div>
</div>
@@ -1,8 +1,3 @@
@keyframes wave-dance {
from { height: var(--h-lo, 4px); }
to { height: var(--h-hi, 20px); }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
@@ -51,22 +46,25 @@
letter-spacing: 0.08em;
}
.waveform-bars {
display: flex;
align-items: center;
gap: 3px;
/* Contained visualizer region (Phase 12 mode C). The visualizer mounts with Fill="true", whose
layer is `position: absolute; inset: 0` — so this box must be a positioned, sized ancestor for the
canvas to fill. `overflow: hidden` keeps the lava inside the card's rounded chrome. The height is the
card's bounded live readout — taller than the old synthetic-bar strip so the real waveform reads, but
still compact for a home-page hero panel. */
.np-visualizer {
position: relative;
height: 120px;
margin-top: 1.2rem;
}
.waveform-bar {
width: 3px;
background: var(--deepdrft-green-accent);
overflow: hidden;
border-radius: 2px;
animation: wave-dance var(--dur, 1s) ease-in-out infinite alternate;
}
.waveform-placeholder {
margin-top: 1.2rem;
height: 1px;
background: rgba(250, 250, 248, 0.12);
}
/* The lava-lamp popover trigger overlays the visualizer's top-right corner (full parity, §8e). Above
the canvas (z-index) and pointer-enabled so the icon is clickable even though the canvas layer below
it is pointer-events:none. */
.np-visualizer-controls {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 1;
}
@@ -7,7 +7,7 @@
scroll/zoom/compositing math live in the WaveformVisualizer.ts interop module; this component is a thin
bridge that feeds it datum + playback + zoom + theme. Deliberately NOT the player-bar peak-bar idiom. *@
<div class="mix-waveform-bg">
<div class="mix-waveform-bg @(Fill ? "mix-waveform-bg--fill" : null)">
<canvas @ref="_canvas" class="mix-waveform-canvas"></canvas>
</div>
@@ -73,6 +73,17 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
/// </summary>
[Parameter] public double PlaybackPosition { get; set; }
/// <summary>
/// Container-sizing mode (phase-12 §6c). Default <c>false</c> keeps the full-viewport mount the
/// engine has always used (fixed, inset 0, clipped above the player bar) — Mix's mode-A full-bleed
/// and the ambient mode-B mounts are unchanged. Set <c>true</c> for a contained mount (mode C, the
/// NowPlaying card): the canvas fills its nearest positioned ancestor instead of the viewport, with
/// no footer clip. This is a CSS/layout toggle only — the renderer already sizes the backing store to
/// the canvas's own box (a ResizeObserver on the canvas, never <c>window</c>), so the JS module is
/// identical in both modes; <c>Fill</c> only changes which box that canvas occupies.
/// </summary>
[Parameter] public bool Fill { get; set; }
// Bridge-level diagnostics. Mirrors the JS-side DEBUG flag in WaveformVisualizer.ts: when true the
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
// `[WaveformVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
@@ -19,6 +19,19 @@
overflow: hidden;
}
/* Fill / container-sizing mode (Phase 12 §6c, mode C). The contained hosts (the NowPlaying card)
set Fill="true": the canvas fills its nearest positioned ancestor instead of the viewport, and the
footer clip drops (there is no player bar to clear — the bounding box IS the clip). The host is
responsible for giving this layer a positioned, sized parent. `inset: 0` over `position: absolute`
makes the box exactly that parent's content box; `overflow: hidden` still clips the canvas to it.
The canvas backing-store sizing in WaveformVisualizer.ts is unchanged — it already measures the
canvas's own box, so this purely re-parents the box from the viewport to the container. */
.mix-waveform-bg--fill {
position: absolute;
inset: 0;
z-index: 0;
}
/* The canvas fills the viewport. All ribbon shading (luminous depth, soft edges) is drawn inside the
canvas by the WebGL2 fragment shader. NO CSS backdrop-filter: it was a confirmed per-frame perf killer
on the Canvas predecessor and is exactly the cost the GPU move exists to eliminate (spec §2, §5.2);