docs: move PLAN 2.4 to COMPLETED — interactivity-gap loading guards landed
This commit is contained in:
+44
-10
@@ -6,6 +6,50 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
|
||||
|
||||
---
|
||||
|
||||
## Phase 2.4 — Interactivity-gap loading guard on dead-during-prerender controls
|
||||
|
||||
**Status:** Fully landed on 2026-06-08 (implementation complete, reviewed and merged to dev).
|
||||
|
||||
Guard controls that are dead during the SSR→interactive handoff window (1–2s on fast loads, 5s+ on cold WASM cache) so they *look* inactive until the Blazor runtime attaches, then re-render into their live form. The listener reaches for **play** first — a play button that looks armed but eats the click reads as "the site is broken," not "the site is loading." This is a credibility/perceived-quality fix on the primary action.
|
||||
|
||||
**Implementation approach:** Extend the existing `RendererInfo.IsInteractive` pattern already established in `PlayStateIcon.razor` and `DeepDrftHero.razor`. Add `Disabled="@(!RendererInfo.IsInteractive)"` (or the HTML equivalent) to unguarded controls during the SSR phase. No global overlay/scrim (rejected — it fights the prerender's purpose and risks colliding with Blazor's `#components-reconnect-modal`); per-control guarding leaves the working parts (plain `<a>` links, idle UI) live. Each control carries its own inline gate — mild duplication over a shared `<InteractivityGate>` wrapper is deliberately accepted (over-engineering for ~4 call sites; would obscure the per-control rendering differences). Consistent with existing patterns.
|
||||
|
||||
**Guarded controls (as implemented):**
|
||||
- **`TrackCard.razor` play `MudFab` (grid + list mode) — HIGHEST PRIORITY.** Disabled during the gap (greyed, non-interactive via MudBlazor's built-in disabled state). Card looks *composed but not-yet-armed*, not alarmed. Re-enables once `RendererInfo.IsInteractive` flips. Note: `/tracks` bridges *data* across the seam via `PersistentComponentState` — but bridging data ≠ wiring handlers; the gap still exists on a cold WASM cache load.
|
||||
- **`TracksView.razor` `MudToggleGroup` (grid/list switch) + `MudPagination`.** Both gated to `Disabled="true"` during the gap. Lower priority than play, but cheap to include in the same pass and visually consistent.
|
||||
- **`SharePopover.razor` (on `TrackDetail`).** The Share `MudIconButton` trigger gated to `Disabled="true"` until interactive; the in-popover copy buttons are moot while the trigger is disabled, so the single guard on the trigger suffices.
|
||||
- **`DeepDrftMenu.razor` "Stream Now" CTA.** Folded `!RendererInfo.IsInteractive` into the existing `disabled="@(...)"` expression (e.g. `disabled="@(_streamLoading || !RendererInfo.IsInteractive)"`) on both desktop and mobile buttons. The label-swap precedent here ("Finding a track…") is the house voice — disabling is the floor.
|
||||
|
||||
**What was deliberately left untouched (mirrors `WASM_SEAMS.md` §2 discipline):**
|
||||
- **Minimized `AudioPlayerBar` dock** — default state shows only `LevelMeterFab`, which is idle (untinted, no animation) until audio plays. Reads correctly during the gap; nothing to guard.
|
||||
- **Expanded `AudioPlayerBar` transport zone** — already routes its play/pause glyph through the guarded `PlayStateIcon`. Already covered by the existing pattern.
|
||||
- **`NowPlaying` / `NowPlayingCard`** — reflect live player state; show "Nothing playing" on both passes on a cold load. No dead control; the player is gesture-gated and intentionally non-persisted.
|
||||
- **Plain `<a href>` links** (track titles → `/track/{key}`, nav links, hero CTAs) — work in static SSR. Out of scope by construction.
|
||||
|
||||
**Coexistence constraint:** This guard targets the *initial* SSR→interactive handoff. It does not duplicate or interfere with Blazor's built-in `#components-reconnect-modal` (dropped-circuit recovery, a different lifecycle event). The two are orthogonal — `RendererInfo.IsInteractive` does not flip back to `false` on a *reconnect*, so the guards correctly stay inactive during a reconnect.
|
||||
|
||||
**Prerequisite:** None. Pure client-side rendering work in `DeepDrftPublic.Client`; no API or data-layer change.
|
||||
|
||||
---
|
||||
|
||||
## LevelMeterFab — Continuous vertical fill animation
|
||||
|
||||
**Status:** Fully landed on 2026-06-08 (feature complete, component + CSS animation, merged to dev).
|
||||
|
||||
Replaced the discrete three-band tint model with a **continuous vertical fill** inside the music-note SVG silhouette. The fill height tracks live audio level bottom-up (0–100%); a fixed three-zone gradient (`linearGradient` with `gradientUnits="userSpaceOnUse"`) renders green (0–60% of note height), yellow (60–85%), and orange (85–100%) zones. The color at the fill line therefore changes naturally as the level rises. The note shape remains always visible as a dim silhouette at 25% opacity; idle (paused/stopped) shows the silhouette alone.
|
||||
|
||||
**Implementation details:**
|
||||
- **C# side (`LevelMeterFab.razor.cs`)**: Removed discrete `_bandClass` field; replaced with continuous `_fillPercent` (0–100). dB → fill % uses a linear map over a −30 to 0 dB window (−30 dB = 0% fill, 0 dB = 100%, −12 dB = 60% / yellow boundary, −4.5 dB = 85% / orange boundary). Smoothing envelope operates on the continuous value (attack-fast / release-slow on dB, then map). Computed properties `FillY` and `FillH` expose the rect geometry to the SVG template.
|
||||
- **SVG (`LevelMeterFab.razor`)**: Two layers — always-on dim silhouette (note path at 25% white) and a clipped fill group (rectangle revealed through the note via `clipPath`, painted with the zone gradient). No color cascade; explicit rgba on silhouette, explicit colors in gradient stops.
|
||||
- **Gradient anchoring**: `linearGradient` with `gradientUnits="userSpaceOnUse"` (not `objectBoundingBox`) — x1="0" y1="24" x2="0" y2="0" (bottom to top in viewBox coordinates). This pins the zones to fixed heights so the fill line always crosses the same colors at the same levels.
|
||||
- **CSS (`LevelMeterFab.razor.css`)**: Removed band-tint color transition (no longer applicable). Geometry attributes `y` and `height` are not CSS-animatable in a reliable way; animation is purely the 30fps C# value updates driven by smoothing envelope. Silhouette remains always-on idle visual when `_fillPercent = 0`.
|
||||
- **Re-render gate**: 0.5% change threshold prevents churn on sub-pixel deltas; renders only on meaningful level swings.
|
||||
- **Idle behavior**: `StopAnimation` resets `_fillPercent = 0` and `_smoothedDb = SilenceFloorDb`, dropping the column and leaving only the dim silhouette.
|
||||
|
||||
Supersedes the earlier discrete-tint `LevelMeterFab` entry from the same component. The new model is load-bearing for real-time level feedback on a commercial dance-music master (−8 to −3 dBFS); the meter "breathes" through the green/yellow zones with peaks reaching orange, rather than holding in one band.
|
||||
|
||||
---
|
||||
|
||||
## Track Gallery View Toggle
|
||||
|
||||
**Status:** Fully landed on 2026-06-08 (feature complete, component + layout + CSS, merged to dev).
|
||||
@@ -71,16 +115,6 @@ Give the track gallery two switchable view modes behind a page-level toggle: **M
|
||||
|
||||
---
|
||||
|
||||
## LevelMeterFab — Reactive audio-level-tint FAB
|
||||
|
||||
**Status:** Fully landed on 2026-06-08 (feature complete, component + integration, merged to dev).
|
||||
|
||||
- **What:** A new `LevelMeterFab` Blazor component that replaces the static music-note FAB in the minimized player dock (`AudioPlayerBar.razor`). Reactively tints the icon based on live audio output level: green (−∞ to −18 dB), yellow (−18 to −6 dB), orange (above −6 dB). When idle (no track, paused, stopped), reverts to static untinted state.
|
||||
- **Why it matters:** The minimized dock is always visible in the UI; adding a live level indicator gives real-time visual feedback on the audio stream's loudness, and the three-band color coding immediately communicates whether the output is quiet, normal, or hot.
|
||||
- **Implementation:** No TypeScript or `AudioInteropService` changes — reuses the existing spectrum callback infrastructure (`StartSpectrumAnimationAsync` / `StopSpectrumAnimationAsync`). The component subscribes to live spectrum buckets at ~30fps, reduces the peak bucket to a reconstructed dB value via the inverse of the spectrum normalization formula, applies attack-fast/release-slow smoothing, and updates the icon color class. CSS transitions on the color (120ms ease-out) smooth the band changes. Follows the identical state-subscription pattern as `SpectrumVisualizer` — observes `IPlayerService.StateChanged` to toggle animation on play and off on pause/stop/track-end.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2.5 — "Stream Now" — random-track instant play
|
||||
|
||||
**Status:** Fully landed on 2026-06-07 (feature complete, endpoints + service methods + menu wiring, merged to dev).
|
||||
|
||||
@@ -20,7 +20,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
|
||||
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`.
|
||||
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
|
||||
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Reactively tints the music-note icon based on live audio level (green/yellow/orange bands), reusing spectrum-callback infrastructure. Idle when paused/stopped.
|
||||
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0–100%), with fixed three-zone gradient (green 0–60%, yellow 60–85%, orange 85–100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
|
||||
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
|
||||
- `Helpers/`: Utilities and mapper functions.
|
||||
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
# PLAN — Level Meter Fill Animation
|
||||
|
||||
**Component:** `DeepDrftPublic.Client/Controls/AudioPlayerBar/LevelMeterFab`
|
||||
**Status:** Spec, ready for implementation
|
||||
**Supersedes:** the three-band discrete tint behavior described in `PLAN-level-meter-fab.md §2/§4`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the single-color whole-note tint with a **continuous vertical fill** inside the music-note silhouette. The note fills bottom-up like a VU/liquid level meter. The fill height tracks the live audio level continuously (0–100%); a fixed three-zone gradient (green → yellow → orange, painted by viewBox height) means the *color at the fill line* changes naturally as the level rises through the zones. Idle state is a dim, unfilled note silhouette.
|
||||
|
||||
The key conceptual shift: today the **whole note** is one of three colors and the trigger is a discrete band. After this change, the **fill level** is continuous and the **color is a property of vertical position**, not of the current band. A quiet track shows a low green pool; a hot dance master pushes the fill up into the orange cap.
|
||||
|
||||
---
|
||||
|
||||
## 1. Visual model
|
||||
|
||||
### Coordinate system
|
||||
|
||||
The SVG keeps `viewBox="0 0 24 24"`. In SVG, **y increases downward**: `y=0` is the top of the note, `y=24` is the bottom. "Fill from the bottom up" therefore means the fill rectangle's top edge (`y`) moves *upward* (decreasing y) as level rises, while its bottom edge stays pinned at `y=24`.
|
||||
|
||||
The existing note path is reused verbatim as a clip:
|
||||
|
||||
```
|
||||
M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z
|
||||
```
|
||||
|
||||
Its drawn extent runs roughly `y≈3` (top of the stem) to `y≈21` (bottom of the note head). The fill rect spans the full `0–24` height and is clipped to the path, so empty/full map cleanly to "no note visible" / "whole note filled" without having to know the path's exact bounding box.
|
||||
|
||||
### Layer stack (back to front)
|
||||
|
||||
1. **Dim silhouette** — the note path filled at `rgba(255,255,255,0.25)`, always rendered. This is what the user sees when idle (no fill) and is the "unfilled" remainder of the note while playing. Drawn first so the fill paints over it.
|
||||
2. **Clipped fill group** — `<g clip-path="url(#lmf-clip-{id})">` containing a single `<rect>` painted with the zone gradient. The rect's `y`/`height` are driven by C# from `_fillPercent`. Because it sits inside the clip group, only the part of the rect overlapping the note path shows.
|
||||
|
||||
So: **the fill rect is NOT the note** — the note shape comes entirely from the clip path plus the always-on dim silhouette. The rect is a plain rectangle that is *revealed through* the note silhouette. This keeps the geometry math trivial (a rect) while the note outline stays crisp.
|
||||
|
||||
### Fill rect geometry as a function of `_fillPercent` (0–100)
|
||||
|
||||
```
|
||||
height = 24 * (_fillPercent / 100)
|
||||
y = 24 - height // pinned to the bottom, grows upward
|
||||
x = 0
|
||||
width = 24
|
||||
```
|
||||
|
||||
- `_fillPercent = 0` → `height = 0`, rect invisible, only the dim silhouette shows (idle look).
|
||||
- `_fillPercent = 50` → `y = 12, height = 12`, note filled to its vertical mid-point.
|
||||
- `_fillPercent = 100` → `y = 0, height = 24`, whole note filled.
|
||||
|
||||
### Gradient → three fixed zones
|
||||
|
||||
The gradient is **vertical and anchored to the viewBox, not to the rect** — use `gradientUnits="userSpaceOnUse"` with `x1=0 y1=24 x2=0 y2=0` (bottom to top). This is essential: it means a given color lives at a fixed *height in the note*, so as the rect grows the colors stay put and the fill line crosses from green into yellow into orange. (If the gradient were objectBoundingBox-relative to the rect, the whole gradient would rescale with every level change and the zones would not be fixed — do not do that.)
|
||||
|
||||
Stops, expressed as % of viewBox height measured **from the bottom** (offset 0% = bottom = y2... note: because y1 is the bottom, `offset="0%"` is the green end):
|
||||
|
||||
| Zone | Height range (from bottom) | Color |
|
||||
|-------------|---------------------------|-----------|
|
||||
| Green | 0% – 60% | `#2ECC71` |
|
||||
| Yellow | 60% – 85% | `#F4C430` |
|
||||
| Orange | 85% – 100% | `#FF6B35` |
|
||||
|
||||
Suggested stop list (small overlap bands give a soft blend rather than a hard line; tune to taste):
|
||||
|
||||
```
|
||||
offset="0%" stop-color="#2ECC71" (green)
|
||||
offset="55%" stop-color="#2ECC71"
|
||||
offset="62%" stop-color="#F4C430" (yellow)
|
||||
offset="82%" stop-color="#F4C430"
|
||||
offset="88%" stop-color="#FF6B35" (orange)
|
||||
offset="100%" stop-color="#FF6B35"
|
||||
```
|
||||
|
||||
If a sharper VU-style zone transition is preferred, collapse the overlap (e.g. `60% green / 60% yellow`, `85% yellow / 85% orange`) for hard color edges.
|
||||
|
||||
### Idle / dim silhouette
|
||||
|
||||
- Idle (paused/stopped, `_fillPercent = 0`): only layer 1 renders — note at `rgba(255,255,255,0.25)`. Recognizable as the player button, visually quiet.
|
||||
- The dim silhouette is also the resting visual when a track is playing but momentarily silent (between drops, intros). The fill simply pools low.
|
||||
- The dim opacity (`0.25`) should read against `--mud-palette-primary` (the FAB background). If contrast is weak on the light palette, bump toward `0.35`; flagged as an open question.
|
||||
|
||||
---
|
||||
|
||||
## 2. dB → fill % calibration for dance music
|
||||
|
||||
### Source signal
|
||||
|
||||
`OnLevelData` already reconstructs an instantaneous dB from the spectrum peak:
|
||||
|
||||
```
|
||||
instantDb = peak * 80.0 - 80.0 // range: -80 dB (silence) .. 0 dB (full scale)
|
||||
```
|
||||
|
||||
This stays. What changes is the mapping from smoothed dB to a **continuous fill percent** instead of a band switch.
|
||||
|
||||
### Why recalibrate
|
||||
|
||||
The old band ceilings (green ≤ −18, yellow ≤ −6) were tuned for a tri-state tint where "mostly green" was the resting expectation. For a continuous fill we want the meter to **live in its middle** during a typical loud dance master and to *use the orange cap* on peaks rather than sitting pegged. Commercial dance music masters hot — roughly **−8 to −3 dBFS true peak**, with dense sustained energy. If the ceiling were 0 dB and the floor −80, a −4 dB master would sit at ~95% fill and never move; if the floor were too high it would clip to 100% constantly. The window below is chosen so the bulk of a track sweeps the green/yellow zones and drops/hot sections reach into orange.
|
||||
|
||||
### Calibration window
|
||||
|
||||
| Parameter | Value | Reasoning |
|
||||
|-----------------------|----------|-----------|
|
||||
| **Floor** (fill = 0%) | −30 dB | Below this is intro/breakdown/near-silence; meter rests low. |
|
||||
| **Ceiling** (fill =100%) | 0 dB | Full scale; reserved for true peaks/clipping headroom. |
|
||||
| **Green/Yellow boundary** | ≈ −12 dB → **60% fill** | A loud sustained section sits here; "running hot but fine." |
|
||||
| **Yellow/Orange boundary** | ≈ −4.5 dB → **85% fill** | Drops and peak loudness push into the orange cap. |
|
||||
|
||||
### The mapping
|
||||
|
||||
Linear map of smoothed dB onto 0–100 across the [floor, ceiling] window:
|
||||
|
||||
```
|
||||
fillPercent = clamp( (smoothedDb - Floor) / (Ceiling - Floor) * 100, 0, 100 )
|
||||
= clamp( (smoothedDb + 30) / 30 * 100, 0, 100 )
|
||||
```
|
||||
|
||||
Check against the gradient stops:
|
||||
- `smoothedDb = -12` → `(−12+30)/30 = 0.60` → **60% fill**, exactly the green/yellow stop. ✅
|
||||
- `smoothedDb = -4.5` → `(−4.5+30)/30 = 0.85` → **85% fill**, exactly the yellow/orange stop. ✅
|
||||
- `smoothedDb = -30` → 0% (floor). `smoothedDb = 0` → 100% (ceiling).
|
||||
|
||||
Because the dB→fill map and the gradient zone heights are both linear and pinned to the same two boundary percentages, **the color at the fill line corresponds to the intended zone at every level** — the implementer does not need to keep two calibrations in sync beyond these two boundary constants.
|
||||
|
||||
> A perceptual (non-linear) curve is possible if the linear feel is too "bottom-heavy," but linear is the simple, defensible default. See Open Questions.
|
||||
|
||||
---
|
||||
|
||||
## 3. C# side changes (`LevelMeterFab.razor.cs`)
|
||||
|
||||
### Fields / constants
|
||||
|
||||
Remove `_bandClass`, the `_svgStyle` band switch, `BandFor`, `GreenCeilingDb`, `YellowCeilingDb`. Replace with a continuous fill model.
|
||||
|
||||
```csharp
|
||||
// Calibration window (see §2). These four constants are the spec contract.
|
||||
private const double FloorDb = -30.0; // fill = 0%
|
||||
private const double CeilingDb = 0.0; // fill = 100%
|
||||
// Zone boundaries are encoded in the SVG gradient stops (§4); the linear map
|
||||
// places -12 dB at 60% and -4.5 dB at 85% automatically.
|
||||
|
||||
private const double SilenceFloorDb = -80.0; // unchanged: analyzer normalization window
|
||||
|
||||
// Smoothing — operates on continuous fill percent, not a band.
|
||||
private const double AttackCoefficient = 0.5; // fast rise (see note below)
|
||||
private const double ReleaseCoefficient = 0.12; // slow decay so the column doesn't strobe
|
||||
|
||||
private double _smoothedDb = SilenceFloorDb;
|
||||
private double _fillPercent; // 0..100, the single piece of render state
|
||||
```
|
||||
|
||||
### Smoothing — operate on the continuous value
|
||||
|
||||
Keep the attack-fast / release-slow envelope, but it must drive the **continuous fill** rather than gate a band change. Two valid placements; **smooth the dB, then map** (recommended — keeps one envelope and the linear map is monotonic so smoothing order is equivalent, but smoothing in dB keeps the perceptual meaning of the coefficients):
|
||||
|
||||
```csharp
|
||||
private Task OnLevelData(double[] buckets)
|
||||
{
|
||||
if (buckets.Length == 0) return Task.CompletedTask;
|
||||
|
||||
var peak = 0.0;
|
||||
foreach (var b in buckets) if (b > peak) peak = b;
|
||||
|
||||
var instantDb = peak * 80.0 - 80.0; // unchanged reconstruction
|
||||
|
||||
var coeff = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient;
|
||||
_smoothedDb += (instantDb - _smoothedDb) * coeff;
|
||||
|
||||
var next = Math.Clamp((_smoothedDb - FloorDb) / (CeilingDb - FloorDb) * 100.0, 0.0, 100.0);
|
||||
|
||||
// Re-render only on a meaningful change to avoid 30fps churn over sub-pixel deltas.
|
||||
if (Math.Abs(next - _fillPercent) >= 0.5)
|
||||
{
|
||||
_fillPercent = next;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
**Coefficient tuning rationale:** the old values (attack 0.6 / release 0.15) were tuned so a *band switch* didn't strobe. A continuous column is more visually sensitive to jitter because every frame is potentially a new height. Slightly gentler values (attack ~0.5, release ~0.12) give a meter that snaps up on transients but settles smoothly. These are by-ear values — the implementer should tune live against a real dance track. The **0.5% re-render threshold** is the anti-churn guard (replaces the old "band != _bandClass" gate).
|
||||
|
||||
### Stop / idle behavior
|
||||
|
||||
`StopAnimation` resets to idle. The continuous equivalent of "revert to untinted":
|
||||
|
||||
```csharp
|
||||
// In StopAnimation, replacing the _bandClass reset:
|
||||
if (_fillPercent != 0)
|
||||
{
|
||||
_fillPercent = 0;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
_smoothedDb = SilenceFloorDb;
|
||||
```
|
||||
|
||||
This drops the column to empty → only the dim silhouette remains. (A CSS-eased decay is no longer available on `y`/`height`; see §5. If a graceful drain on stop is desired rather than a snap, the implementer can run a short C# ramp-down — Open Question.)
|
||||
|
||||
### Computed render output
|
||||
|
||||
Expose the rect geometry as computed properties (string-formatted with invariant culture to avoid comma decimal separators on non-US locales):
|
||||
|
||||
```csharp
|
||||
using System.Globalization;
|
||||
|
||||
private double FillHeight => 24.0 * (_fillPercent / 100.0);
|
||||
private string FillY => (24.0 - FillHeight).ToString("0.###", CultureInfo.InvariantCulture);
|
||||
private string FillH => FillHeight.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
```
|
||||
|
||||
`_instanceId` already exists (`Guid.NewGuid().ToString()`) and is reused for the clip/gradient IDs (§4). Strip hyphens or prefix it so it is a valid SVG/CSS id token, e.g. `private string IdSuffix => _instanceId.Replace("-", "");`.
|
||||
|
||||
---
|
||||
|
||||
## 4. SVG structure (`LevelMeterFab.razor`)
|
||||
|
||||
Complete replacement for the `<svg>` body. `@(IdSuffix)` makes the `clipPath` and `linearGradient` IDs unique per instance so two FABs on screen don't cross-clip (Blazor can render more than one if the dock and another surface both mount it).
|
||||
|
||||
```razor
|
||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
|
||||
<button class="lmf-fab-btn" type="button" @onclick="OnClick">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="lmf-icon" aria-hidden="true">
|
||||
<defs>
|
||||
<!-- Vertical gradient anchored to viewBox (userSpaceOnUse), bottom -> top.
|
||||
y1=24 (bottom) is the green end; y2=0 (top) is the orange end. -->
|
||||
<linearGradient id="lmf-grad-@(IdSuffix)"
|
||||
gradientUnits="userSpaceOnUse" x1="0" y1="24" x2="0" y2="0">
|
||||
<stop offset="0%" stop-color="#2ECC71" />
|
||||
<stop offset="55%" stop-color="#2ECC71" />
|
||||
<stop offset="62%" stop-color="#F4C430" />
|
||||
<stop offset="82%" stop-color="#F4C430" />
|
||||
<stop offset="88%" stop-color="#FF6B35" />
|
||||
<stop offset="100%" stop-color="#FF6B35" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- The note silhouette, used to clip the fill rect. -->
|
||||
<clipPath id="lmf-clip-@(IdSuffix)">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Always-on dim silhouette: the idle look and the unfilled remainder. -->
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
|
||||
fill="rgba(255,255,255,0.25)" />
|
||||
|
||||
<!-- Clipped fill: a full-width rect revealed through the note shape. -->
|
||||
<g clip-path="url(#lmf-clip-@(IdSuffix))">
|
||||
<rect class="lmf-fill-rect"
|
||||
x="0" width="24"
|
||||
y="@FillY" height="@FillH"
|
||||
fill="url(#lmf-grad-@(IdSuffix))" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The `style="@_svgStyle"` attribute on the `<svg>` is gone; color now lives in the gradient.
|
||||
- `fill="currentColor"` is gone; the dim silhouette uses an explicit rgba so it does not inherit the (now removed) color cascade.
|
||||
- The drop-shadow glow that previously lived in `_svgStyle` is dropped by default. If a glow is wanted on hot signal, it can return as a CSS `filter` on `.lmf-fill-rect` gated by a class — Open Question.
|
||||
|
||||
---
|
||||
|
||||
## 5. CSS changes (`LevelMeterFab.razor.css`)
|
||||
|
||||
The fill animation is driven by **C#-computed SVG attribute changes + Blazor re-render**, not CSS classes. Important constraint:
|
||||
|
||||
- CSS `transition` **does** work on SVG *presentation properties* exposed as CSS (`fill`, `fill-opacity`, `opacity`, `stroke`).
|
||||
- CSS `transition` **does NOT** work on the SVG *geometry attributes* `y` and `height` in any reliable cross-browser way. (They are presentation attributes but not animatable via CSS transitions in most engines.) Therefore the column's vertical motion must come from the 30fps C# value updates, which already produce smooth motion given the smoothing envelope.
|
||||
|
||||
Concrete edits:
|
||||
|
||||
1. **Remove** the band-tint transition rule — it targets a color cascade that no longer exists:
|
||||
```css
|
||||
/* DELETE: */
|
||||
.lmf-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition: color 120ms ease-out, filter 120ms ease-out;
|
||||
}
|
||||
```
|
||||
2. **Replace** with a plain sizing rule (no transition needed):
|
||||
```css
|
||||
.lmf-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
```
|
||||
3. Optionally add a short fade on the fill rect's *opacity* (legal CSS transition target) so the column appears/disappears softly on play/stop without affecting the height motion:
|
||||
```css
|
||||
.lmf-fill-rect {
|
||||
transition: fill-opacity 120ms ease-out;
|
||||
}
|
||||
```
|
||||
(Only meaningful if the implementer chooses to toggle `fill-opacity` on stop rather than snapping `_fillPercent` to 0. Otherwise omit.)
|
||||
4. The `.lmf-fab-btn` rules (size, shadow, hover, focus) are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance criteria
|
||||
|
||||
- [ ] When paused or stopped, the FAB shows a single dim note silhouette (≈25% white), no fill, recognizable as the player button.
|
||||
- [ ] On play, a colored column rises from the bottom of the note and tracks the audio level continuously (not a 3-state snap).
|
||||
- [ ] The fill height responds to level in real time: quiet passages pool low (green), sustained loud sections sit mid-to-high (yellow), drops/peaks push into the orange cap.
|
||||
- [ ] The color at any fill height matches its zone: bottom 60% green, 60–85% yellow, top 15% orange — and these stay fixed as the column rises and falls.
|
||||
- [ ] On a typical commercial dance master (−8 to −3 dBFS peak), the meter visibly "breathes" through the green/yellow zones and reaches orange on drops — it is neither pegged at 100% nor dead near the bottom.
|
||||
- [ ] The column motion is smooth, not strobing, at 30fps; transients rise quickly and decay gently.
|
||||
- [ ] On stop, the column returns to empty (snap or short drain) leaving the dim silhouette.
|
||||
- [ ] Two `LevelMeterFab` instances rendered simultaneously do not interfere (unique clip/gradient IDs); neither clips the other.
|
||||
- [ ] No regression to the existing subscribe/animate lifecycle (`StartAnimation`/`StopAnimation`, `StateChanged` side-channel, dispose).
|
||||
- [ ] Renders correctly under both light and dark MudBlazor palettes; dim silhouette is visible against `--mud-palette-primary` in both.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions / implementation notes
|
||||
|
||||
1. **Gradient stop hardness.** Soft overlap (current §4 stops) vs. hard zone edges (collapse overlaps to a single offset). VU-meter convention is hard edges; liquid-fill convention is soft. Decide by eye against a real track. *Recommendation: start soft, harden if it reads muddy.*
|
||||
2. **Linear vs. perceptual dB→fill curve.** Linear is the default and matches the calibration table exactly. If the meter feels bottom-heavy (sits low for too much of the track), a mild gamma (`fill = pow(linearFill, 0.8)`) lifts the mid-range. Defer until tested live.
|
||||
3. **Drain-on-stop vs. snap.** Snapping `_fillPercent = 0` is simplest. A 200–300ms C# ramp-down reads more like a real meter releasing. Geometry can't be CSS-transitioned (§5), so a graceful drain needs a short C# timer loop. *Recommendation: ship the snap, add drain only if it feels abrupt.*
|
||||
4. **Hot-signal glow.** The old design had a `drop-shadow` glow per band. Reintroducing a glow only in the orange zone (e.g. a CSS `filter` class toggled when `_fillPercent >= 85`) could make clipping feel urgent. Out of scope for v1; note for later.
|
||||
5. **Re-render threshold.** The 0.5% change gate (§3) is a starting value. If motion looks steppy, lower it; if CPU is a concern on low-end devices, raise it. The spectrum callback already runs at the analyzer's cadence, so this only filters redundant renders.
|
||||
6. **`<animate>` SMIL elements — explicitly not recommended.** SMIL could animate the rect, but the level data already arrives as a C#-side stream at 30fps; routing it through declarative SMIL would mean re-issuing animation targets every frame, which is strictly worse than just setting `y`/`height`. Keep the animation in C#.
|
||||
7. **ID token validity.** `_instanceId` is a GUID with hyphens — valid in SVG `id` and `url(#...)`, but strip hyphens (`IdSuffix`) defensively for any future CSS-selector use. Confirm the chosen form is consistent between the `id="..."` and the `url(#...)` reference.
|
||||
8. **Invariant formatting.** `FillY`/`FillH` must format with `CultureInfo.InvariantCulture` — a comma decimal separator (e.g. German locale) would produce invalid SVG. Called out in §3; verify in implementation.
|
||||
|
||||
---
|
||||
|
||||
## Migration note for `PLAN-level-meter-fab.md`
|
||||
|
||||
The original FAB plan documents the three-band discrete tint as the contract (§2/§4 referenced in the current code comments). Once this fill model lands, those sections are superseded. doc-keeper should reconcile or archive the discrete-band description so the two plans don't both claim to be authoritative on the same component.
|
||||
@@ -97,26 +97,6 @@ These follow from `CONTEXT.md §5`. Direction is strongly implied but no specifi
|
||||
- **Shape:** Same extension to `GetPaged` as 2.2. UI is a debounced text input bound to the VM's filter property. EF Core translates `Contains` to SQLite `LIKE`.
|
||||
- **Prerequisite:** Fold into 2.2 if both are being done — the same `GetPaged` extension serves both. Doing them separately doubles the API churn.
|
||||
|
||||
### 2.4 Interactivity-gap loading guard on dead-during-prerender controls
|
||||
|
||||
- **What:** Under `InteractiveAuto` (`App.razor`), the static SSR prerender ships a fully-rendered, clickable-*looking* page before any circuit exists. Every Blazor event-bound control is dead in that window (1–2s fast, 5s+ on a cold first load with no WASM cache). The listener reaches for **play** first — and nothing happens. Guard these controls so they *look* inactive until the runtime attaches, then re-render into their live form.
|
||||
- **Why it matters:** The play button on a track is the single most-reached-for control on the site, and it is the most prominent dead one. A play button that looks armed but eats the click is the worst version of this bug — it reads as "the site is broken," not "the site is loading." This is a credibility/perceived-quality fix on the primary action, not a nicety.
|
||||
- **The pattern already exists — extend it, do not invent a new layer.** `PlayStateIcon.razor` is the reference implementation: gate on `!RendererInfo.IsInteractive`, render a `MudProgressCircular` in place of the live button until hydration, then re-render into the wired control (it carries an explanatory comment). `DeepDrftHero.razor` uses the same `RendererInfo.IsInteractive` seam for animation gating. This item generalises that established idiom across the remaining unguarded controls. **A global overlay/scrim was considered and rejected** — it fights the prerender's purpose (the page is visible and partly usable; plain `<a>` links already work), needs its own teardown, and risks colliding with Blazor's built-in `#components-reconnect-modal`. Per-control guarding leaves the working parts live.
|
||||
- **`RendererInfo.IsInteractive` returns `false` during static SSR/prerender, `true` once Server circuit or WASM runtime is up.** It is per-component (no app-global flag), available in any component's `@code` / codebehind. Each control below carries its own inline guard — mild duplication of the gate expression, accepted deliberately over a shared `<InteractivityGate>` wrapper (over-engineering for ~4 call sites; would obscure the per-control rendering differences). Consistent with how `PlayStateIcon` and `DeepDrftHero` already do it.
|
||||
- **Controls to touch, with per-control implementation notes:**
|
||||
- **`TrackCard.razor` play `MudFab` (grid + list mode) — HIGHEST PRIORITY.** Today a raw `MudFab StartIcon="@PlayPauseIcon" OnClick="@PlayClick"` in both the grid (`deepdrft-track-info-bottom`) and list (`deepdrft-track-row-fab`) branches — it does **not** route through `PlayStateIcon`, so it is unguarded. `TrackCard` is a `ComponentBase` (codebehind `TrackCard.razor.cs`), so read `RendererInfo.IsInteractive` directly there. **Design call (diverges from `PlayStateIcon`'s whole-button-swap on purpose):** do *not* replace each fab with a full `MudProgressCircular` — a 12-card grid would render 12 spinner circles and read as "everything is broken/loading." Instead keep the `MudFab` visually present but `Disabled="true"` during the gap (greyed, non-interactive via MudBlazor's built-in disabled state), optionally with the play glyph dimmed or a small inline busy hint. The card should look *composed but not-yet-armed*, not alarmed. Re-enable (the existing `OnClick` wiring takes over) once `RendererInfo.IsInteractive` flips. Note: `/tracks` already bridges *data* across the seam via `PersistentComponentState` (`WASM_SEAMS.md` S1) — but bridging data ≠ wiring handlers; the gap still exists on a cold WASM-cache load even though the cards are populated.
|
||||
- **`TracksView.razor` `MudToggleGroup` (grid/list switch) + `MudPagination`.** Both have a `Disabled` property. Gate both on `!RendererInfo.IsInteractive` → `Disabled="true"` during the gap. Lower priority than play (a user who can't switch view modes for 2s is mildly inconvenienced, not misled), but cheap to include in the same pass and visually consistent.
|
||||
- **`SharePopover.razor` (on `TrackDetail`).** The Share `MudIconButton` (`OnClick="@Toggle"`) and the copy buttons inside the popover are all dead during the gap. `SharePopover` is a `ComponentBase` (codebehind). Gate the trigger `MudIconButton` to `Disabled="true"` until interactive; the in-popover copy buttons are moot while the trigger is disabled (popover can't open), so the single guard on the trigger suffices.
|
||||
- **`DeepDrftMenu.razor` "Stream Now" CTA.** Already has a `_streamLoading` disabled-guard, but that only covers the *post-click* in-flight window — it does **not** cover the pre-circuit gap, so a click during the gap silently no-ops. Fold `!RendererInfo.IsInteractive` into the existing `disabled="@(...)"` expression (e.g. `disabled="@(_streamLoading || !RendererInfo.IsInteractive)"`) on both the desktop and mobile button. The label-swap precedent here ("Finding a track…") is the house voice — consider a parallel "Warming up…"/spinner affordance during the gap if cheap, but disabling is the floor. The dark-mode toggle in this file is **commented out** — not a live concern; leave it.
|
||||
- **What is already correct — DO NOT TOUCH (mirrors `WASM_SEAMS.md` §2 discipline):**
|
||||
- **Minimized `AudioPlayerBar` dock** — default state shows only `LevelMeterFab`, which is idle (untinted, no animation) until audio actually plays. It reads correctly during the gap; nothing to guard.
|
||||
- **Expanded `AudioPlayerBar` transport zone** — already routes its play/pause glyph through the guarded `PlayStateIcon`. Already covered by the existing pattern.
|
||||
- **`NowPlaying` / `NowPlayingCard`** — reflect live player state; show "Nothing playing" on both passes on a cold load (per `WASM_SEAMS.md` G2). No dead control; the player is gesture-gated and intentionally non-persisted. Leave it.
|
||||
- **Plain `<a href>` links** (track titles → `/track/{key}`, nav links, hero CTAs) — work in static SSR. Out of scope by construction.
|
||||
- **Coexistence constraint:** This guard targets the *initial* SSR→interactive handoff (`RendererInfo.IsInteractive`). It must not duplicate or interfere with Blazor's built-in `#components-reconnect-modal` (dropped-circuit recovery, a different lifecycle event). The two are orthogonal — `RendererInfo.IsInteractive` does not flip back to `false` on a *reconnect*, so the guards correctly stay inactive during a reconnect. Do not wire any custom reconnect UI into this work.
|
||||
- **Optional brand polish (not the spine of the work):** if a palette-tinted busy affordance is wanted, tint the existing `MudProgressCircular` / disabled-fab glyph toward the accent ("Lowcountry"/"Charleston") rather than introducing a new atmospheric loading layer. Keep the skeleton/spinner vocabulary already established in `TracksView` and `PlayStateIcon`.
|
||||
- **Prerequisite:** None. Pure client-side rendering work in `DeepDrftPublic.Client`; no API or data-layer change. Can land independently of any Phase 1–4 item.
|
||||
- **Reference:** `WASM_SEAMS.md` (the sibling SSR→WASM seam audit) is the precedent doc and idiom source; this item is the *control-interactivity* counterpart to that doc's *state-persistence* focus. Worth a glance before implementation for the `RendererInfo.IsInteractive` rationale already written up there.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user