85 Commits

Author SHA1 Message Date
daniel-c-harvey 17a35247c1 docs: mark About page follow-ups (2) + (4) resolved in COMPLETED.md
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m11s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m20s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m54s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m34s
Deploy DeepDrftManager / Deploy (push) Successful in 1m30s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-17 22:27:10 -04:00
daniel-c-harvey fb987acc18 Merge p12-w5-khabran-bio into dev (Khabran bio + multi-paragraph bio render) 2026-06-17 22:18:06 -04:00
daniel-c-harvey b524b8e6ec feature: Images edits 2026-06-17 22:18:01 -04:00
daniel-c-harvey 9cfc31f725 content(about): wire Khabran's bio + multi-paragraph render
Bio embedded as \n\n-delimited string; render splits on that boundary
into per-para <p class="bio-body">. Adjacent-sibling margin keeps
stacked paragraphs readable. Daniel's single-para bio is unaffected.
2026-06-17 22:16:18 -04:00
daniel-c-harvey d512a1d329 Merge p12-w4-pullquote into dev (widen desktop pull-quote, fix 960px snap) 2026-06-17 22:10:18 -04:00
daniel-c-harvey 4e2033e40c fix(about): widen pull-quote desktop max-width 44ch to 70ch to end ribbon snap at 960px 2026-06-17 22:04:20 -04:00
daniel-c-harvey 8c811c411c docs: mark About photo slots largely resolved in COMPLETED.md
Bio portraits (Daniel + Khabran, circular/crossfade) and Process mixer
figure (dd-mixer-2) landed; dd-pedals now on Home Origin split. Khabran
bio text remains open.
2026-06-17 21:57:26 -04:00
daniel-c-harvey 44c17c8b73 Merge p12-w3-about-photos into dev (bio portraits, image swaps, circular framing, pull-quote width) 2026-06-17 21:52:25 -04:00
daniel-c-harvey d961eadc93 feature: Cleanup Waveform Controls 2026-06-17 21:51:29 -04:00
daniel-c-harvey c7d627b817 feat(about): wire bio portraits, swap images, circular frame, widen pull-quote
Portraits (1365 square) rendered as circles via border-radius:50%; parallax
tamed to fit. Process figure swapped to dd-mixer-2; Home Our Origin split
swapped to dd-pedals. Pull-quote widened 22ch to 44ch.
2026-06-17 21:45:55 -04:00
daniel-c-harvey 9850be8a49 docs: update About page to Liner Notes editorial treatment
CLAUDE.md: replace stale Home-primitives description with Liner Notes
layout and note about-rail.ts interop. COMPLETED.md: add redesign
addendum to Phase 12 About Page entry (Direction 1).
2026-06-17 20:12:12 -04:00
daniel-c-harvey f49e196596 Merge p12-w2-about-liner-notes into dev (About page Liner Notes editorial redesign) 2026-06-17 20:10:14 -04:00
daniel-c-harvey c8168564bb style(about): redesign /about as numbered "Liner Notes" editorial spine
Replace Home-cloned section grammar with a numbered left rail (Bodoni
numerals, vertical spine, mono marginalia), an asymmetric content column,
and SVG waveform dividers. Adds a degrade-safe IntersectionObserver interop.
Copy verbatim.
2026-06-17 20:04:00 -04:00
daniel-c-harvey a210b2ded7 docs(about): propose 3 visual-distinction directions
About reuses Home's section grammar in Home's order. New product note offers
three narrative-backbone directions (Liner Notes / Contact Sheet / Offset
Ledger) within brand guardrails, with a recommendation. Awaiting Daniel's pick.
2026-06-17 19:38:45 -04:00
daniel-c-harvey 7386ab0dd0 docs: reflect Phase 12 About Page landing
Move Phase 12 entry from PLAN.md to COMPLETED.md; note /about page and
open follow-ups (images, Khabran bio, shared primitives). Add terse
/about mention to CLAUDE.md public client bullet.
2026-06-17 18:20:06 -04:00
daniel-c-harvey 3f83e0f11c docs(phase-15): record polish round 2; mark slider decision superseded
Note the five round-2 changes in COMPLETED.md; mark §8 + the §2/§11 slider references superseded (scroll reverted to RadialKnob).
2026-06-17 18:19:32 -04:00
daniel-c-harvey 6303b4f62c Merge p12-w1-about-page into dev (About page in Home visual language) 2026-06-17 18:17:12 -04:00
daniel-c-harvey 02cc83ed31 Merge p15-w3-controls-polish into dev
Phase 15 polish round 2: mute panel ground, revert WAVE scroll to a RadialKnob,
add a distinct waveform glyph (DDIcons) for the waveform toggle, strong green
active-state on the toggles, and refresh the popover pointer-capture comment.
2026-06-17 18:15:33 -04:00
daniel-c-harvey a97cdcf395 fix(about): differentiate medium-card eyebrows; co-locate orphaned media query
Studio/Live/DJ Set eyebrows mirror Home's established vocabulary.
Orphaned @media (max-width: 960px) for .section-dark-standfirst
merged into the sibling dark-section block.
2026-06-17 18:13:00 -04:00
daniel-c-harvey 5614bbefad fix(DDIcons): correct Waveform doc-comment bar count from seven to six 2026-06-17 18:09:44 -04:00
daniel-c-harvey 6ecc7f1f37 polish(p15): mute panel, revert scroll to knob, waveform icon + strong toggle state
Mute --deepdrft-panel-ground; WAVE scroll MudSlider back to RadialKnob; new DDIcons Waveform/WaveformFilled glyph for the waveform toggle; strong green ON-state chip vs dim OFF; refresh popover pointer-capture comment.
2026-06-17 18:03:16 -04:00
daniel-c-harvey 35ae775954 feat(public): add /about page in Home visual language
Three-movement About page (People/Process/Product) built from Home's
section primitives; registered in nav. Image slots and Khabran's bio
degrade gracefully until assets/copy land.
2026-06-17 17:53:25 -04:00
daniel-c-harvey 412b96ba16 docs(about-page): lock spec as approved; final photos sole open item
Resolve §9 open questions: hero title "The Collective", Khabran bio as
empty-slot placeholder, wwwroot/img hosting, Process placement for
"designed not extracted". COPY D approved provisional; typo flags kept.
2026-06-17 17:46:37 -04:00
daniel-c-harvey 40b5cb8328 docs(about-page): apply Daniel's copy decisions
Mark A,B,C,E,D-intro,F,G,H approved with verbatim text; redraft D
(no Octave One, live-hardware spirit) pending approval; resolve the
medium-card question; flag two COPY C typos for confirmation.
2026-06-17 17:05:08 -04:00
daniel-c-harvey 7e27856359 docs: spec About page for public site (Phase 12)
Three-movement About page (People/Process/Product) in the Home page's
existing visual language; draft copy fenced for approval, image slots and
open questions captured. Adds product-notes/about-page.md and PLAN.md §12.
2026-06-17 16:30:56 -04:00
daniel-c-harvey 2c5c569797 docs(phase-15): record post-landing fixes + RCL TypeScript interop
Note the seven smoke-test fixes (incl. site-wide RadialKnob pointer capture) in
COMPLETED.md; document DeepDrftShared.Client TS interop in root CLAUDE.md.
2026-06-17 16:24:49 -04:00
daniel-c-harvey 855a4a5d2a Merge p15-w2-controls-fixes into dev
Phase 15 follow-up: fix seven control-panel + knob defects from Daniel's smoke
test — greyer panel ground, drag scrollbar + body-scroll lock, light caption
icons, centered WAVE slider, milder scrim, overlay above header/footer, and
real RadialKnob pointer capture (site-wide stuck-knob fix).
2026-06-17 15:55:42 -04:00
daniel-c-harvey 3835d9f9c4 fix(RadialKnob): real pointer capture via setPointerCapture interop
Switch initiator to @onpointerdown; capture the pointer on the knob element
through a new knob.ts helper so pointermove/up/cancel reach the knob even
when the cursor leaves the window. Accurate comment; IAsyncDisposable cleanup.
2026-06-17 15:43:26 -04:00
daniel-c-harvey 8a329aadcf fix(p15): remediate seven control-panel + knob defects
Greyer panel ground (token); remove drag scrollbar + lock body scroll; caption icons light; center WAVE slider; RadialKnob drag uses pointer events (robust to cursor leaving window); milder scrim alpha; overlay z-index above header/footer.
2026-06-17 15:32:01 -04:00
daniel-c-harvey e2c3f2a3aa docs: note eyebrow-label + divider-rule header on ReleaseDescription 2026-06-17 15:31:44 -04:00
daniel-c-harvey b16fc3ca7e Merge p16-w2-release-description-aesthetics into dev (editorial eyebrow + divider-rule styling for release blurb) 2026-06-17 15:30:41 -04:00
daniel-c-harvey 282cafc52f style(release-description): editorial eyebrow + divider-rule aesthetic 2026-06-17 15:30:33 -04:00
daniel-c-harvey 08f56d09d1 docs: note per-track Profile/High-res columns carry always-visible regenerate buttons 2026-06-17 15:23:00 -04:00
daniel-c-harvey e4b6fc525f fix: Release Description width 2026-06-17 15:22:30 -04:00
daniel-c-harvey 53a27ce06c Merge p16-w1-cms-grid-cleanup into dev (CMS grid cell layout fixes + per-track waveform regenerate buttons) 2026-06-17 15:15:35 -04:00
daniel-c-harvey fc32791cea fix(cms): fix grid cell vertical stacking; add per-track regenerate buttons
MixBrowser WaveformCell: wrap icon+button in MudStack Row. SessionBrowser
HeroCell: split into two SpecialActionColumns (thumb + button). AlbumBrowser
track table: always show regenerate button for Profile and High-res.
2026-06-17 15:15:23 -04:00
daniel-c-harvey 007033e7e8 docs: note ReleaseDescription blurb component on release detail pages 2026-06-17 14:57:27 -04:00
daniel-c-harvey e38678009e docs(phase-15): record visualizer controls landing
Move Phase 15 from PLAN to COMPLETED; fix DDIcons location to
DeepDrftShared.Client/Common; update WaveformVisualizerControls/Popover/State
descriptions for the three-row modal-overlay rework.
2026-06-17 14:50:30 -04:00
daniel-c-harvey 1fef60a7fb Merge release-description-blurb into dev (render release Description blurb on Session, Mix, and Cut detail pages) 2026-06-17 14:50:04 -04:00
daniel-c-harvey 29ab4840d0 Merge p15-w1-visualizer-controls into dev
Phase 15 — visualizer control-deck rework: screen-centered tinted MudOverlay
(NowPlayingCard chrome), deterministic three-row LAVA/WAVE layout, lava/waveform
lamp toggles backed by a genuine per-subsystem draw-skip, scroll/zoom slider,
playful tooltips, green=interactive/light=static colour principle.
2026-06-17 14:44:52 -04:00
daniel-c-harvey 15ddc4c332 feat: Styles 2026-06-17 14:44:08 -04:00
daniel-c-harvey 2c2342fbaf fix(p15): remediate four green-minor review findings
Tokenize scrim navy RGB triple (--deepdrft-scrim-rgb); LAVA row now
flex-start so knobs group left; WAVE row keeps space-between for
right-pinned width knob; remove inert margin-left:auto/wvc-row-right;
correct stale seven->ten count in OnControlStateChanged comment.
2026-06-17 14:42:23 -04:00
daniel-c-harvey b8f81edb59 feat: render release Description blurb on Session, Mix, and Cut detail pages
New shared ReleaseDescription control renders the blurb in a uniform themed block
below the hero/header; null/whitespace renders nothing, with no layout artifact.
2026-06-17 14:39:03 -04:00
daniel-c-harvey db8391b81c docs(phase-14): record /tracks→/releases consolidation
Update root CLAUDE.md DeepDrftManager description, log Phase 14 in
COMPLETED.md, and refresh the PLAN.md Phase 14 note.
2026-06-17 14:36:31 -04:00
daniel-c-harvey db29b0dd18 Merge p14-w1-releases-consolidation into dev (Phase 14: retire /tracks list, consolidate into /releases; catalogue cards → CUTS/SESSIONS/MIXES) 2026-06-17 14:28:37 -04:00
daniel-c-harvey dd4f8ddded feat(visualizer): Phase 15 control-deck rework
Centered tinted MudOverlay (NowPlayingCard chrome) replaces the anchored popover; eight dials become a deterministic three-row LAVA/WAVE layout; lava + waveform lamp toggles drive a genuine per-subsystem draw-skip; scroll/zoom becomes a slider; playful tooltips; green=interactive/light=static.
2026-06-17 14:28:15 -04:00
daniel-c-harvey 23a1275025 docs(Releases.razor): correct stale medium-tab comment — tabs are explicit markup, not enum-driven; adding a medium requires a hand-added panel in enum order 2026-06-17 14:27:50 -04:00
daniel-c-harvey 13fbcc2d43 fix: restore waveform status coherence, drop dead GetGenreSummaries, restore track info tooltip 2026-06-17 14:13:34 -04:00
daniel-c-harvey fe481d0417 docs(phase-15): resolve all five open questions
off = fully absent (real draw-skip seam); scroll/zoom binds ScrollSpeed;
labels light, lamp toggles green, mild tint from one token. Unify under
green = interactive, light = non-interactive.
2026-06-17 14:11:01 -04:00
daniel-c-harvey ded5dca698 docs: NowPlaying subscribes to player StateChanged to propagate live-track params 2026-06-17 14:09:07 -04:00
daniel-c-harvey 167b2fc3c5 Merge nowplaying-visualizer-coupling into dev (NowPlaying visualizer couples to live track when streaming starts) 2026-06-17 13:59:10 -04:00
daniel-c-harvey 2071a821db fix: NowPlaying re-renders on StateChanged so WaveformVisualizer gets live track params when streaming starts 2026-06-17 13:44:08 -04:00
daniel-c-harvey 6f00c6fa54 docs(phase-15): spec visualizer controls enhancements (modal popover, sectioned layout, lava/waveform toggles) 2026-06-17 13:44:00 -04:00
daniel-c-harvey 43bbc8172b docs: NowPlayingCard subscribes to player StateChanged 2026-06-17 13:37:47 -04:00
daniel-c-harvey 30999726e5 Consolidate CMS /tracks into standalone /releases page
Retire the Tracks list view; promote the Releases view to /releases with
medium tabs (ALL/CUTS/SESSIONS/MIXES). Migrate bulk profile/high-res
backfill and per-track waveform columns into the releases grids. Point
catalogue cards at the three mediums. Remove dead BrowseMode/ViewModel.
2026-06-17 13:35:25 -04:00
daniel-c-harvey 826ce218a4 Merge nowplaying-card-reactivity into dev (NowPlaying card now re-renders on track change) 2026-06-17 13:35:18 -04:00
daniel-c-harvey 739d6c6e81 Subscribe NowPlayingCard to player StateChanged so it re-renders on track change 2026-06-17 13:24:13 -04:00
daniel-c-harvey d12b732e40 docs(phase-12): record NowPlaying hero-background visualizer relocation 2026-06-17 13:17:08 -04:00
daniel-c-harvey e24048e961 Merge p12-w5-nowplaying-hero-bg into dev (Phase 12 cleanup: NowPlaying waveform visualizer becomes full-bleed hero-right background) 2026-06-17 13:14:27 -04:00
daniel-c-harvey 528f09d96a Move NowPlaying waveform visualizer to full-bleed hero-right background
Lift the WaveformVisualizer + control popover out of the 120px NowPlayingCard box into NowPlaying as a full-panel background layer; migrate the hero-right wrapper and its scoped styles from Home into NowPlaying.
2026-06-17 13:06:48 -04:00
daniel-c-harvey 0dce46bcab docs: record CMS public landing in root architecture (Phase 13)
DeepDrftManager bullet now describes the public splash at / and the
catalogue move to /catalogue. Also lands a stray Phase 12 DeepDrftAPI
waveform-vault doc edit left uncommitted by a concurrent session.
2026-06-17 12:40:48 -04:00
daniel-c-harvey f00758dc47 docs(phase-12): record waveform-visualizer generalization landing
Move the landed Phase 12 section from PLAN.md to COMPLETED.md; update DeepDrftAPI/Content/Public.Client CLAUDE.md for the WaveformVisualizer rename, per-track high-res datum + track-waveforms vault, track-cardinal fetch, popover controls, Ambient slot, and NowPlaying host.
2026-06-17 12:36:45 -04:00
daniel-c-harvey 8a187a3ed8 Merge p13-w1-cms-landing into dev (Phase 13: CMS public landing splash at /, catalogue moved to /catalogue) 2026-06-17 12:31:15 -04:00
daniel-c-harvey 9395f503b4 Merge p12-w4-t2-nowplaying into dev (12.D: real waveform visualizer in NowPlaying card, mode C + Fill mode) 2026-06-17 12:23:43 -04:00
daniel-c-harvey bc804afb55 Merge p12-w4-t1-ambient-slot into dev (12.C: ambient visualizer slot on scaffold + popover controls on all detail hosts) 2026-06-17 12:23:34 -04:00
daniel-c-harvey 80220d06f0 feat(cms): add public landing splash at /, move catalogue to /catalogue 2026-06-17 12:17:18 -04:00
daniel-c-harvey 05486a61af 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.
2026-06-17 12:15:49 -04:00
daniel-c-harvey 955182d6da feat(p12-w4): ambient visualizer slot on scaffold + popover controls on all detail hosts
Add optional Ambient slot to ReleaseDetailScaffold (full-bleed layer behind content; absent = no regression). Cut mounts it + popover; Session mounts the engine directly behind its hero; Mix swaps its inline knob-bar for the lava-lamp popover.
2026-06-17 12:11:03 -04:00
daniel-c-harvey 5fb46bf5eb docs(product): spec CMS public landing page (Phase 13)
Splash owns /, catalogue moves to /catalogue, authed users redirected
via HierarchicalRoleAuthorizeView. Skipper's public-layout pattern,
branded to DeepDrft. Adds Phase 13 to PLAN.md.
2026-06-17 11:44:33 -04:00
daniel-c-harvey 9009f2c8cf Merge p12-w3-bridge-live-track into dev (bridge follows the live playing track, not the fixed host TrackId) 2026-06-17 11:39:32 -04:00
daniel-c-harvey f1afe6e028 fix(visualizer): follow the live playing track, not the fixed host TrackId
Replace the TrackId-only IsActivePlayer gate with a LivePlayerTrack source that follows the playing track when it is the host track or shares the host release; single-track Mix/Session unchanged at parity.
2026-06-17 11:38:45 -04:00
daniel-c-harvey 7a3d44420a docs: document CMS upload heartbeat timeout and Upload:* tunables 2026-06-17 11:30:49 -04:00
daniel-c-harvey 4477026638 Merge cms-upload-heartbeat into dev (large CMS upload: idle/heartbeat timeout, two-phase response budget, per-file progress meter) 2026-06-17 11:27:55 -04:00
daniel-c-harvey 9f8808a596 Merge p12-w2-t2-popover-panel into dev (12.E: popover-hosted waveform control panel) 2026-06-17 11:22:36 -04:00
daniel-c-harvey b501cd9e3e Merge p12-w2-t1-track-fetch into dev (12.B2: track-cardinal high-res waveform fetch + bridge rewire) 2026-06-17 11:22:25 -04:00
daniel-c-harvey 803bc7840a fix(cms-upload): scope InfiniteTimeSpan to upload client; add response-wait budget after body completes 2026-06-17 11:14:15 -04:00
daniel-c-harvey 7aeced6a8d feat(visualizer): popover-hosted control panel (Phase 12.E)
Build WaveformVisualizerControlPopover pairing the lava-lamp trigger with the eight-knob WaveformVisualizerControls panel; style to the NowPlaying Hero look from tokens. Panel chrome scoped to the popover mount via a PanelChrome flag; Mix's inline mount unchanged.
2026-06-17 11:12:27 -04:00
daniel-c-harvey a19a734757 feat(p12-w2): track-cardinal high-res waveform fetch + bridge rewire
Add GET api/track/{trackEntryKey}/waveform/high-res (+ proxy), ITrackDataService.GetTrackWaveform; rewire visualizer to resolve the current track's EntryKey and re-fetch on track change. Retire the client mix-waveform read path.
2026-06-17 11:12:26 -04:00
daniel-c-harvey c9c6286571 Fix large CMS upload timeout with idle heartbeat and add per-file progress meter
Replace the 100s default HttpClient timeout (set Timeout=Infinite) with an idle/heartbeat
deadline driven by a ProgressStreamContent wrapper that reports bytes-on-the-wire. Each tick
resets the idle window and advances a MudProgressLinear per upload row. Idle window is
configurable via Upload:IdleTimeoutSeconds (default 90s).
2026-06-17 11:07:19 -04:00
daniel-c-harvey ec3989c354 Merge p12-w1-t2-highres-compute into dev (12.B1: generalize high-res waveform compute to every track, Direction B) 2026-06-17 10:29:30 -04:00
daniel-c-harvey 916bf626de Merge p12-w1-t1-rename into dev (12.A: rename Mix* visualizer engine to Waveform* abstraction) 2026-06-17 10:28:42 -04:00
daniel-c-harvey 3eef1a50f9 docs(release-controller): fix stale POST mix/waveform comment - track-waveforms vault, duration-derived high-res 2026-06-17 10:27:45 -04:00
daniel-c-harvey 585dd30efb fix(visualizer): correct cross-ref extension .ts to .cs in WaveformVisualizer comment 2026-06-17 10:27:42 -04:00
daniel-c-harvey accf20ba57 feat(waveform): generalize high-res compute to every track (Direction B)
Per-track high-res datum keyed by EntryKey in the renamed track-waveforms vault; computed at upload for all tracks, regenerable per-track via CMS, with a re-runnable backfill. Mix read path repointed so it keeps working.
2026-06-17 10:18:44 -04:00
daniel-c-harvey 3839948eeb refactor(12.A): rename Mix* visualizer engine to Waveform* abstraction 2026-06-17 10:16:44 -04:00
103 changed files with 6134 additions and 1919 deletions
+8 -6
View File
@@ -9,11 +9,11 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
### Core Projects
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes).
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles, release-track join operations. Release endpoints: paged list with medium filter, single read, mix waveform compute, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles (512-bucket seeker + per-track high-res visualizer datum in the `track-waveforms` vault), release-track join operations. Release endpoints: paged list with medium filter, single read, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
@@ -80,11 +80,13 @@ Keep this seam clean — it is the most architecturally load-bearing part of the
- Dark mode toggles via cookie (`darkMode`, 365 days). Client-side via JS interop.
- During server prerender, `DarkModeService` (in `DeepDrftPublic`) reads the cookie and seeds `DarkModeSettings.IsDarkMode`, which carries into WASM render via `PersistentComponentState`. Avoids "wrong theme flash" on initial paint.
- `DarkModeSettings` lives in `DeepDrftPublic.Client.Common` (consumed by both server prerender and client components).
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DDIcons.cs`.
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DeepDrftShared.Client/Common/DDIcons.cs`.
### TypeScript interop, not raw JS
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging.
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging. A second interop module lives at `DeepDrftPublic/Interop/about/about-rail.ts` (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"``outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`) and `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
## Development Commands
@@ -123,7 +125,7 @@ dotnet ef database update --project DeepDrftData --startup-project DeepDrftPubli
All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitignored `environment/` files:
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 600 — budget for server-side persist after the body is fully sent).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
## Folder-Level Guidance
+81
View File
@@ -6,6 +6,87 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 12 — About Page (public site editorial) (landed 2026-06-17)
**Landed:** 2026-06-17 on dev.
- **What:** A real About page for the public site (`/about`), built entirely in the **Home page's existing visual language** — no new look. Three movements — **the People**, **the Process**, **the Product** — with ethos / pathos / logos woven through the prose as registers, not labelled blocks. The strategic frame (Daniel): the site is *presentation and proof of effort* — evidence that real people are pushing the classic club sound forward; the About page is where that claim is made explicit. Built as `DeepDrftPublic.Client/Pages/About.razor` + scoped `About.razor.css`; registered in the nav index (`DeepDrftPublic.Client/Layout/Pages.cs`). Images served statically from `DeepDrftPublic/wwwroot/img/`; image slots and Khabran's bio degrade gracefully until final assets/copy land.
- **Why:** This is its own phase, not a graft onto Phase 11: Phase 11 was structural (release-cardinal browse, queue, GUID handles), whereas this is a net-new **editorial** surface. The page reuses Home's section primitives wholesale (`.hero`, `.section-divider`, two-column `.section`, dark `.section-dark` feature band, `.section-split`, `.cta-banner`, `ParallaxImage` full-bleed bands) — no new visual language introduced; the only candidate new styling is two member-bio cards, assembled from existing type tokens. Full spec: `product-notes/about-page.md`.
- **Shape:** `DeepDrftPublic.Client/Pages/About.razor` (new; `@page "/about"`; three-movement editorial page using Home section primitives); `DeepDrftPublic.Client/Pages/About.razor.css` (new; scoped styles — Home section primitives currently re-declared here rather than shared globally, a known follow-up); `DeepDrftPublic.Client/Layout/Pages.cs` (nav index registration added). Static images from `DeepDrftPublic/wwwroot/img/`.
**Voice constraint (hard):** smart, serious, no AI-isms — underground Detroit/Midwest deep-club-house heritage carried to Charleston. All body prose remains DRAFT pending Daniel's approval — section headers and UI labels are set; any sentence/paragraph of site copy is a placeholder until Daniel passes it.
**Open follow-ups:** (1) ~~Final photo files for the five image slots~~ Photo slots largely resolved: bio portraits (Khabran + Daniel) now wired as circular framed portraits with a bw→colour hover crossfade (`dd-khabran`, `dd-daniel`); Process hands-on-gear figure now uses bespoke `dd-mixer-2`; the Liner Notes redesign removed the separate full-bleed atmosphere and closing-band slots, so the duo hero (`dd-duo-2`) and the hands-on-gear inset are the only full-bleed image positions remaining and both carry bespoke assets. Home page "Our Origin" split swapped the retired `kp-shoulder` for `dd-pedals`. (2) ~~Khabran's bio text still open~~ Khabran's bio is now wired (three paragraphs; bio-body render updated to emit each paragraph as its own `<p class="bio-body">` — Daniel's single-paragraph bio is unaffected). (3) Optional promotion of the duplicated Home section primitives from `About.razor.css` to a shared global stylesheet. (4) ~~Whether CUTS/SESSIONS/MIXES are explained on the page~~ Resolved by the Liner Notes redesign: the triptych renders as a stacked editorial definition list (see Redesign addendum below).
**Redesign — Liner Notes editorial treatment (2026-06-17):** The page was rebuilt from the Home section primitives approach into a distinct **"Liner Notes"** editorial layout. Structure and copy (three-movement People / Process / Product) are unchanged; the visual treatment is entirely new. Key elements: numbered left rail (oversized Bodoni 01/02/03 movement numerals + continuous vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking left into the margin, hand-authored SVG waveform movement dividers (a self-contained decorative motif, not the live `WaveformVisualizer` component). The CUTS/SESSIONS/MIXES triptych is now a stacked editorial definition list rather than Home's medium-card image grid. Active-movement highlight on the left rail is progressive enhancement via a new `DeepDrftPublic/Interop/about/about-rail.ts` IntersectionObserver interop (compiled output gitignored). Superseded Home section primitives were removed from `About.razor.css`; the global stylesheet was untouched. Design authority: `product-notes/about-page-distinction.md` (Direction 1).
---
## Phase 15 — Visualizer Controls Enhancements (landed 2026-06-17)
**Landed:** 2026-06-17 on dev.
- **What:** A presentation and interaction rework of the waveform visualizer control surface — the eight-RadialKnob panel (Phase 12) hosted by `WaveformVisualizerControlPopover`. Not a renderer change: the WebGL2 visualizer, the eight continuous dial values + their defaults, and the `Changed`-event bridge seam are unchanged. The phase reworks how the controls are reached and presented, adds two on/off toggles (lava, waveform), and gives the panel a deterministic, sectioned layout that encodes the visualizer's composition (lava field + waveform ribbon, optionally overlaid).
Four tracks shipped as a single bundled PR (`15.A → {15.B, 15.C} → 15.D`):
- **15.A — State booleans + bridge wiring.** Two new `WaveformVisualizerControlState` booleans: `LavaEnabled` and `WaveformEnabled` (both default `true`). `WaveformVisualizer.ts` gained a genuine per-subsystem draw-skip: when a subsystem is "off" it is not drawn, contributes no collisions, and incurs no render cost (not dimmed). The bridge pushes the new booleans on `Changed` alongside the eight existing dials. The per-subsystem draw-skip seam was built as part of this track (it did not exist prior).
- **15.B — Screen-centered tinted-modal primitive + NowPlayingCard chrome.** `WaveformVisualizerControlPopover` changed from an anchored `MudPopover` to a screen-centered, tinted modal `MudOverlay` (`DarkBackground`, `Modal="true"`). The `AnchorOrigin`/`TransformOrigin` parameters were dropped — a centered modal has no anchor. Panel chrome follows the NowPlayingCard look: square corners, lighter-navy ground, thin light border. Chrome classes stay in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). Tint opacity resolves from a single `--deepdrft-modal-scrim-alpha` token. Knob-drag safety is preserved: `RadialKnob` mounts its own `position:fixed` capture div above the scrim while dragging, so releasing outside the panel does not close the modal.
- **15.C — Deterministic three-row layout + toggles + scroll slider.** The flat eight-knob grid replaced by a three-row sectioned layout: **Row 1 (MODE, always visible):** two lamp toggles (lava / waveform) left-aligned + collisions knob (only when both subsystems on) + color knob pinned far-right. **Row 2 (LAVA, visible only when lava on):** "LAVA:" label + Gravity / Heat / FluidAmount / FluidViscosity knobs. **Row 3 (WAVE, visible only when waveform on):** "WAVE:" label + scroll/zoom `MudSlider` (bound to `ScrollSpeed` alone) + width knob pinned far-right. The lamp toggles use the `DDIcons.LavaLamp` / `DDIcons.LavaLampFilled` glyph (lit = on, unlit = off) and are green (`Color.Primary`) because they are interactive.
- **15.D — Tooltips + light icon colour.** Each control received a playful, non-technical `MudTooltip`. Knob caption icons and section labels changed to light (`Color.Default` / CSS light token) per the resolved colour principle: green = interactive elements (toggles, knob arcs/pointers, scroll slider); light = static/decorative elements (section labels, caption icons).
- **Why:** The eight-knob flat grid gave the user no signal about which knobs drive the lava vs. the waveform, and neither subsystem could be turned off independently. The new layout sections controls by subsystem, making "lava only" / "waveform only" first-class operating modes. The screen-centering solves the anchored-popover problem: `MudPopover` positions off its trigger's bounding rect — wrong for a control panel that should read as centered regardless of where the lava-lamp icon sits (Mix corner, Cut/Session ambient, NowPlaying corner).
- **Shape:** `DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor` — three-row layout replacing the flat eight-knob grid; two `ToggleLava`/`ToggleWaveform` handlers; conditional row visibility; `MudSlider` for scroll speed. `DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor``MudPopover` replaced by `MudOverlay` (centered, `DarkBackground`, `Modal`); `AnchorOrigin`/`TransformOrigin` parameters removed. `DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs` — two new boolean properties (`LavaEnabled`, `WaveformEnabled`) and matching `DefaultLavaEnabled`/`DefaultWaveformEnabled` consts (both `true`). `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` — per-subsystem draw-skip seam (lava physics + blob uploads skipped when lava off; ribbon SDF + collision boundary dropped when waveform off). `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css``--deepdrft-modal-scrim-alpha` token; `.waveform-visualizer-control-overlay` centering; `.waveform-visualizer-control-modal` panel chrome (square corners, lighter-navy, thin border); row/section layout classes (`wvc-row`, `wvc-row-mode`, `wvc-row-section`, `wvc-row-wave`, `wvc-section-label`, `wvc-toggle`, `wvc-slider`). Full design, layout contract, primitive rationale, tooltip copy, and acceptance: `product-notes/phase-15-visualizer-controls-enhancements.md`.
**Post-landing fixes (2026-06-17):** Seven defects found during smoke-testing were remediated in a follow-up round on dev: (1) new `--deepdrft-panel-ground` CSS token so the blue slider reads against the panel background; (2) drag-scrollbar removed + body-scroll locked while the modal is open; (3) knob caption icons forced light so lamp toggles stay green; (4) WAVE-row slider vertically centered; (5) **site-wide `RadialKnob` pointer-capture fix** — drag no longer sticks when the cursor leaves the browser window, implemented via real `setPointerCapture` / `releasePointerCapture` (benefits every `RadialKnob` on the site, not just this panel); (6) modal scrim alpha softened (0.3 → 0.15); (7) modal overlay z-index raised above the header and player-dock footer. Fix #5 introduced a **new TypeScript interop module in `DeepDrftShared.Client`**: `Interop/knob/knob.ts` (exports `capturePointer`/`releasePointer`), compiled to `wwwroot/js/knob/knob.js` via `Microsoft.TypeScript.MSBuild`, lazy-imported by `RadialKnob.razor` as `_content/DeepDrftShared.Client/js/knob/knob.js` — following the existing `parallax.ts` precedent in the same RCL.
**Polish round 2 (2026-06-17):** Five further UI changes from Daniel's second review: (1) panel ground darkened further (`--deepdrft-panel-ground` `#1e2028``#1a1c22`); (2) **WAVE-row scroll/zoom control reverted from `MudSlider` back to a `RadialKnob`** — Daniel's explicit call, reversing the §8 slider decision; the scroll control is now a knob like the other dials; (3) **waveform toggle given its own distinct icon** — new `DDIcons.Waveform`/`WaveformFilled` six-bar sound-wave glyph, so the waveform toggle and lava toggle each have a unique visual identity (lava toggle keeps the lamp); (4) **strong active-state styling on both toggles** — green-accent filled chip + ring when ON, dimmed when OFF, making subsystem state unmistakable at a glance; (5) `WaveformVisualizerControlPopover.razor` in-source comment refreshed to describe the `setPointerCapture` mechanism.
---
## Phase 14 — CMS Releases Consolidation (landed 2026-06-17)
**Landed:** 2026-06-17 on dev.
- **What:** Retired the CMS `/tracks` list view and consolidated all release browsing into a new standalone **`/releases`** page (`DeepDrftManager/Components/Pages/Tracks/Releases.razor`). The TRACKS|RELEASES `BrowseMode` toggle is gone. The `/releases` layout is: bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid. The unique per-track waveform-status columns (Profile / High-res, with per-row generate buttons) and the per-track info tooltip (EntryKey + OriginalFileName) now live in `CmsAlbumBrowser`'s expanded child-row track table; page-level bulk runs and per-row generates share a refresh bridge (`InvalidateWaveformStatusAsync` + `OnWaveformGenerated` EventCallback wired through each medium container). The `/catalogue` dashboard cards changed from Tracks / Releases / Genres to **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; `/tracks/genres` was removed. Operational sub-routes (`/tracks/upload`, edit routes, `/tracks/mixes`, `/tracks/sessions`, etc.) stayed at `/tracks/*`. `ICmsTrackService.GetGenreSummariesAsync` removed (dead interface member). `GetTrackCountAsync` intentionally retained — planned for the public-site NowPlayingStats feature.
- **Why:** The `/tracks` page mixed a list view and a releases browser behind a toggle (`BrowseMode`), and the waveform-status columns cluttered a per-track list that had no natural home once releases became the cardinal browse unit. Consolidating into a dedicated `/releases` page with a medium tab strip matches the release-medium mental model established in Phase 9 and makes waveform management a subordinate detail of the release's expanded track table rather than a top-level grid column. Retiring genre browse removes a dead-end CMS surface (genre is a filter, not a first-class browse dimension for the admin).
- **Shape:** New: `DeepDrftManager/Components/Pages/Tracks/Releases.razor` (`@page "/releases"` + alias routes for `/tracks`, `/tracks/albums`, `/tracks/archive`). Deleted: `TrackList.razor`, `CmsTrackGrid.razor` (+ `.css`), `CmsGenreBrowser.razor` (+ `.css`), `Services/CmsTrackBrowserViewModel.cs` (+ its DI registration in `Program.cs`). Changed: `Index.razor` dashboard cards updated to CUTS / SESSIONS / MIXES deep-linking to `/releases?medium=<medium>`; `CmsAlbumBrowser` expanded child-row track table gains waveform-status columns + info tooltip + `OnWaveformGenerated` EventCallback; `ICmsTrackService` / `CmsTrackService``GetGenreSummariesAsync` removed.
---
## Phase 13 — CMS Public Landing (landed 2026-06-17)
**Landed:** 2026-06-17 on dev.
- **What:** Gave `DeepDrftManager` (the CMS) a true public face: an unauthenticated splash at `/` with DeepDrft branding and a single **Login** CTA; authenticated admins are redirected past it to the catalogue. Previously `/` was the `[Authorize]`-gated catalogue dashboard, so an anonymous hit fell straight through to the login form with no front door. Pattern borrowed from the `MainHomeLayout` / `Home.razor` idiom (dedicated public layout + `HierarchicalRoleAuthorizeView` redirect-the-authed-user), branded to the DeepDrft navy/green/off-white identity (`DeepDrftPalettes.Cms`). Additive — the admin experience is intact; only the catalogue's route moved. Routing decision: **Option A — splash owns `/`, catalogue moves to `/catalogue`** (Options B and C were weighed and rejected). New files: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, `CmsHomeLayout`) wraps its body in `<HierarchicalRoleAuthorizeView>`: `Authorized``<RedirectToCatalogue />`; `NotAuthorized` → hero (`img/cms-hero.png`) + Login CTA (returnUrl → `/catalogue`). `Components/Layout/CmsHomeLayout.razor` — lean public layout (`DeepDrftPalettes.Cms` theme, "Deep Drft — Admin" AppBar, centered narrow `MudContainer`, `MudPopoverProvider` only). `Components/RedirectToCatalogue.razor` — inline `NavigationManager.NavigateTo("/catalogue")` redirect, mirroring `RedirectToAccessDenied`. Changed: `Index.razor` route `@page "/"``@page "/catalogue"`; `CmsLayout.razor` "Back to site" home button `Href` and tooltip updated to `/catalogue` / "Catalogue". AppBar wording resolved: "Deep Drft — Admin" in the bar, "Deep Drft" as the hero title.
- **Why:** An anonymous visitor hitting the CMS root landed directly on the AuthBlocks login form with no DeepDrft context, branding, or explanation. The splash provides a proper front door while keeping the admin surface fully intact.
- **Shape:** `DeepDrftManager/Components/Pages/Home.razor` (new); `DeepDrftManager/Components/Layout/CmsHomeLayout.razor` (new); `DeepDrftManager/Components/RedirectToCatalogue.razor` (new); `DeepDrftManager/Components/Pages/Index.razor` (route changed to `/catalogue`); `DeepDrftManager/Components/Layout/CmsLayout.razor` (home-button href + tooltip updated). Hero asset: `DeepDrftManager/wwwroot/img/cms-hero.png` (Daniel-supplied; page compiles and renders without it). Full spec: `product-notes/cms-public-landing.md`.
---
## Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (all tracks landed 2026-06-17)
**Landed:** 2026-06-17 on dev. Six tracks (12.A, 12.B1, 12.B2, 12.E, 12.C, 12.D) plus a bridge live-track fix, all merged.
- **What:** Took the landed Mix WebGL2 lava visualizer (Phase 10 reframe) and made it the one track-cardinal visualizer — serving Mix detail, all Release Detail pages, and the home-page NowPlaying card — rendering the waveform of whatever track is currently playing/selected. Two deliverables: (1) the generalized engine serving three hosting modes, (2) the NowPlayingHero rewire. Full design, extraction analysis, per-track model, Direction B compute, wave decomposition: `product-notes/phase-12-waveform-visualizer-generalization.md`.
- **12.A — Rename to the abstraction.** `MixWaveformVisualizer``WaveformVisualizer`, `MixVisualizerControls``WaveformVisualizerControls`, `MixVisualizerControlState``WaveformVisualizerControlState`, `MixZoomMapping``WaveformZoomMapping`, `MixVisualizer.ts``WaveformVisualizer.ts`. Mechanical rename across the five C#/Razor files + TS module + import path + DI registration. No behavior change; Mix detail identical after.
- **12.B1 — Generalize high-res compute to every track + backfill (Direction B).** `MixWaveformResolution``WaveformResolution`. Vault `mix-waveforms``track-waveforms` (`VaultConstants.TrackWaveforms`), keyed per-track by `EntryKey`. New `WaveformProfileService.ComputeAndStoreHighResAsync` is the shared compute seam — upload path, CMS generate action, and Mix trigger all funnel through it. `UnifiedTrackService.UploadAsync` now computes the high-res datum for every new track. CMS generate action generalized to any track; a re-runnable "backfill high-res" batch action added in the CMS `TrackList`. `WaveformStatusDto.HasHighRes` added alongside the existing `HasProfile`. Backfill is Daniel-gated (CMS batch action; fetch 404s gracefully for not-yet-backfilled tracks).
- **12.B2 — Per-track datum fetch + bridge rewire.** New track-cardinal endpoint `GET api/track/{trackEntryKey}/waveform/high-res` (unauthenticated) + public proxy; `ITrackDataService.GetTrackWaveform`; bridge resolves the current track's `EntryKey` and re-fetches on track change. Client `GetMixWaveform` read path retired; API-side release waveform endpoint kept as a caller-less legacy delegate. Mix renders the same high-res lava via the track-cardinal fetch.
- **12.E — Popover-hosted control panel.** `WaveformVisualizerControls` became the panel content; new `WaveformVisualizerControlPopover` pairs the lava-lamp icon with the panel as overlay content (`MudPopover`). Panel styled to the NowPlaying Hero look from `deepdrft-tokens.css` (no hardcoded hex). A `PanelChrome` flag scopes panel chrome to the popover mount. One popover placed by the lava-lamp icon on every host — full parity across Mix, Cut, Session, and NowPlaying card.
- **Bridge live-track fix.** The visualizer now follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`), not the fixed host `TrackId`.
- **12.C — `Ambient` slot on `ReleaseDetailScaffold` + mount on detail pages (mode B).** New optional `Ambient` slot on `ReleaseDetailScaffold` (full-bleed layer behind content; absent slot = no regression). Cut mounts the ambient visualizer + the lava-lamp icon → popover. Session mounts the engine directly behind its hero (it doesn't compose the scaffold) + the popover. Mix swapped its inline controls bar for the lava-lamp icon → popover, keeping its own full-bleed mode-A mount.
- **12.D — NowPlayingHero rewire (mode C).** `NowPlayingCard` replaced the 20 synthetic CSS bars with a contained `<WaveformVisualizer>` driven by the live cascaded player, pointed at the current track. Added a `Fill` container-sizing mode (CSS-only, defaults off). Placed the lava-lamp icon → popover on the card for full parity. Visualizer runs at-rest on the home page even before playback (deliberate; perf tuning deferred).
- **Why:** The landed Mix visualizer was structurally track-cardinal below the surface (bridge keyed on `TrackId`; renderer a pure function of a loudness datum + duration) but named `Mix*` throughout and restricted to Mix-only data. "Generalize" was a rename + per-track high-res compute extension, not a rebuild. Direction B (high-res for all media) was chosen over the cheaper 512-bucket-fallback Direction A to deliver uniform waveform quality. Controls moved from per-page inline knob bars to a single popover-hosted panel to achieve zero-cost placement on any host including the small NowPlaying card.
- **Shape:** `DeepDrftPublic.Client/Controls/`: `WaveformVisualizer.razor` (+ `.razor.cs`, `.razor.css`) — renamed engine, added `[Parameter] bool Fill`; `WaveformVisualizerControls.razor` — renamed, now panel content with `PanelChrome` flag; `WaveformVisualizerControlPopover.razor` — new, lava-lamp icon + `MudPopover` wrapping the panel; `WaveformZoomMapping.cs` — renamed; `ReleaseDetailScaffold.razor` (+ `.razor.cs`) — new optional `Ambient` `RenderFragment` slot; `NowPlayingCard.razor` — synthetic bars replaced, `<WaveformVisualizer Fill="true">` + `<WaveformVisualizerControlPopover>`. `DeepDrftPublic.Client/Services/`: `WaveformVisualizerControlState.cs` — renamed. `DeepDrftPublic.Client/Pages/`: `CutDetail.razor` — mounts ambient visualizer + popover; `SessionDetail.razor` — mounts engine + popover directly; `MixDetail.razor` — swaps inline controls bar for popover. `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` — renamed TS module. `DeepDrftContent/Processors/`: `WaveformResolution.cs` — renamed; `WaveformProfileService.cs``ComputeAndStoreHighResAsync` added, medium-neutral. `DeepDrftContent/Constants/VaultConstants.cs``TrackWaveforms = "track-waveforms"`. `DeepDrftAPI/Controllers/TrackController.cs``GET api/track/{trackEntryKey}/waveform/high-res` (unauthenticated) + `POST api/track/{trackId}/waveform/high-res` (ApiKey, generalized generate); `WaveformStatusDto.HasHighRes` populated. `DeepDrftAPI/Services/UnifiedTrackService.cs``UploadAsync` now calls `ComputeAndStoreHighResAsync` for every new track. `DeepDrftPublic/Controllers/TrackProxyController.cs` — proxy for the new high-res endpoint.
---
## Phase 10 — Mix Visualizer Reframe: Waves R1R4 (Lava tuning + eight-knob controls)
**Landed:** 2026-06-17 on dev.
+25 -7
View File
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## One-line purpose
Dual-database authority for tracks (SQL metadata + FileDatabase binary), releases (SQL metadata with media-specific satellites), and images (FileDatabase binary); AuthBlocks API host (JWT auth, role/admin seed). Track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing with filters, metadata operations, waveform profiles, and release associations. Release endpoints provide paged listing with medium filter, single-release read, and media-specific operations (mix waveform compute, session hero-image upload). Image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for authenticated endpoints, JWT + AuthBlocks for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
Dual-database authority for tracks (SQL metadata + FileDatabase binary), releases (SQL metadata with media-specific satellites), and images (FileDatabase binary); AuthBlocks API host (JWT auth, role/admin seed). Track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing with filters, metadata operations, waveform profiles (512-bucket player-bar seeker + per-track high-res visualizer datum), and release associations. Release endpoints provide paged listing with medium filter, single-release read, and media-specific operations (session hero-image upload; mix waveform is a caller-less legacy delegate — the track-cardinal `GET api/track/{entryKey}/waveform/high-res` is the live fetch path). Image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for authenticated endpoints, JWT + AuthBlocks for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
## What lives here now (only)
@@ -77,6 +77,23 @@ Admin backfill: computes and stores a waveform profile for an existing track fro
- Fetches audio from vault, decodes it, computes a loudness profile, and stores the profile in the `waveform-profiles` vault.
- Returns 200 on success. Returns 404 if no audio is stored under that key. Returns 500 if WAV decoding or vault write fails.
### GET api/track/{trackId}/waveform/high-res (unauthenticated)
Track-cardinal high-res datum fetch. Returns the per-track duration-derived high-res waveform datum (~333 samples/sec) from the `track-waveforms` vault. This is the live read path for the `WaveformVisualizer` bridge — the release-level mix waveform endpoint is a caller-less legacy delegate.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
- Returns 200 on success. Returns 404 if no high-res datum is stored (graceful — not-yet-backfilled tracks fall back to no visualizer data). Returns 500 on vault error.
### POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
Server-side trigger: compute and store the per-track high-res datum for any track from its vault audio, keyed by `EntryKey` in the `track-waveforms` vault. Drives the CMS per-row "Generate high-res" action and the CMS batch backfill action. Generalised off Mix-only in Phase 12.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- Calls `WaveformProfileService.ComputeAndStoreHighResAsync` via `UnifiedTrackService`.
- Returns 200 on success. Returns 404 if no audio stored under that key. Returns 500 on compute/storage failure.
### GET api/track/meta/by-key/{entryKey} (unauthenticated)
Single track metadata by vault entry key (EntryKey). Unauthenticated, reachable through the public proxy.
@@ -87,10 +104,10 @@ Single track metadata by vault entry key (EntryKey). Unauthenticated, reachable
### GET api/track/waveform-status ([ApiKeyAuthorize])
Admin backfill view: returns every track with a flag indicating whether a waveform profile is stored. Used by the CMS PreProcessing panel to flag tracks needing waveform computation.
Admin backfill view: returns every track with flags indicating whether each waveform type is stored. Used by the CMS track list to flag tracks needing waveform computation.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Response**: `List<WaveformStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, and `HasProfile` (bool).
- **Response**: `List<WaveformStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, `HasProfile` (bool — 512-bucket player-bar seeker profile in `waveform-profiles` vault), and `HasHighRes` (bool — duration-derived high-res visualizer datum in `track-waveforms` vault).
- Returns 200 on success. Returns 500 on query error.
### DELETE api/track/release/{id:long} ([ApiKeyAuthorize])
@@ -228,9 +245,9 @@ Single release with both metadata navs (nulls for non-matching media). Public, s
- **Response**: `ReleaseDto` with `Id`, `EntryKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
### GET api/release/{entryKey}/mix/waveform (unauthenticated)
### GET api/release/{entryKey}/mix/waveform (unauthenticated — caller-less legacy delegate)
Serves the high-res waveform datum for a Mix release as base64-encoded bytes. Mirrors `GET api/track/{id}/waveform` but reads from the `mix-waveforms` vault. Public read — addresses by the release `EntryKey` (§3e).
Legacy endpoint: formerly served the high-res waveform datum for a Mix release from the `mix-waveforms` vault. **No longer called by the client** — the live fetch path is now the track-cardinal `GET api/track/{trackId}/waveform/high-res` (Phase 12). The endpoint is retained in the API but has no active callers. `UnifiedReleaseService.TriggerMixWaveformAsync` now delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` (the same shared seam used by the upload path and the generalized CMS generate action).
- **Route parameter `entryKey`** (string): the release's `EntryKey`.
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
@@ -238,7 +255,7 @@ Serves the high-res waveform datum for a Mix release as base64-encoded bytes. Mi
### POST api/release/{id:long}/mix/waveform ([ApiKeyAuthorize])
Server-side trigger: fetch the Mix's track audio from the vault, compute a 2048-bucket waveform, store it in the `mix-waveforms` vault, and link it via `MixMetadata.WaveformEntryKey`. No request body.
Server-side trigger: fetch the Mix's track audio from the vault, compute the duration-derived high-res datum, store it in the `track-waveforms` vault under the track's `EntryKey`, and link it via `MixMetadata.WaveformEntryKey`. Delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` — the same shared seam used by the upload path and the generalized CMS generate action. No request body.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL release ID.
@@ -289,7 +306,8 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
3. Register `FileDatabase` as singleton.
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
**In `Program.cs`** (SQL + AuthBlocks + wiring):
+16 -9
View File
@@ -67,11 +67,17 @@ public class ReleaseController : ControllerBase
}
// GET api/release/{entryKey}/mix/waveform (unauthenticated)
// Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform
// but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key,
// or no datum is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The
// {entryKey} string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different
// verb + constraint). Declared before the shorter "{entryKey}" route for clarity.
// Serves the high-res waveform datum for a Mix release as base64, reading the Mix's track datum from
// the track-waveforms vault. 404 when the release is not a Mix, carries no waveform key, or no datum
// is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The {entryKey}
// string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different verb +
// constraint). Declared before the shorter "{entryKey}" route for clarity.
//
// LEGACY (phase-12 §5b): the visualizer no longer fetches through this release-addressed route — it
// resolves the current track's datum via the track-cardinal GET api/track/{trackEntryKey}/waveform/
// high-res. This endpoint is retained as a thin transitional delegate (it serves the identical datum,
// since a Mix is single-track) and has no client caller today; remove it once nothing depends on the
// release-addressed shape.
[HttpGet("{entryKey}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
{
@@ -91,7 +97,7 @@ public class ReleaseController : ControllerBase
return NotFound();
}
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.TrackWaveforms);
if (bytes is null)
{
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
@@ -106,9 +112,10 @@ public class ReleaseController : ControllerBase
}
// POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body)
// Server-side trigger: fetch the Mix's track audio from the vault, compute a 2048-bucket waveform,
// store it in the mix-waveforms vault, and set MixMetadata.WaveformEntryKey. 404 when the release is
// missing or has no stored audio; 500 on compute/storage failure. Declared before "{id:long}".
// Server-side trigger: fetch the Mix's track audio from the vault, compute a duration-derived high-res
// waveform via ComputeAndStoreHighResAsync, store it in the track-waveforms vault, and set
// MixMetadata.WaveformEntryKey. 404 when the release is missing or has no stored audio; 500 on
// compute/storage failure. Declared before "{id:long}".
[ApiKeyAuthorize]
[HttpPost("{id:long}/mix/waveform")]
public async Task<ActionResult> GenerateMixWaveform(long id, CancellationToken ct = default)
+59 -2
View File
@@ -138,8 +138,9 @@ public class TrackController : ControllerBase
}
// GET api/track/waveform-status ([ApiKeyAuthorize])
// Admin backfill view: returns every track with a flag for whether a waveform profile is
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
// Admin backfill view: returns every track with flags for whether each waveform datum is stored —
// the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer
// datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
// segment is never treated as a trackId.
[ApiKeyAuthorize]
@@ -158,12 +159,14 @@ public class TrackController : ControllerBase
foreach (var track in tracks.Value)
{
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
status.Add(new WaveformStatusDto
{
TrackId = track.Id,
EntryKey = track.EntryKey,
TrackName = track.TrackName,
HasProfile = profile is not null,
HasHighRes = highRes is not null,
});
}
@@ -574,6 +577,31 @@ public class TrackController : ControllerBase
});
}
// GET api/track/{trackId}/waveform/high-res (unauthenticated)
// Track-cardinal high-res datum fetch (phase-12 §5b): returns the per-track high-res waveform datum
// from the track-waveforms vault, base64-encoded, keyed by EntryKey. This is what the lava visualizer
// fetches for whatever track is currently playing/selected — the release is only addressing context.
// Distinct from GET {trackId}/waveform (the 512-bucket player-bar profile in the default vault): the
// "high-res" suffix selects the duration-derived TrackWaveforms datum. 404 when no high-res datum is
// stored (a track not yet backfilled — the visualizer blanks gracefully). Declared before the
// parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
[HttpGet("{trackId}/waveform/high-res")]
public async Task<ActionResult> GetHighResWaveform(string trackId)
{
var bytes = await _waveformProfileService.GetProfileAsync(trackId, VaultConstants.TrackWaveforms);
if (bytes is null)
{
_logger.LogInformation("No high-res waveform datum for track: {TrackId}", trackId);
return NotFound();
}
return Ok(new WaveformProfileDto
{
BucketCount = bytes.Length,
Data = Convert.ToBase64String(bytes),
});
}
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
// Admin backfill: compute and store a waveform profile for an existing track from its vault
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
@@ -600,6 +628,35 @@ public class TrackController : ControllerBase
return Ok();
}
// POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
// Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store
// the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the
// track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill.
// Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio
// is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared
// before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
[ApiKeyAuthorize]
[HttpPost("{trackId}/waveform/high-res")]
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
{
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
if (audio is null)
{
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
return NotFound();
}
var stored = await _waveformProfileService.ComputeAndStoreHighResAsync(
audio.Buffer, trackId, audio.Duration);
if (!stored)
{
_logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
return StatusCode(500, "Failed to generate high-res waveform datum.");
}
return Ok();
}
[ApiKeyAuthorize]
[HttpPut("{trackId}")]
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
@@ -102,9 +102,12 @@ public class UnifiedReleaseService
/// <summary>
/// Fetch the Mix's track audio from the vault, compute a high-res waveform datum at a constant time
/// resolution (≈333 samples/sec derived from the track's duration; see
/// <see cref="MixWaveformResolution"/>), store it in the MixWaveforms vault under the track's
/// <see cref="WaveformResolution"/>), store it in the TrackWaveforms vault under the track's
/// EntryKey, then point the release's Mix satellite at that same key. The datum key equals the
/// track's EntryKey — the Mix is single-track.
/// track's EntryKey — the Mix is single-track. Under the per-track model (phase-12 §5) this is the
/// same datum every track now carries. The visualizer fetches it via the track-cardinal
/// <c>GET api/track/{trackEntryKey}/waveform/high-res</c> (12.B2); the Mix satellite link and the
/// legacy release-addressed read path are retained transitionally and no longer feed the visualizer.
/// </summary>
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
{
@@ -148,10 +151,10 @@ public class UnifiedReleaseService
}
// Duration-derived, constant-time-resolution capture (≈333 samples/sec) so long mixes are not
// under-sampled by a fixed bucket count — see MixWaveformResolution / spec §F.
var bucketCount = MixWaveformResolution.BucketCountForDuration(audio.Duration);
var computed = await _waveformProfileService.ComputeAndStoreAsync(
audio.Buffer, entryKey, bucketCount, VaultConstants.MixWaveforms);
// under-sampled by a fixed bucket count — see WaveformResolution / spec §F. Same per-track
// high-res datum every track now carries (phase-12 §5).
var computed = await _waveformProfileService.ComputeAndStoreHighResAsync(
audio.Buffer, entryKey, audio.Duration);
if (!computed)
{
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
+21 -7
View File
@@ -158,24 +158,38 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
// Best-effort waveform profile: both stores succeeded, so the upload is a success
// regardless of the profile outcome. A missing profile renders as a flat seekbar on the
// Best-effort waveform datums: both stores succeeded, so the upload is a success regardless of
// the datum outcome. A missing datum renders as a flat seekbar / blank visualizer on the
// frontend, so a failure here is logged and swallowed — never fails the upload.
await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct);
await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct);
return saveResult;
}
private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct)
// Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile
// the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer
// consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same
// audio: read it back from the vault once (the authoritative parsed duration + the stored buffer)
// rather than re-reading and re-parsing the temp file. Best-effort throughout — never fails upload.
private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
{
try
{
var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey);
var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
if (audio is null)
{
_logger.LogWarning(
"Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
entryKey);
return;
}
await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey);
await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey);
_logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey);
}
}
+6 -5
View File
@@ -43,7 +43,7 @@ namespace DeepDrftAPI
if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult();
InitializeImageVault(db).GetAwaiter().GetResult();
InitializeMixWaveformsVault(db).GetAwaiter().GetResult();
InitializeTrackWaveformsVault(db).GetAwaiter().GetResult();
return db;
});
@@ -66,12 +66,13 @@ namespace DeepDrftAPI
}
}
// Ensure the mix-waveforms vault exists. Holds high-resolution waveform datums for DJ Mix releases.
private static async Task InitializeMixWaveformsVault(FileDatabase fileDatabase)
// Ensure the track-waveforms vault exists. Holds the per-track high-resolution waveform datum
// (every track — Mix, Session, Cut), keyed by the track's EntryKey.
private static async Task InitializeTrackWaveformsVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.MixWaveforms))
if (!fileDatabase.HasVault(VaultConstants.TrackWaveforms))
{
await fileDatabase.CreateVaultAsync(VaultConstants.MixWaveforms, MediaVaultType.Media);
await fileDatabase.CreateVaultAsync(VaultConstants.TrackWaveforms, MediaVaultType.Media);
}
}
}
+3 -1
View File
@@ -82,6 +82,8 @@ Multi-format support via router pattern. All processors live in `DeepDrftContent
- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Skips ID3v2 tag, finds first valid MPEG frame sync, decodes frame header (bitrate, sample rate, channels). Reads Xing/Info header for VBR total-frame count (accurate duration); VBRI header as fallback; CBR estimate from file size otherwise. Returns `AudioBinary` with original bytes and `.mp3` extension. On parse failure, falls back to defaults (180s / 320 kbps).
- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Validates `fLaC` magic, reads STREAMINFO metadata block (20-bit sample rate, 3-bit channels, 5-bit bits-per-sample, 36-bit total samples — all bit-packed). Computes duration from `totalSamples / sampleRate`; average bitrate from file size. Returns `AudioBinary` with original bytes and `.flac` extension. On parse failure, falls back to defaults (180s / 1411 kbps).
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav``AudioProcessor`, `.mp3``Mp3AudioProcessor`, `.flac``FlacAudioProcessor`. Throws `ArgumentException` for unsupported extensions.
- `WaveformProfileService.ComputeAndStoreHighResAsync(entryKey)`: The shared compute seam for the duration-derived high-res waveform datum (~333 samples/sec). Medium-neutral — computes for any track by `EntryKey`, stores in the `track-waveforms` vault. Called by the upload path (`UnifiedTrackService.UploadAsync` for every new track), the CMS per-row generate action, and the Mix release trigger (now a legacy delegate). Phase 12 generalization of the former Mix-only compute.
- `WaveformResolution`: Enum / constants controlling bucket density for the high-res compute. Renamed from `MixWaveformResolution` in Phase 12.
Vault stores original bytes with correct extension and MIME type (inferred from file extension or content-type header at upload time).
@@ -120,7 +122,7 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
## Vault constants
`VaultConstants.Tracks = "tracks"` and `VaultConstants.Images = "images"` — the vault names in production use. New vault names go here when adding new vault types.
`VaultConstants.Tracks = "tracks"`, `VaultConstants.Images = "images"`, and `VaultConstants.TrackWaveforms = "track-waveforms"` — the vault names in production use. `TrackWaveforms` holds the per-track high-res waveform datum keyed by `TrackEntity.EntryKey` (Phase 12; renamed from the former `mix-waveforms`, which was Mix-only). New vault names go here when adding new vault types.
## Service registration
+4 -2
View File
@@ -22,8 +22,10 @@ public static class VaultConstants
public const string Images = "images";
/// <summary>
/// Vault name for Mix high-resolution waveform datums, keyed by the mix track's EntryKey.
/// Vault name for per-track high-resolution waveform datums, keyed by the track's EntryKey.
/// Every track (Mix, Session, Cut) carries one — computed at upload, regenerable on demand.
/// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution.
/// The datum resolution is duration-derived (≈333 samples/sec, see <c>WaveformResolution</c>).
/// </summary>
public const string MixWaveforms = "mix-waveforms";
public const string TrackWaveforms = "track-waveforms";
}
@@ -42,9 +42,9 @@ public class WaveformProfileService
/// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
/// callers pass an explicit count for higher-resolution data — e.g. the Mix datum derives its count
/// from the audio duration (≈333 samples/sec, see <c>MixWaveformResolution</c>) so long mixes are not
/// under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
/// callers pass an explicit count for higher-resolution data — e.g. the per-track high-res datum
/// derives its count from the audio duration (≈333 samples/sec, see <c>WaveformResolution</c>) so long
/// tracks are not under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
/// does not itself decide the count. Returns false (and logs) on any
/// failure — a missing profile is handled gracefully downstream, so callers on the upload path
/// log-and-continue rather than failing the upload. Does not throw for expected failure modes.
@@ -99,6 +99,24 @@ public class WaveformProfileService
}
}
/// <summary>
/// Computes a track's high-resolution loudness datum and stores it in the
/// <see cref="VaultConstants.TrackWaveforms"/> vault keyed by <paramref name="entryKey"/>. The bucket
/// count is duration-derived (≈333 samples/sec, clamped — see <see cref="WaveformResolution"/>) so the
/// datum captures at a constant time resolution regardless of track length. This is the single home
/// for "the high-res per-track datum" — the upload path, the CMS generate action, and the Mix trigger
/// all funnel through it, so every track (Mix, Session, Cut) gets an identical datum keyed the same way.
/// Returns false (logged) on any failure, per the content-agnostic contract above.
/// </summary>
public Task<bool> ComputeAndStoreHighResAsync(
ReadOnlyMemory<byte> wavBytes,
string entryKey,
double durationSeconds)
{
var bucketCount = WaveformResolution.BucketCountForDuration(durationSeconds);
return ComputeAndStoreAsync(wavBytes, entryKey, bucketCount, VaultConstants.TrackWaveforms);
}
/// <summary>
/// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
/// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
@@ -1,40 +1,41 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Derives the bucket count for a Mix loudness datum from the audio's duration, so the stored
/// profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
/// Derives the bucket count for a track's high-resolution loudness datum from the audio's duration, so
/// the stored profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
/// Applies to every track (Mix, Session, Cut) — the release is just the host (phase-12 §5).
///
/// Rationale (phase-9 Mix Visualizer redesign spec §F): the max-zoom window shows one quarter note
/// at 180 BPM = 333 ms of audio, and a smooth glassy curve wants ~100+ sample points across that
/// window. A fixed 2048-bucket datum gives fractions of a sample per 333 ms window on any real-length
/// mix (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of mix
/// audio (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of
/// length — the direct expression of "high enough resolution regardless of content length."
///
/// This is the orchestration-side derivation (duration → bucket count); the actual compute/store stays
/// in <see cref="WaveformProfileService"/>, which is content-agnostic and parameterized by bucket count.
/// </summary>
public static class MixWaveformResolution
public static class WaveformResolution
{
/// <summary>≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples.</summary>
public const int SamplesPerSecond = 333;
/// <summary>
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute mix at 333/s). Past this length we
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute track at 333/s). Past this length we
/// accept slightly-below-target density rather than an unbounded datum (spec §F mitigation #1).
/// </summary>
public const int MaxBucketCount = 2_000_000;
/// <summary>
/// Floor on bucket count. Keeps the historical 2048-bucket density as the minimum so a degenerate
/// near-zero or very-short mix still yields a usable profile rather than zero/handful of buckets.
/// near-zero or very-short track still yields a usable profile rather than zero/handful of buckets.
/// </summary>
public const int MinBucketCount = 2048;
/// <summary>
/// Maps a track's duration (seconds) to a bucket count of <c>ceil(durationSeconds × 333)</c>,
/// clamped to [<see cref="MinBucketCount"/>, <see cref="MaxBucketCount"/>]. Non-finite or negative
/// durations fall to the floor. A 60-minute mix → ~1.2M buckets; a 3-minute mix → ~60k.
/// durations fall to the floor. A 60-minute track → ~1.2M buckets; a 3-minute track → ~60k.
/// </summary>
public static int BucketCountForDuration(double durationSeconds)
{
@@ -0,0 +1,26 @@
@inherits LayoutComponentBase
@using DeepDrftShared.Client.Common
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
<MudPopoverProvider />
<MudLayout>
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
Deep Drft — Admin
</MudText>
</MudAppBar>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Small"
Class="d-flex flex-column justify-center align-center"
Style="min-height: calc(100vh - 48px);">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@@ -12,9 +12,9 @@
Deep Drft — Admin
</MudText>
<MudSpacer />
<MudTooltip Text="Back to site">
<MudTooltip Text="Catalogue">
<MudIconButton Icon="@Icons.Material.Filled.Home"
Href="/"
Href="/catalogue"
Color="Color.Inherit" />
</MudTooltip>
</MudAppBar>
@@ -0,0 +1,31 @@
@page "/"
@layout Layout.CmsHomeLayout
<PageTitle>Deep Drft — Admin</PageTitle>
<HierarchicalRoleAuthorizeView>
<Authorized>
<RedirectToCatalogue />
</Authorized>
<NotAuthorized>
<MudStack AlignItems="AlignItems.Center" Spacing="4" Class="my-8">
<MudImage Fluid="true" Src="img/cms-hero.png" Alt="Deep Drft" />
<MudText Typo="Typo.h2" Align="Align.Center">Deep Drft</MudText>
<MudText Typo="Typo.subtitle1" Align="Align.Center" Class="text-uppercase mud-text-secondary">
Catalogue Management
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Href="@LoginHref"
Class="mt-4"
Style="min-width: 200px;">
Login
</MudButton>
</MudStack>
</NotAuthorized>
</HierarchicalRoleAuthorizeView>
@code {
private static readonly string LoginHref =
$"/account/login?returnUrl={Uri.EscapeDataString("catalogue")}";
}
+49 -71
View File
@@ -1,9 +1,10 @@
@page "/"
@page "/catalogue"
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@attribute [Authorize]
@layout Layout.CmsLayout
@inject NavigationManager Nav
@inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject ILogger<Index> Logger
<PageTitle>DeepDrft CMS</PageTitle>
@@ -12,114 +13,91 @@
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
<MudGrid Spacing="4">
<MudItem xs="12" sm="4">
@SummaryCard("Tracks", Icons.Material.Filled.LibraryMusic, Color.Primary, _tracksLoading, _trackCount)
</MudItem>
<MudItem xs="12" sm="4">
@SummaryCard("Releases", Icons.Material.Filled.Album, Color.Secondary, _albumsLoading, _albumCount)
</MudItem>
<MudItem xs="12" sm="4">
@SummaryCard("Genres", Icons.Material.Filled.Category, Color.Tertiary, _genresLoading, _genreCount)
</MudItem>
@foreach (var card in Cards)
{
<MudItem xs="12" sm="4">
@SummaryCard(card)
</MudItem>
}
</MudGrid>
</MudContainer>
@code {
private bool _tracksLoading = true;
private bool _albumsLoading = true;
private bool _genresLoading = true;
// One card per release medium. Each deep-links to /releases with the medium tab pre-selected via the
// same ?medium= convention the Add Track buttons use. The count is that medium's release total.
private sealed record MediumCard(ReleaseMedium Medium, string Label, string Icon, Color Color);
private int? _trackCount;
private int? _albumCount;
private int? _genreCount;
private static readonly IReadOnlyList<MediumCard> Cards = new[]
{
new MediumCard(ReleaseMedium.Cut, "CUTS", Icons.Material.Filled.Album, Color.Primary),
new MediumCard(ReleaseMedium.Session, "SESSIONS", Icons.Material.Filled.Mic, Color.Secondary),
new MediumCard(ReleaseMedium.Mix, "MIXES", Icons.Material.Filled.GraphicEq, Color.Tertiary),
};
// Medium → release count (null while loading or on failure). Each medium's count is one cheap paged
// read (pageSize 1) for its TotalCount, run concurrently.
private readonly Dictionary<ReleaseMedium, int?> _counts = new();
private readonly HashSet<ReleaseMedium> _loading = Cards.Select(c => c.Medium).ToHashSet();
protected override async Task OnInitializedAsync()
{
// Three independent reads run concurrently. Each loader calls StateHasChanged in its
// finally block so its card updates as soon as its own fetch returns.
await Task.WhenAll(LoadTrackCount(), LoadAlbumCount(), LoadGenreCount());
// Each loader calls StateHasChanged in its finally block so its card updates as soon as its own
// fetch returns, rather than blocking on the slowest of the three.
await Task.WhenAll(Cards.Select(c => LoadCountAsync(c.Medium)));
}
private async Task LoadTrackCount()
private async Task LoadCountAsync(ReleaseMedium medium)
{
try
{
var result = await CmsTrackService.GetTrackCountAsync();
_trackCount = result.Success ? result.Value : null;
// pageSize 1 — we only need TotalCount, not the rows. Sort column is required by the API but
// immaterial to the count.
var result = await CmsReleaseService.GetPagedAsync(
medium, page: 1, pageSize: 1, sortColumn: "Title", sortDescending: false);
_counts[medium] = result.Success && result.Value is not null ? result.Value.TotalCount : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard track count failed: {Error}",
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
Logger.LogWarning("Dashboard {Medium} count failed: {Error}",
medium, result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_tracksLoading = false;
_loading.Remove(medium);
StateHasChanged();
}
}
private async Task LoadAlbumCount()
{
try
{
var result = await CmsTrackService.GetReleasesAsync();
_albumCount = result.Success && result.Value is not null ? result.Value.Count : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard album summaries failed: {Error}",
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_albumsLoading = false;
StateHasChanged();
}
}
private async Task LoadGenreCount()
{
try
{
var result = await CmsTrackService.GetGenreSummariesAsync();
_genreCount = result.Success && result.Value is not null ? result.Value.Count : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard genre summaries failed: {Error}",
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_genresLoading = false;
StateHasChanged();
}
}
private RenderFragment SummaryCard(string label, string icon, Color color, bool loading, int? count) => __builder =>
private RenderFragment SummaryCard(MediumCard card) => __builder =>
{
var loading = _loading.Contains(card.Medium);
var count = _counts.GetValueOrDefault(card.Medium);
<MudCard Elevation="8" Style="height: 100%;">
<MudCardContent>
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
<MudIcon Icon="@icon" Color="@color" Size="Size.Large" />
<MudIcon Icon="@card.Icon" Color="@card.Color" Size="Size.Large" />
@if (loading)
{
<MudProgressCircular Color="@color" Indeterminate="true" Size="Size.Small" />
<MudProgressCircular Color="@card.Color" Indeterminate="true" Size="Size.Small" />
}
else
{
<MudText Typo="Typo.h3" Color="@color">@(count?.ToString() ?? "—")</MudText>
<MudText Typo="Typo.h3" Color="@card.Color">@(count?.ToString() ?? "—")</MudText>
}
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@label</MudText>
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@card.Label</MudText>
</MudStack>
</MudCardContent>
<MudCardActions Class="justify-center pb-4">
<MudButton Variant="Variant.Text" Color="@color" EndIcon="@Icons.Material.Filled.ArrowForward"
OnClick="@(() => Nav.NavigateTo("/tracks"))">
<MudButton Variant="Variant.Text" Color="@card.Color" EndIcon="@Icons.Material.Filled.ArrowForward"
OnClick="@(() => Nav.NavigateTo(ReleasesHref(card.Medium)))">
View
</MudButton>
</MudCardActions>
</MudCard>
};
// Deep-link to the Releases page with this medium's tab pre-selected. Mirrors the ?medium= seed the
// Add Track buttons use; the Releases page reads it to set the active tab.
private static string ReleasesHref(ReleaseMedium medium) =>
$"/releases?medium={medium.ToString().ToLowerInvariant()}";
}
@@ -8,7 +8,6 @@
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject CmsTrackBrowserViewModel VM
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -87,7 +86,7 @@
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
<MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks/albums"))"
OnClick="@(() => Navigation.NavigateTo("/releases"))"
Disabled="_saving">
Cancel
</MudButton>
@@ -476,9 +475,26 @@
{
// New track — upload, then link cover art with a follow-up update (same
// two-step pattern as BatchUpload; the upload endpoint takes no imagePath).
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
row.UploadedBytes = 0;
row.TotalBytes = row.WavFile!.Size;
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
// Re-render only on whole-percent change so a large upload paints ~100 frames,
// not thousands. Progress<T> marshals back onto the renderer dispatcher.
var lastPercent = -1;
var progress = new Progress<long>(written =>
{
row.UploadedBytes = written;
if (row.UploadPercent != lastPercent)
{
lastPercent = row.UploadPercent;
StateHasChanged();
}
});
var uploadResult = await CmsTrackService.UploadTrackAsync(
wavStream,
row.WavFile.Size,
row.WavFile.Name,
row.WavFile.ContentType,
row.TrackName,
@@ -491,7 +507,8 @@
createdByUserId,
_releaseType,
trackNumber,
_medium);
_medium,
progress);
if (!uploadResult.Success || uploadResult.Value is null)
{
@@ -546,15 +563,10 @@
StateHasChanged();
}
// Either branch changed catalogue data, so the browse caches are stale regardless of
// whether every track saved. Invalidate before navigating (or staying) so the /tracks
// album and genre lists re-fetch.
VM.Invalidate();
if (failed == 0)
{
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
Navigation.NavigateTo("/tracks/albums");
Navigation.NavigateTo("/releases");
}
else
{
@@ -27,6 +27,17 @@ public class BatchRowModel
public BatchRowStatus Status { get; set; } = BatchRowStatus.Queued;
public string? ErrorMessage { get; set; }
/// <summary>Bytes pushed to the wire so far for this row's in-flight upload. Reset per attempt.</summary>
public long UploadedBytes { get; set; }
/// <summary>Total payload bytes for this row (the WAV file size), the progress denominator.</summary>
public long TotalBytes { get; set; }
/// <summary>Upload completion as a 0100 percent, or 0 when the total is unknown.</summary>
public int UploadPercent => TotalBytes > 0
? (int)Math.Clamp(UploadedBytes * 100 / TotalBytes, 0, 100)
: 0;
}
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
@@ -41,6 +41,13 @@
OnClick="@(() => OnRemove.InvokeAsync(index))"
aria-label="Remove track" />
</MudStack>
@if (row.Status == BatchRowStatus.Uploading)
{
<MudProgressLinear Color="Color.Info"
Value="@row.UploadPercent"
Class="mx-2 mb-2"
aria-label="@($"Uploading {row.TrackName}")" />
}
</div>
}
</MudList>
@@ -11,7 +11,6 @@
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILogger<BatchUpload> Logger
@inject CmsTrackBrowserViewModel VM
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
@@ -64,6 +63,12 @@
{
@* Track name is derived from the Release Name for Session/Mix — no separate input. *@
<MudText Typo="Typo.caption">Selected: @(_tracks[0].WavFile?.Name ?? "—")</MudText>
@if (_tracks[0].Status == BatchRowStatus.Uploading)
{
<MudProgressLinear Color="Color.Info"
Value="@_tracks[0].UploadPercent"
aria-label="Uploading track" />
}
}
</MudStack>
</MudPaper>
@@ -81,7 +86,7 @@
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
<MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
OnClick="@(() => Navigation.NavigateTo("/releases"))"
Disabled="_uploading">
Cancel
</MudButton>
@@ -330,14 +335,33 @@
row.Status = BatchRowStatus.Uploading;
StateHasChanged();
row.UploadedBytes = 0;
row.TotalBytes = row.WavFile!.Size;
try
{
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
// service wraps it in StreamContent so the whole file is never materialised in
// memory before DeepDrftAPI receives it.
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
// service wraps it in ProgressStreamContent so the whole file is never materialised
// in memory before DeepDrftAPI receives it, and reports bytes-on-the-wire back here.
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
// Progress ticks fire ~once per 80 KB; re-render only when the whole-percent changes
// so a half-gig upload paints ~100 frames, not thousands. Progress<T> marshals the
// callback onto the component's renderer dispatcher, so StateHasChanged is safe here.
var lastPercent = -1;
var progress = new Progress<long>(written =>
{
row.UploadedBytes = written;
if (row.UploadPercent != lastPercent)
{
lastPercent = row.UploadPercent;
StateHasChanged();
}
});
var result = await CmsTrackService.UploadTrackAsync(
wavStream,
row.WavFile.Size,
row.WavFile.Name,
row.WavFile.ContentType,
row.TrackName,
@@ -350,7 +374,8 @@
createdByUserId,
_releaseType,
trackNumber,
_medium);
_medium,
progress);
if (!result.Success || result.Value is null)
{
@@ -458,8 +483,7 @@
if (failed == 0)
{
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
VM.Invalidate();
Navigation.NavigateTo("/tracks");
Navigation.NavigateTo("/releases");
}
else
{
@@ -114,10 +114,71 @@ else
<HeaderContent>
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
<MudTh>Track Name</MudTh>
@* Per-track waveform-datum status + generate (migrated from the retired
CmsTrackGrid). The expanded child row is the releases view's only
per-track surface, so the unique per-track Profile / High-res columns
live here. *@
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
@* Info column: per-track EntryKey + OriginalFileName tooltip (migrated
from the retired CmsTrackGrid's .cms-track-info monospace block). *@
<MudTh Style="width: 1%;"></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
<MudTd DataLabel="Profile">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (HasProfile(track.EntryKey))
{
<MudTooltip Text="Profile generated">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</MudTooltip>
}
<MudTooltip Text="@(HasProfile(track.EntryKey) ? "Regenerate profile" : "Generate profile")">
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
Size="Size.Small"
Color="Color.Secondary"
Disabled="@_generating.Contains(track.EntryKey)"
OnClick="@(() => GenerateProfileAsync(track))" />
</MudTooltip>
</MudStack>
</MudTd>
<MudTd DataLabel="High-res">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (HasHighRes(track.EntryKey))
{
<MudTooltip Text="High-res datum generated">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</MudTooltip>
}
<MudTooltip Text="@(HasHighRes(track.EntryKey) ? "Regenerate high-res datum" : "Generate high-res datum")">
<MudIconButton Icon="@Icons.Material.Filled.Waves"
Size="Size.Small"
Color="Color.Secondary"
Disabled="@_generatingHighRes.Contains(track.EntryKey)"
OnClick="@(() => GenerateHighResAsync(track))" />
</MudTooltip>
</MudStack>
</MudTd>
@* Per-track info tooltip (restored from the retired CmsTrackGrid's
.cms-track-info monospace block): EntryKey + OriginalFileName. *@
<MudTd>
<MudTooltip Placement="Placement.Left">
<TooltipContent>
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.EntryKey</MudText>
@if (!string.IsNullOrWhiteSpace(track.OriginalFileName))
{
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.OriginalFileName</MudText>
}
</TooltipContent>
<ChildContent>
<MudIconButton Icon="@Icons.Material.Outlined.Info"
Size="Size.Small"
Color="Color.Default" />
</ChildContent>
</MudTooltip>
</MudTd>
</RowTemplate>
</MudTable>
}
@@ -133,6 +194,26 @@ else
[Parameter] public bool IsLoading { get; set; }
[Parameter] public EventCallback OnReleasesChanged { get; set; }
/// <summary>
/// Fires after any per-row waveform generate (profile or high-res) succeeds. The parent page
/// wires this to its own <c>RefreshWaveformStatusAsync</c> so its missing-count badges stay
/// current after an individual-row generate inside an expanded album row.
/// </summary>
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
/// <summary>
/// Clears the cached per-track waveform status so the next row expand re-fetches fresh data
/// from the API. Called by the parent page after a catalogue-wide bulk run so already-expanded
/// rows reflect the new state on the next expand interaction.
/// </summary>
public Task InvalidateWaveformStatusAsync()
{
_profileStatus = null;
_highResStatus = null;
StateHasChanged();
return Task.CompletedTask;
}
// Zero or more dedicated, header-labelled special-action columns (Session hero upload, Mix waveform
// generate), each rendered as its own header cell + per-row cell between the Tracks and Actions
// columns. The ALL and Cut tabs leave this empty and render exactly as before — only the standard
@@ -181,6 +262,103 @@ else
[ReleaseMedium.Mix] = "DJ Mix",
};
// EntryKey → HasProfile / HasHighRes for the expanded-row per-track waveform columns (migrated from
// the retired CmsTrackGrid). Loaded once per grid instance on first row expand; a per-row generate
// flips a single entry to true. Null until first loaded.
private Dictionary<string, bool>? _profileStatus;
private Dictionary<string, bool>? _highResStatus;
private readonly HashSet<string> _generating = new();
private readonly HashSet<string> _generatingHighRes = new();
private bool HasProfile(string entryKey) =>
_profileStatus is not null && _profileStatus.TryGetValue(entryKey, out var has) && has;
private bool HasHighRes(string entryKey) =>
_highResStatus is not null && _highResStatus.TryGetValue(entryKey, out var has) && has;
// Fetch the catalogue-wide waveform status once and cache it. The admin catalogue is small (one unpaged
// call covers it), and per-track status only matters for rows the admin actually expands.
private async Task EnsureWaveformStatusAsync()
{
if (_profileStatus is not null) return;
var result = await CmsTrackService.GetWaveformStatusAsync();
if (result.Success && result.Value is not null)
{
_profileStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile);
_highResStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasHighRes);
}
else
{
// Leave both empty (not null) so we do not re-fetch on every expand after a transient failure;
// the next OnReleasesChanged refresh path will rebuild the grid and retry.
_profileStatus = new Dictionary<string, bool>();
_highResStatus = new Dictionary<string, bool>();
}
}
private async Task GenerateProfileAsync(TrackDto track)
{
_generating.Add(track.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
if (result.Success)
{
(_profileStatus ??= new())[track.EntryKey] = true;
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
await OnWaveformGenerated.InvokeAsync();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
}
finally
{
_generating.Remove(track.EntryKey);
StateHasChanged();
}
}
private async Task GenerateHighResAsync(TrackDto track)
{
_generatingHighRes.Add(track.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateHighResWaveformAsync(track.EntryKey);
if (result.Success)
{
(_highResStatus ??= new())[track.EntryKey] = true;
Snackbar.Add($"Generated high-res datum for '{track.TrackName}'.", Severity.Success);
await OnWaveformGenerated.InvokeAsync();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"High-res generate failed for '{track.TrackName}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", track.EntryKey);
Snackbar.Add($"High-res generate failed for '{track.TrackName}' — please try again.", Severity.Error);
}
finally
{
_generatingHighRes.Remove(track.EntryKey);
StateHasChanged();
}
}
private async Task ToggleExpand(AlbumRow row)
{
row.IsExpanded = !row.IsExpanded;
@@ -189,6 +367,9 @@ else
row.IsLoading = true;
StateHasChanged();
row.Tracks = await LoadTracksAsync(row.Release.Title);
// The per-track Profile / High-res columns need waveform status for the rows just loaded.
// Loaded once for the catalogue on first expand and cached for this grid instance.
await EnsureWaveformStatusAsync();
row.IsLoading = false;
}
}
@@ -8,9 +8,11 @@
own data load so a host (TrackList today, the 8.A tab strip later) renders it with no parameters and
no VM plumbing. Re-loads on first render and re-fetches after a row mutation so the list stays in
sync with the catalogue. *@
<CmsAlbumBrowser Releases="_releases"
<CmsAlbumBrowser @ref="_albumBrowser"
Releases="_releases"
IsLoading="_loading"
OnReleasesChanged="OnGridReleasesChanged" />
OnReleasesChanged="OnGridReleasesChanged"
OnWaveformGenerated="OnWaveformGenerated" />
@code {
// Fires after a row mutation (delete) so a host can invalidate sibling caches derived from the same
@@ -18,9 +20,23 @@
// notification, not the data source. Optional: an embed that has no sibling state leaves it unset.
[Parameter] public EventCallback OnReleasesChanged { get; set; }
/// <summary>
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
/// </summary>
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
private CmsAlbumBrowser? _albumBrowser;
private IReadOnlyList<ReleaseDto> _releases = Array.Empty<ReleaseDto>();
private bool _loading = true;
/// <summary>
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
/// Called by the parent page after a catalogue-wide bulk run.
/// </summary>
public Task InvalidateWaveformStatusAsync() =>
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
protected override Task OnInitializedAsync() => ReloadAsync();
private async Task OnGridReleasesChanged()
@@ -6,17 +6,34 @@
tab carries expand-tracks, delete, the Type chip, and per-row edit identically to the ALL tab — no
forked grid. Cuts have no medium-specific action, so no SpecialColumns are supplied; the grid renders
its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
<CmsAlbumBrowser Releases="Releases"
<CmsAlbumBrowser @ref="_albumBrowser"
Releases="Releases"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync" />
OnReleasesChanged="ReloadAsync"
OnWaveformGenerated="OnWaveformGenerated" />
@code {
/// <summary>
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
/// </summary>
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
private CmsAlbumBrowser? _albumBrowser;
protected override ReleaseMedium Medium => ReleaseMedium.Cut;
protected override string MediumNoun => "cuts";
protected override CutRow ToRow(ReleaseDto release) => new() { Release = release };
protected override ReleaseDto ReleaseOf(CutRow row) => row.Release;
/// <summary>
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
/// Called by the parent page after a catalogue-wide bulk run.
/// </summary>
public Task InvalidateWaveformStatusAsync() =>
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
public sealed class CutRow
{
public required ReleaseDto Release { get; set; }
@@ -1,52 +0,0 @@
@using DeepDrftModels.DTOs
@if (IsLoading)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (Genres.Count == 0)
{
<MudText Typo="Typo.body1" Class="mt-4">No genres found.</MudText>
}
else
{
<MudGrid Spacing="3" Class="mt-2">
@foreach (var genre in Genres)
{
var isExpanded = ExpandedGenre == genre.Genre;
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="@(isExpanded ? 4 : 1)"
Style="cursor: pointer;"
@onclick="@(() => ToggleGenre(genre.Genre))">
<div class="@SwatchClass(isExpanded)"></div>
<MudCardContent>
<MudText Typo="Typo.h6">@genre.Genre</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@genre.TrackCount track(s)</MudText>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@if (ExpandedGenre is not null)
{
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" Class="mb-2">@ExpandedGenre</MudText>
<CmsTrackGrid @key="ExpandedGenre" GenreFilter="@ExpandedGenre" ShowAddButton="false" />
}
}
@code {
[Parameter] public IReadOnlyList<GenreSummaryDto> Genres { get; set; } = Array.Empty<GenreSummaryDto>();
[Parameter] public bool IsLoading { get; set; }
[Parameter] public string? ExpandedGenre { get; set; }
[Parameter] public EventCallback<string?> OnExpandedGenreChanged { get; set; }
// The view model owns the toggle (selecting the open genre collapses it), so we pass the raw
// clicked genre rather than pre-computing the next state here — keeps the toggle logic single-sourced.
private async Task ToggleGenre(string genre) =>
await OnExpandedGenreChanged.InvokeAsync(genre);
private static string SwatchClass(bool isExpanded) =>
isExpanded ? "cms-genre-swatch cms-genre-swatch--active" : "cms-genre-swatch";
}
@@ -1,10 +0,0 @@
.cms-genre-swatch {
width: 100%;
height: 80px;
background-color: var(--mud-palette-action-default-hover);
transition: background-color 0.2s ease;
}
.cms-genre-swatch--active {
background-color: var(--mud-palette-primary-hover);
}
@@ -23,9 +23,9 @@ else
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Href="/releases"
Class="mb-4">
Back to Release Archive
Back to Releases
</MudButton>
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
@@ -41,9 +41,24 @@ else
/// </summary>
[Parameter] public bool Embedded { get; set; }
/// <summary>
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
/// </summary>
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
private CmsAlbumBrowser? _albumBrowser;
protected override ReleaseMedium Medium => ReleaseMedium.Mix;
protected override string MediumNoun => "mixes";
/// <summary>
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
/// Called by the parent page after a catalogue-wide bulk run.
/// </summary>
public Task InvalidateWaveformStatusAsync() =>
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
protected override MixRow ToRow(ReleaseDto release) => new()
{
Release = release,
@@ -56,9 +71,11 @@ else
// both branches above render the same markup without duplication. The Mix declares one dedicated
// "Waveform" special-action column; the grid renders it between Tracks and Actions, handing the cell
// each release, and RowFor recovers the matching MixRow's generate state.
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
private RenderFragment GridContent => @<CmsAlbumBrowser @ref="_albumBrowser"
Releases="Releases"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync"
OnWaveformGenerated="OnWaveformGenerated"
SpecialColumns="_specialColumns" />;
// Allocated once per component instance in OnInitialized (field initializers cannot reference
@@ -77,33 +94,35 @@ else
@{ var row = RowFor(release); }
@if (row is not null)
{
@if (row.HasWaveform)
{
<MudTooltip Text="Waveform generated">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudTooltip Text="No waveform — incomplete">
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
</MudTooltip>
}
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.GraphicEq"
Disabled="@row.IsGenerating"
OnClick="@(() => GenerateWaveformAsync(row))">
@if (row.IsGenerating)
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (row.HasWaveform)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating…</span>
<MudTooltip Text="Waveform generated">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</MudTooltip>
}
else
{
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
<MudTooltip Text="No waveform — incomplete">
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
</MudTooltip>
}
</MudButton>
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.GraphicEq"
Disabled="@row.IsGenerating"
OnClick="@(() => GenerateWaveformAsync(row))">
@if (row.IsGenerating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating…</span>
}
else
{
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
}
</MudButton>
</MudStack>
}
</text>;
@@ -24,9 +24,9 @@ else
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Href="/releases"
Class="mb-4">
Back to Release Archive
Back to Releases
</MudButton>
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
@@ -42,9 +42,24 @@ else
/// </summary>
[Parameter] public bool Embedded { get; set; }
/// <summary>
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
/// </summary>
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
private CmsAlbumBrowser? _albumBrowser;
protected override ReleaseMedium Medium => ReleaseMedium.Session;
protected override string MediumNoun => "sessions";
/// <summary>
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
/// Called by the parent page after a catalogue-wide bulk run.
/// </summary>
public Task InvalidateWaveformStatusAsync() =>
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
protected override SessionRow ToRow(ReleaseDto release) => new()
{
Release = release,
@@ -57,9 +72,11 @@ else
// both branches above render the same markup without duplication. The Session declares one dedicated
// "Hero" special-action column; the grid renders it between Tracks and Actions, handing the cell each
// release, and RowFor recovers the matching SessionRow's upload state.
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
private RenderFragment GridContent => @<CmsAlbumBrowser @ref="_albumBrowser"
Releases="Releases"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync"
OnWaveformGenerated="OnWaveformGenerated"
SpecialColumns="_specialColumns" />;
// Allocated once per component instance in OnInitialized (field initializers cannot reference
@@ -68,13 +85,16 @@ else
protected override void OnInitialized()
{
_specialColumns = new[] { new SpecialActionColumn("Hero", HeroCell) };
_specialColumns = new[]
{
new SpecialActionColumn("Hero", HeroThumbCell),
new SpecialActionColumn("", HeroButtonCell),
};
base.OnInitialized();
}
// Per-row cell for the dedicated "Hero" column: thumbnail preview plus set/replace upload button with
// progress. Recovers the typed SessionRow via RowFor; skips rendering for a release not on the page.
private RenderFragment<ReleaseDto> HeroCell => release =>@<text>
// Per-row cell for the "Hero" thumbnail column: just the image preview div.
private RenderFragment<ReleaseDto> HeroThumbCell => release =>@<text>
@{ var row = RowFor(release); }
@if (row is not null)
{
@@ -86,6 +106,14 @@ else
{
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
}
}
</text>;
// Per-row cell for the "Hero Image" upload button column: set/replace upload button with progress.
private RenderFragment<ReleaseDto> HeroButtonCell => release =>@<text>
@{ var row = RowFor(release); }
@if (row is not null)
{
<MudFileUpload T="IBrowserFile"
Accept="image/*"
FilesChanged="@(file => UploadHeroAsync(row, file))"
@@ -1,258 +0,0 @@
@using System.Net
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@inject ICmsTrackService CmsTrackService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject ILogger<CmsTrackGrid> Logger
@inject NavigationManager NavigationManager
@if (ShowAddButton)
{
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/tracks/upload">
Add Track
</MudButton>
</MudStack>
}
<MudTable T="TrackDto"
@ref="_table"
ServerData="LoadServerData"
Hover="true"
Striped="true"
Dense="true"
Bordered="false"
FixedHeader="true"
RowsPerPage="@PageSize"
AllowUnsorted="false">
<NoRecordsContent>
<MudText Typo="Typo.body1">No tracks found.</MudText>
</NoRecordsContent>
<LoadingContent>
<MudText Typo="Typo.body1">Loading tracks…</MudText>
</LoadingContent>
<HeaderContent>
<MudTh Style="width: 1%; white-space: nowrap;">Track #</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Art</MudTh>
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Track #">@context.TrackNumber</MudTd>
<MudTd DataLabel="Art">
@if (!string.IsNullOrEmpty(context.Release?.ImagePath))
{
<div class="cms-track-thumb"
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
}
else
{
<div class="cms-track-thumb cms-track-thumb--fallback"></div>
}
</MudTd>
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
<MudTd DataLabel="Artist">@(context.Release?.Artist ?? "—")</MudTd>
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
<MudTd DataLabel="Waveform">
@if (HasProfile(context.EntryKey))
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
}
</MudTd>
<MudTd DataLabel="Actions">
<MudTooltip Text="Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
Href="@($"/tracks/{context.Id}/edit")" />
</MudTooltip>
<MudTooltip Text="Delete">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => ConfirmAndDelete(context))" />
</MudTooltip>
<MudTooltip>
<TooltipContent>
<div class="cms-track-info">
<div>Entry: @context.EntryKey</div>
<div>File: @(context.OriginalFileName ?? "—")</div>
</div>
</TooltipContent>
<ChildContent>
<MudIconButton Icon="@Icons.Material.Outlined.Info" Size="Size.Small" />
</ChildContent>
</MudTooltip>
@if (!HasProfile(context.EntryKey))
{
<MudTooltip Text="Generate Waveform">
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
Size="Size.Small"
Color="Color.Secondary"
Disabled="@(_bulkRunning || _generating.Contains(context.EntryKey))"
OnClick="@(() => GenerateOneAsync(context))" />
</MudTooltip>
}
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
</PagerContent>
</MudTable>
@code {
[Parameter] public string? AlbumFilter { get; set; }
[Parameter] public string? GenreFilter { get; set; }
[Parameter] public bool ShowAddButton { get; set; } = true;
[Parameter] public int PageSize { get; set; } = 20;
[Parameter] public EventCallback OnTracksChanged { get; set; }
[Parameter] public EventCallback OnStatusLoaded { get; set; }
private MudTable<TrackDto>? _table;
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
private Dictionary<string, bool> _waveformStatus = new();
private readonly HashSet<string> _generating = new();
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
private bool _bulkRunning;
protected override async Task OnInitializedAsync()
{
await RefreshWaveformStatusAsync();
}
private bool HasProfile(string entryKey) =>
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private static string ThumbUrl(string imagePath) =>
$"/api/image/{Uri.EscapeDataString(imagePath)}";
/// <summary>Number of tracks with a missing waveform profile — drives the parent's bulk button label.</summary>
public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value);
/// <summary>
/// Reload the full waveform-status map. Called on init and by the parent after a bulk generate so
/// the per-row icons reflect the new state.
/// </summary>
public async Task RefreshWaveformStatusAsync()
{
var result = await CmsTrackService.GetWaveformStatusAsync();
_waveformStatus = result.Success && result.Value is not null
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
: new Dictionary<string, bool>();
StateHasChanged();
await OnStatusLoaded.InvokeAsync();
}
/// <summary>Set by the parent while its bulk generate runs so per-row buttons disable.</summary>
public void SetBulkRunning(bool running)
{
_bulkRunning = running;
StateHasChanged();
}
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
{
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
var sortDescending = state.SortDirection == SortDirection.Descending;
var result = await CmsTrackService.GetPagedAsync(
pageNumber, state.PageSize, sortColumn, sortDescending,
AlbumFilter, GenreFilter, cancellationToken);
if (!result.Success || result.Value is null)
{
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
}
var page = result.Value;
return new TableData<TrackDto>
{
Items = page.Items,
TotalItems = page.TotalCount
};
}
private async Task ConfirmAndDelete(TrackDto track)
{
var confirmed = await DialogService.ShowMessageBox(
title: "Delete track",
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Release?.Artist ?? "Unknown")}? This removes both the metadata row and the underlying audio entry."),
yesText: "Delete",
cancelText: "Cancel");
if (confirmed != true) return;
try
{
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
if (result.Success)
{
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
if (_table is not null) await _table.ReloadServerData();
await OnTracksChanged.InvokeAsync();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Delete failed: {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
Snackbar.Add("Delete failed — please try again.", Severity.Error);
}
}
private async Task GenerateOneAsync(TrackDto track)
{
_generating.Add(track.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
if (result.Success)
{
_waveformStatus[track.EntryKey] = true;
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
}
finally
{
_generating.Remove(track.EntryKey);
StateHasChanged();
}
}
}
@@ -1,17 +0,0 @@
.cms-track-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
background-size: cover;
background-position: center;
flex-shrink: 0;
}
.cms-track-thumb--fallback {
background-color: var(--mud-palette-action-default-hover);
}
.cms-track-info {
font-family: monospace;
text-align: left;
}
@@ -0,0 +1,294 @@
@page "/releases"
@page "/tracks"
@page "/tracks/albums"
@page "/tracks/archive"
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@inject ICmsTrackService CmsTrackService
@inject ISnackbar Snackbar
@inject ILogger<Releases> Logger
@inject NavigationManager NavigationManager
@attribute [Authorize]
<PageTitle>Releases — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
<MudText Typo="Typo.h3">Releases</MudText>
@* Catalogue-wide waveform backfill (migrated from the retired /tracks view). Both buttons act over
every track's waveform status — independent of any single grid — so the page owns the status map
directly: it computes the missing counts and re-fetches after a run. No grid reference involved. *@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || _highResBulkRunning || MissingProfileCount == 0)"
OnClick="GenerateAllMissingAsync">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Profiles (@MissingProfileCount)</span>
}
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Waves"
Disabled="@(_bulkRunning || _highResBulkRunning || MissingHighResCount == 0)"
OnClick="GenerateAllMissingHighResAsync">
@if (_highResBulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Backfilling @_highResBulkDone / @_highResBulkTotal…</span>
}
else
{
<span>Backfill High-res (@MissingHighResCount)</span>
}
</MudButton>
</MudStack>
</MudStack>
@* Medium tab strip: an ALL tab plus one explicit MudTabPanel per ReleaseMedium, ALL left-most. Each
panel is hand-declared in markup (not enum-driven) so @ref captures of the per-tab grid components
are possible. Adding a future medium requires a hand-added MudTabPanel; its position in markup must
match ReleaseMedium enum order, since the ?medium= deep-link seed and ActiveMedium getter are
position-based (panel 0 = ALL, panels 1.. = enum values in order). *@
@* Medium-aware Add Track: the button reflects the active tab and pre-selects the upload form to that
tab's medium via a single query-param (?medium=…); the ALL tab defaults to Cut. The medium is a seed
only — the upload form's selector stays user-changeable after landing. *@
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="@AddTrackHref(ActiveMedium)">
Add Track
</MudButton>
</MudStack>
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
@bind-ActivePanelIndex="_activeTabIndex">
<MudTabPanel Text="ALL">
<CmsAllReleasesGrid @ref="_allGrid"
OnWaveformGenerated="RefreshWaveformStatusAsync" />
</MudTabPanel>
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Cut]">
<CmsCutBrowser @ref="_cutBrowser"
OnWaveformGenerated="RefreshWaveformStatusAsync" />
</MudTabPanel>
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Session]">
<CmsSessionBrowser @ref="_sessionBrowser"
Embedded="true"
OnWaveformGenerated="RefreshWaveformStatusAsync" />
</MudTabPanel>
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Mix]">
<CmsMixBrowser @ref="_mixBrowser"
Embedded="true"
OnWaveformGenerated="RefreshWaveformStatusAsync" />
</MudTabPanel>
</MudTabs>
</MudContainer>
@code {
// Active tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() in order. Seeded
// from the ?medium= query param so the catalogue cards can deep-link straight to a medium's tab.
private int _activeTabIndex;
// Optional deep-link target from the catalogue cards (?medium=session selects the Sessions tab) and the
// seed for the Add Track button on the ALL tab. Read once on init; the user can switch tabs freely after.
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
// The medium the Add Track button pre-selects for the active tab. ALL (panel 0) defaults to Cut; each
// medium tab maps to its enum value by position, so a fourth medium tab gets a correct Add Track for
// free — no markup fork.
private ReleaseMedium ActiveMedium =>
_activeTabIndex <= 0 ? ReleaseMedium.Cut : Enum.GetValues<ReleaseMedium>()[_activeTabIndex - 1];
// Single query-param convention: the upload page reads ?medium=… and seeds its selector (which stays
// user-changeable). Always explicit, including ALL→cut, so the link is unambiguous.
private static string AddTrackHref(ReleaseMedium medium) =>
$"/tracks/upload?medium={medium.ToString().ToLowerInvariant()}";
// Medium → tab label. The one place medium display text lives for the tab strip. The ALL tab is
// rendered separately (it is not a medium). Tabs are explicit markup so @ref captures work.
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
new Dictionary<ReleaseMedium, string>
{
[ReleaseMedium.Cut] = "CUTS",
[ReleaseMedium.Session] = "SESSIONS",
[ReleaseMedium.Mix] = "MIXES",
};
// @ref handles for the per-tab grids. Used to (a) invalidate their cached per-track waveform status
// after a page-level bulk run, and (b) to wire OnWaveformGenerated so per-row generates bubble up
// and refresh the page-level missing-count badges. Tabs are now explicit markup rather than the
// former enum-driven MediumGrid() switch so @ref captures are possible.
private CmsAllReleasesGrid? _allGrid;
private CmsCutBrowser? _cutBrowser;
private CmsSessionBrowser? _sessionBrowser;
private CmsMixBrowser? _mixBrowser;
// EntryKey → HasProfile / HasHighRes, loaded once on init so the bulk buttons can show accurate missing
// counts without depending on any rendered grid. Re-fetched after each bulk run so the counts settle.
private IReadOnlyList<WaveformStatusDto> _waveformStatus = Array.Empty<WaveformStatusDto>();
private int MissingProfileCount => _waveformStatus.Count(s => !s.HasProfile);
private int MissingHighResCount => _waveformStatus.Count(s => !s.HasHighRes);
// Local state for the parent-owned "Generate All Profiles" bulk run.
private bool _bulkRunning;
private int _bulkTotal;
private int _bulkDone;
// Local state for the "Backfill High-res" bulk run. Independent of the profile bulk above.
private bool _highResBulkRunning;
private int _highResBulkTotal;
private int _highResBulkDone;
protected override async Task OnInitializedAsync()
{
// Seed the active tab from ?medium= so a catalogue card deep-links straight to its medium. Panel 0
// is ALL; a recognised medium maps to its 1-based position. Unrecognised/absent falls through to ALL.
if (!string.IsNullOrWhiteSpace(MediumParam)
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
&& Enum.IsDefined(medium))
{
_activeTabIndex = Array.IndexOf(Enum.GetValues<ReleaseMedium>(), medium) + 1;
}
await RefreshWaveformStatusAsync();
}
private async Task RefreshWaveformStatusAsync()
{
var result = await CmsTrackService.GetWaveformStatusAsync();
_waveformStatus = result.Success && result.Value is not null
? result.Value
: Array.Empty<WaveformStatusDto>();
StateHasChanged();
}
// Invalidates the cached per-track waveform status on all embedded grids so the next row expand
// re-fetches fresh data. Called after each catalogue-wide bulk run so already-expanded rows
// reflect the new waveform state on the next expand interaction.
private async Task InvalidateAllGridsAsync()
{
var tasks = new[]
{
_allGrid?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
_cutBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
_sessionBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
_mixBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
};
await Task.WhenAll(tasks);
}
/// <summary>
/// Backfill every track missing a waveform profile, one request at a time so a large backfill does not
/// flood the API with concurrent WAV decodes. On completion, re-reads the status map so the missing
/// count settles.
/// </summary>
private async Task GenerateAllMissingAsync()
{
var missing = _waveformStatus.Where(s => !s.HasProfile).ToList();
if (missing.Count == 0)
{
return;
}
_bulkRunning = true;
_bulkTotal = missing.Count;
_bulkDone = 0;
var failures = 0;
foreach (var status in missing)
{
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
if (!result.Success)
{
failures++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
failures++;
}
_bulkDone++;
StateHasChanged();
}
_bulkRunning = false;
await RefreshWaveformStatusAsync();
await InvalidateAllGridsAsync();
var succeeded = missing.Count - failures;
if (failures == 0)
{
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
}
else
{
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
}
}
/// <summary>
/// Backfill the per-track high-res visualizer datum for every track missing one, one request at a time
/// so a large backfill does not flood the API with concurrent WAV decodes. Re-runnable (a second run
/// re-reads status and only retries what is still missing). On completion, re-reads the status map.
/// </summary>
private async Task GenerateAllMissingHighResAsync()
{
var missing = _waveformStatus.Where(s => !s.HasHighRes).ToList();
if (missing.Count == 0)
{
return;
}
_highResBulkRunning = true;
_highResBulkTotal = missing.Count;
_highResBulkDone = 0;
var failures = 0;
foreach (var status in missing)
{
try
{
var result = await CmsTrackService.GenerateHighResWaveformAsync(status.EntryKey);
if (!result.Success)
{
failures++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", status.EntryKey);
failures++;
}
_highResBulkDone++;
StateHasChanged();
}
_highResBulkRunning = false;
await RefreshWaveformStatusAsync();
await InvalidateAllGridsAsync();
var succeeded = missing.Count - failures;
if (failures == 0)
{
Snackbar.Add($"Backfilled {succeeded} high-res datum(s).", Severity.Success);
}
else
{
Snackbar.Add($"Backfilled {succeeded} high-res datum(s); {failures} failed.", Severity.Warning);
}
}
}
@@ -1,251 +0,0 @@
@page "/tracks"
@page "/tracks/albums"
@page "/tracks/genres"
@page "/tracks/archive"
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@inject CmsTrackBrowserViewModel VM
@inject ICmsTrackService CmsTrackService
@inject ISnackbar Snackbar
@inject ILogger<TrackList> Logger
@inject NavigationManager NavigationManager
@attribute [Authorize]
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
<MudText Typo="Typo.h3">Tracks</MudText>
@if (VM.Mode == BrowseMode.Tracks)
{
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
OnClick="GenerateAllMissingAsync">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
}
</MudButton>
}
</MudStack>
@* Top-level browse dimension. The former three-way toggle (Tracks / Releases / Release Archive)
collapsed to two (§8.A): "Releases" now hosts the in-page medium tab strip below, subsuming both
the old Releases grid (as the ALL tab) and the retired Release Archive cards. *@
<MudToggleGroup T="BrowseMode"
Value="VM.Mode"
ValueChanged="OnModeChanged"
SelectionMode="SelectionMode.SingleSelection"
Color="Color.Primary"
Size="Size.Small"
Class="mb-4">
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
</MudToggleGroup>
@if (VM.Mode == BrowseMode.Tracks)
{
<CmsTrackGrid @ref="_grid" ShowAddButton="true" PageSize="20" OnStatusLoaded="StateHasChanged" />
}
else if (VM.Mode == BrowseMode.Albums)
{
@* The Release Archive tab strip (§8.A): an ALL tab plus one tab per ReleaseMedium, ALL left-most.
The medium tabs are enum-driven — a fourth medium adds a tab automatically; only a label-lookup
entry (MediumTabLabels) and a content arm (MediumGrid) are needed, no markup fork. Selecting a
tab swaps the grid below in place; no navigation to a separate page occurs. *@
@* Medium-aware Add Track (§8.E): the button lives in the tab-strip chrome (not inside any grid
component — 8.C owns those) and reflects the active tab. It pre-selects the upload form to the
tab's medium via a single query-param (?medium=…); the ALL tab defaults to Cut. The medium is a
seed only — the upload form's selector stays user-changeable after landing. *@
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="@AddTrackHref(ActiveMedium)">
Add Track
</MudButton>
</MudStack>
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
@bind-ActivePanelIndex="_activeTabIndex">
<MudTabPanel Text="ALL">
<CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
</MudTabPanel>
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
<MudTabPanel Text="@MediumTabLabels[medium]">
@MediumGrid(medium)
</MudTabPanel>
}
</MudTabs>
}
else
{
@* Genre browse keeps its route (/tracks/genres) but lost its tab to Release Archive (§3.1).
Reachable by direct URL; no longer in the toggle group. *@
<CmsGenreBrowser Genres="VM.Genres"
IsLoading="VM.GenresLoading"
ExpandedGenre="@VM.ExpandedGenre"
OnExpandedGenreChanged="OnExpandedGenreChanged" />
}
</MudContainer>
@code {
private CmsTrackGrid? _grid;
// Active Release-Archive tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() in
// order. Drives the medium-aware Add Track button (§8.E).
private int _activeTabIndex;
// The medium the Add Track button pre-selects for the active tab. ALL (panel 0) defaults to Cut
// (Daniel, 2026-06-13); each medium tab maps to its enum value by position, so a fourth medium tab
// gets a correct Add Track for free — no markup fork.
private ReleaseMedium ActiveMedium =>
_activeTabIndex <= 0 ? ReleaseMedium.Cut : Enum.GetValues<ReleaseMedium>()[_activeTabIndex - 1];
// Single query-param convention: the upload page reads ?medium=… and seeds its selector (which stays
// user-changeable). Always explicit, including ALL→cut, so the link is unambiguous.
private static string AddTrackHref(ReleaseMedium medium) =>
$"/tracks/upload?medium={medium.ToString().ToLowerInvariant()}";
// Medium → tab label. The one place medium display text lives for the tab strip; a future medium adds
// one entry here and surfaces a tab automatically. Mirrors the extension discipline the retired
// ReleaseArchiveBrowser used for its cards. The ALL tab is rendered separately (it is not a medium).
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
new Dictionary<ReleaseMedium, string>
{
[ReleaseMedium.Cut] = "CUTS",
[ReleaseMedium.Session] = "SESSIONS",
[ReleaseMedium.Mix] = "MIXES",
};
// Medium → embedded grid. Each medium's grid is its own component (Cut has no per-row action; Session
// carries hero upload; Mix carries waveform generation), so the content dispatch is a per-medium
// mapping by nature — but it is a single switch returning a fragment, not a markup fork. The browsers
// render Embedded so their standalone page chrome (container, title, back button) is suppressed here.
private RenderFragment MediumGrid(ReleaseMedium medium) => medium switch
{
ReleaseMedium.Cut => @<CmsCutBrowser />,
ReleaseMedium.Session => @<CmsSessionBrowser Embedded="true" />,
ReleaseMedium.Mix => @<CmsMixBrowser Embedded="true" />,
_ => @<MudText Typo="Typo.body1" Class="mt-4">No grid for this medium.</MudText>
};
// The all-releases grid refreshes its own list after a delete; this notification lets us invalidate
// the VM's genre cache so genre counts reflect the deletion on the next switch into Genre mode.
private void OnAlbumsChanged()
{
VM.Invalidate();
StateHasChanged();
}
// Local state for the parent-owned "Generate All Missing" bulk run.
private bool _bulkRunning;
private int _bulkTotal;
private int _bulkDone;
protected override async Task OnInitializedAsync()
{
// /tracks/archive and /tracks/albums both land on the Releases view (the tab strip); the old
// separate Archive mode is retired (§8.A) but the route stays reachable rather than 404ing.
var uri = NavigationManager.Uri;
var initial =
uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
: uri.Contains("/tracks/archive", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Genres
: BrowseMode.Tracks;
await VM.SwitchModeAsync(initial);
}
private async Task OnModeChanged(BrowseMode mode)
{
await VM.SwitchModeAsync(mode);
var path = mode switch
{
BrowseMode.Albums => "/tracks/albums",
BrowseMode.Genres => "/tracks/genres",
_ => "/tracks"
};
NavigationManager.NavigateTo(path, replace: true);
StateHasChanged();
}
private void OnExpandedGenreChanged(string? genre)
{
VM.SetExpandedGenre(genre);
StateHasChanged();
}
/// <summary>
/// Backfill every track missing a waveform profile, one request at a time so a large backfill
/// does not flood the API with concurrent WAV decodes. On completion, refreshes the grid's
/// status map so the per-row icons reflect the new state.
/// </summary>
private async Task GenerateAllMissingAsync()
{
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
if (!statusResult.Success || statusResult.Value is null)
{
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
return;
}
var missing = statusResult.Value.Where(s => !s.HasProfile).ToList();
if (missing.Count == 0)
{
return;
}
_bulkRunning = true;
_bulkTotal = missing.Count;
_bulkDone = 0;
_grid?.SetBulkRunning(true);
var failures = 0;
foreach (var status in missing)
{
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
if (!result.Success)
{
failures++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
failures++;
}
_bulkDone++;
StateHasChanged();
}
_bulkRunning = false;
_grid?.SetBulkRunning(false);
if (_grid is not null)
{
await _grid.RefreshWaveformStatusAsync();
}
var succeeded = missing.Count - failures;
if (failures == 0)
{
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
}
else
{
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
}
}
}
@@ -0,0 +1,10 @@
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
NavigationManager.NavigateTo("/catalogue", forceLoad: false, replace: true);
}
}
+15 -5
View File
@@ -27,9 +27,6 @@ builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
// DeepDrftAPI api/release family. Same no-in-process-data-layer posture as ICmsTrackService.
builder.Services.AddScoped<ICmsReleaseService, CmsReleaseService>();
// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets).
builder.Services.AddScoped<CmsTrackBrowserViewModel>();
// AuthBlocksWeb: server-side cascading auth state plus the JWT client services used by the
// /account/login + /account/logout Razor pages that ship in the AuthBlocksWeb RCL.
// The auth API lives on DeepDrftAPI, so pass its URL — not Manager's own Kestrel URL.
@@ -44,8 +41,10 @@ builder.Services.AddHttpClient("DeepDrft.Content", client =>
client.BaseAddress = new Uri(contentApiUrl);
});
// Named HttpClient for ApiKey-protected Content API calls (CmsTrackService's vault delete).
// API key baked into the default request headers so callers need not add it manually.
// Named HttpClient for ApiKey-protected Content API calls (CmsTrackService's non-upload operations:
// delete, paged list, metadata read/write, waveform jobs, releases, genres).
// Timeout left at the default 100s — these are short request/response pairs and an infinite timeout
// would hang an InteractiveServer circuit forever on a dead connection.
var contentApiKey = builder.Configuration["Api:ContentApiKey"]
?? throw new InvalidOperationException("Api:ContentApiKey is required");
builder.Services.AddHttpClient("DeepDrft.Content.Cms", client =>
@@ -54,6 +53,17 @@ builder.Services.AddHttpClient("DeepDrft.Content.Cms", client =>
client.DefaultRequestHeaders.Add("ApiKey", contentApiKey);
});
// Dedicated upload client — inherits the API key but removes the whole-request timeout.
// Large WAV uploads (several hundred MB) outrun the 100s default. The upload path enforces an
// idle/heartbeat deadline instead (body-streaming phase via ProgressStreamContent) plus a separate
// response-wait budget (CmsTrackService), so the client itself must not impose a total cap.
builder.Services.AddHttpClient("DeepDrft.Content.Cms.Upload", client =>
{
client.BaseAddress = new Uri(contentApiUrl);
client.DefaultRequestHeaders.Add("ApiKey", contentApiKey);
client.Timeout = Timeout.InfiniteTimeSpan;
});
// Reverse-proxy support (nginx in production).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
@@ -1,76 +0,0 @@
using DeepDrftModels.DTOs;
namespace DeepDrftManager.Services;
/// <summary>The browse dimensions for the /tracks page.</summary>
public enum BrowseMode
{
Tracks,
/// <summary>The release view — hosts the medium tab strip (ALL · CUTS · SESSIONS · MIXES, §8.A).</summary>
Albums,
Genres,
}
/// <summary>
/// Holds the /tracks browser's current mode plus the album- and genre-mode datasets. Scoped per
/// circuit. Album and genre lists are fetched lazily on first switch into their mode and cached for
/// the circuit's lifetime; Track mode owns its own paging inside <c>CmsTrackGrid</c> and needs no
/// state here.
/// </summary>
public class CmsTrackBrowserViewModel
{
private readonly ICmsTrackService _trackService;
public CmsTrackBrowserViewModel(ICmsTrackService trackService)
{
_trackService = trackService;
}
public BrowseMode Mode { get; private set; } = BrowseMode.Tracks;
// Genre mode.
public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
public bool GenresLoading { get; private set; }
public string? ExpandedGenre { get; private set; }
/// <summary>
/// Switch the active mode, lazily loading the genre dataset on first entry into Genre mode and
/// collapsing any expanded genre row. Track mode and the all-releases grid (Albums mode) each own
/// their own data — the grid loads itself (see <c>CmsAllReleasesGrid</c>) — so no fetch happens for
/// either here.
/// </summary>
public async Task SwitchModeAsync(BrowseMode mode)
{
Mode = mode;
ExpandedGenre = null; // collapse on mode switch
if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
{
GenresLoading = true;
var result = await _trackService.GetGenreSummariesAsync();
Genres = result.Success && result.Value is not null
? result.Value
: Array.Empty<GenreSummaryDto>();
GenresLoading = false;
}
}
/// <summary>Toggle the expanded genre row. Selecting the already-expanded genre collapses it.</summary>
public void SetExpandedGenre(string? genre)
{
ExpandedGenre = ExpandedGenre == genre ? null : genre;
}
/// <summary>
/// Drop the cached genre dataset so the next <see cref="SwitchModeAsync"/> into Genre mode
/// re-fetches from the API. Call after a track or release mutation (edit, delete) since the genre
/// summaries are derived from the catalogue and go stale on any such change. The all-releases grid
/// owns and refreshes its own data, so it needs no invalidation here.
/// </summary>
public void Invalidate()
{
Genres = Array.Empty<GenreSummaryDto>();
}
}
+119 -47
View File
@@ -18,21 +18,43 @@ namespace DeepDrftManager.Services;
public class CmsTrackService : ICmsTrackService
{
private const string ContentCmsClientName = "DeepDrft.Content.Cms";
private const string UploadClientName = "DeepDrft.Content.Cms.Upload";
private const string UploadPath = "api/track/upload";
// Idle/heartbeat window: abort an upload only after this long with zero bytes written to the wire.
// The window resets on every progress tick, so a slow-but-moving half-gig upload never trips it;
// a genuinely stalled socket does. Governs the BODY-STREAMING phase only.
// Operator-tunable via Upload:IdleTimeoutSeconds.
private const int DefaultIdleTimeoutSeconds = 90;
// Response-wait budget: once the request body is fully on the wire the server runs AudioProcessor
// decode → vault write → SQL persist. For a several-hundred-MB WAV this can take many minutes.
// The idle heartbeat goes silent after the last byte, so a separate, larger deadline governs the
// response-wait phase so a fully-uploaded file is never killed mid-persist.
// Operator-tunable via Upload:ResponseTimeoutSeconds.
private const int DefaultResponseTimeoutSeconds = 600; // 10 minutes
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CmsTrackService> _logger;
private readonly TimeSpan _uploadIdleTimeout;
private readonly TimeSpan _uploadResponseTimeout;
public CmsTrackService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<CmsTrackService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
var idleSeconds = configuration.GetValue<int?>("Upload:IdleTimeoutSeconds") ?? DefaultIdleTimeoutSeconds;
_uploadIdleTimeout = TimeSpan.FromSeconds(idleSeconds > 0 ? idleSeconds : DefaultIdleTimeoutSeconds);
var responseSeconds = configuration.GetValue<int?>("Upload:ResponseTimeoutSeconds") ?? DefaultResponseTimeoutSeconds;
_uploadResponseTimeout = TimeSpan.FromSeconds(responseSeconds > 0 ? responseSeconds : DefaultResponseTimeoutSeconds);
}
public async Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream,
long contentLength,
string fileName,
string contentType,
string trackName,
@@ -46,12 +68,55 @@ public class CmsTrackService : ICmsTrackService
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
IProgress<long>? progress = null,
CancellationToken ct = default)
{
// Two-phase cancellation for the upload send:
//
// BODY-STREAMING phase (while bytes are on the wire):
// idleCts fires if no progress tick arrives within the idle window. Each
// ProgressStreamContent chunk resets CancelAfter(idle), so a slow-but-moving
// upload never trips it; a genuinely stalled socket does.
//
// RESPONSE-WAIT phase (after the last byte, while the server persists):
// The idle heartbeat goes silent once the body is fully sent. responseCts is
// armed at that moment with a larger budget so a fully-uploaded file is never
// killed mid-persist. idleCts is simultaneously disarmed (CancelAfter(Infinite))
// so it cannot misfire during the response-wait.
//
// sendCts links both so either deadline — plus the caller's ct — cancels the send.
using var idleCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
idleCts.CancelAfter(_uploadIdleTimeout);
// responseCts starts disarmed; the body-complete callback below arms it.
using var responseCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Umbrella token passed to SendAsync — either phase token (or the caller) can cancel.
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(idleCts.Token, responseCts.Token);
// Rebuild the multipart container so the boundary is owned by HttpClient and the
// caller-supplied stream (already buffered by the SignalR upload) is the source.
using var multipart = new MultipartFormDataContent();
var wavContent = new StreamContent(wavStream);
var wavContent = new ProgressStreamContent(
wavStream,
contentLength,
written =>
{
// One mechanism, three consumers: advance the UI meter, reset the idle heartbeat,
// and on body-complete transition to the response-wait budget.
progress?.Report(written);
if (written < contentLength)
{
// Body still in flight — keep the idle heartbeat alive.
idleCts.CancelAfter(_uploadIdleTimeout);
}
else
{
// Last byte on the wire. Disarm the idle timer and start the response budget.
idleCts.CancelAfter(Timeout.InfiniteTimeSpan);
responseCts.CancelAfter(_uploadResponseTimeout);
}
});
wavContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType);
multipart.Add(wavContent, "audioFile", fileName);
@@ -70,13 +135,31 @@ public class CmsTrackService : ICmsTrackService
// for an unrecognised value). Authoritative only when this upload creates the release.
multipart.Add(new StringContent(medium.ToString()), "medium");
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
// Use the dedicated upload client (InfiniteTimeSpan) so the two-phase CTS logic above is the
// sole timeout authority. Non-upload operations use the bounded "DeepDrft.Content.Cms" client.
var client = _httpClientFactory.CreateClient(UploadClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
HttpResponseMessage response;
try
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, sendCts.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Either idle window (body-streaming stall) or response-wait budget (server persist too slow).
if (idleCts.IsCancellationRequested)
{
_logger.LogWarning("Upload of {TrackName} stalled — no progress for {IdleSeconds}s; aborting.",
trackName, _uploadIdleTimeout.TotalSeconds);
return ResultContainer<TrackDto>.CreateFailResult(
$"Upload stalled — no data transferred for {_uploadIdleTimeout.TotalSeconds:0}s. Please retry.");
}
// responseCts fired: body reached the server but persist timed out.
_logger.LogWarning("Upload of {TrackName} timed out waiting for server response after {ResponseSeconds}s.",
trackName, _uploadResponseTimeout.TotalSeconds);
return ResultContainer<TrackDto>.CreateFailResult(
$"Upload timed out waiting for the server to respond after {_uploadResponseTimeout.TotalSeconds:0}s. Please retry.");
}
catch (Exception ex)
{
@@ -501,6 +584,39 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform/high-res", null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for high-res waveform generation of {EntryKey}", entryKey);
return Result.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return Result.CreatePassResult();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result.CreateFailResult("Track audio not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Content API high-res waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to generate high-res waveform datum.");
}
}
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -545,50 +661,6 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.GetAsync("api/track/genres", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for genre summaries");
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API genre summaries failed: {Status}", (int)response.StatusCode);
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Failed to load genres.");
}
List<GenreSummaryDto>? genres;
try
{
genres = await response.Content.ReadFromJsonAsync<List<GenreSummaryDto>>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize genre summaries from Content API response");
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an unexpected response.");
}
if (genres is null)
{
_logger.LogError("Content API returned a null genre summaries list");
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
}
}
public async Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default)
{
// Re-use the paged endpoint: a single-item page carries the full TotalCount, so no
+14 -3
View File
@@ -21,9 +21,14 @@ public interface ICmsTrackService
/// <paramref name="medium"/> sets the parent release's <see cref="ReleaseMedium"/> when this upload
/// creates the release. The medium is authoritative only on creation — adding a track to an existing
/// release never changes its medium (that is the edit path, <see cref="UpdateAsync"/>).
/// <paramref name="contentLength"/> is the total payload size (the browser file's <c>Size</c>); it
/// sets Content-Length and is the denominator for <paramref name="progress"/>, which reports cumulative
/// bytes pushed to the wire. Each progress tick also resets the idle/heartbeat upload timeout, so a
/// stalled connection aborts without a fixed total-duration cap.
/// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream,
long contentLength,
string fileName,
string contentType,
string trackName,
@@ -37,6 +42,7 @@ public interface ICmsTrackService
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
IProgress<long>? progress = null,
CancellationToken ct = default);
/// <summary>
@@ -105,12 +111,17 @@ public interface ICmsTrackService
/// </summary>
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
/// <summary>
/// Trigger high-res visualizer datum generation for a single track via
/// <c>POST api/track/{entryKey}/waveform/high-res</c> (phase-12 §5). Re-runnable — recomputes on each
/// call. Drives the per-row generate action and the batch backfill. Maps a 404 to a "Track audio not
/// found." failure.
/// </summary>
Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default);
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
/// <summary>Returns all distinct genres with track counts from GET api/track/genres.</summary>
Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default);
/// <summary>
/// Returns the total track count by calling GET api/track/page with pageSize=1 and reading TotalCount.
/// </summary>
@@ -0,0 +1,64 @@
using System.Net;
namespace DeepDrftManager.Services;
/// <summary>
/// An <see cref="HttpContent"/> that streams a source stream to the wire while reporting cumulative
/// bytes written after each chunk. This is the single source of truth for both the upload progress
/// meter and the idle/heartbeat timeout: every reported tick advances the UI <em>and</em> resets the
/// idle deadline, so one mechanism feeds both concerns.
/// </summary>
/// <remarks>
/// Wrap the audio payload (not the whole multipart container) so <see cref="TryComputeLength"/>
/// returns the file length and the reported byte counts map directly onto "bytes of this file".
/// </remarks>
public sealed class ProgressStreamContent : HttpContent
{
// 80 KB: large enough to keep the socket fed on a healthy link, small enough that a stalled
// connection trips the idle window without a multi-MB write swallowing the whole heartbeat budget.
private const int CopyBufferSize = 81_920;
private readonly Stream _source;
private readonly long _length;
private readonly Action<long> _onBytesWritten;
/// <param name="source">The payload stream. Read once, sequentially — not seekable-rewound.</param>
/// <param name="length">Total bytes the source will yield; sets Content-Length and the meter denominator.</param>
/// <param name="onBytesWritten">Invoked after each chunk with the cumulative bytes written so far.</param>
public ProgressStreamContent(Stream source, long length, Action<long> onBytesWritten)
{
_source = source;
_length = length;
_onBytesWritten = onBytesWritten;
}
// Token-aware overload (.NET 5+): HttpClient calls this on the send path and passes the request's
// CancellationToken, so the idle-heartbeat CTS aborts an in-flight read/write promptly — not just
// between chunks. The parameterless base override delegates here with CancellationToken.None.
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
=> CopyAsync(stream, cancellationToken);
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
=> CopyAsync(stream, CancellationToken.None);
private async Task CopyAsync(Stream stream, CancellationToken cancellationToken)
{
var buffer = new byte[CopyBufferSize];
long written = 0;
int read;
while ((read = await _source.ReadAsync(buffer, cancellationToken)) > 0)
{
await stream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
written += read;
// Report after the bytes are on the wire — a tick means real forward progress, which is
// exactly the signal the idle heartbeat must reset on.
_onBytesWritten(written);
}
}
protected override bool TryComputeLength(out long length)
{
length = _length;
return true;
}
}
+6 -4
View File
@@ -1,10 +1,11 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Per-track waveform profile status for the CMS PreProcessing panel. Tells admins which tracks
/// already carry a stored loudness profile and which predate the WaveformSeeker feature and need
/// backfilling. <see cref="HasProfile"/> is the existence check; <see cref="EntryKey"/> is the
/// vault key used to trigger generation for a missing profile.
/// Per-track waveform datum status for the CMS PreProcessing panel. Tells admins which tracks already
/// carry each stored datum and which need backfilling. <see cref="HasProfile"/> is the 512-bucket
/// player-bar profile; <see cref="HasHighRes"/> is the duration-derived high-res visualizer datum
/// (phase-12 §5 — every track now carries one). <see cref="EntryKey"/> is the vault key used to trigger
/// generation for either missing datum.
/// </summary>
public class WaveformStatusDto
{
@@ -12,4 +13,5 @@ public class WaveformStatusDto
public string EntryKey { get; set; } = string.Empty;
public string TrackName { get; set; } = string.Empty;
public bool HasProfile { get; set; }
public bool HasHighRes { get; set; }
}
+14 -6
View File
@@ -10,7 +10,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
## Actual structure
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting), `TrackDetail.razor` (single-track detail view with cover, metadata, play affordance), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRowCenter` controls + `TopRightAction` lava-lamp; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
- `Controls/`: Reusable components.
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
@@ -24,7 +24,14 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `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 (0100%), with fixed three-zone gradient (green 060%, yellow 6085%, orange 85100%). 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.
- `ReleaseHeroOverlay.razor`: Shared presentational overlay shell consumed by both `SessionDetail` and `MixDetail`. Renders a background-image hero region with genre/date/share overlaid at the top and title/artist/play at the bottom. Parameters: `HeroImageKey`, `PlaceholderIcon`, `CoverThumbKey` (optional cover thumb in bottom row), `Title`, `Artist`, `Genre`, `ReleaseDate`, `ShareContent` (slot), `PlayContent` (slot), `Class` (per-page aspect/sizing override). Owns no player logic or data fetch; each consuming page passes its own play and share slots. Overlay shell is plain `<div>`s; background-image surface is a `<div class="release-hero-img">` (no `MudPaper`).
- `MixVisualizerControls.razor`: Eight-knob RadialKnob control bar for the Mix detail lava-lamp visualizer. Controls (in order): waveform scroll speed, color gradient rotation speed, lava gravity, lava heat, fluid amount, fluid viscosity (cohesion), collision strength, waveform width. `[Parameter] bool Visible` — the host always renders this component and feeds the lava-lamp toggle into `Visible`; the knobs are `@if`-gated on `Visible` while the container holds a reserved `min-height` so content below never pops when the lamp toggles. Owns no JS interop: mutates the injected `MixVisualizerControlState` and raises `Changed`; the backdrop bridge (`MixWaveformVisualizer`) subscribes and pushes each changed dial to the WebGL module. No control is a seek surface (read-only contract).
- `ReleaseDescription.razor`: Shared presentational blurb block for release detail pages. Renders a header row (mono uppercase eyebrow label "Notes" in `.deepdrft-release-description-label` + thin horizontal divider in `.deepdrft-release-description-rule`, inside `.deepdrft-release-description-header`) above the release's short `Description` paragraph (`.deepdrft-release-description-text`, display-serif weight 300), all wrapped in `.deepdrft-release-description`, placed just below the hero/header. The header row mirrors the home page's `.section-label`/`.divider-line` motif. Purely presentational — owns no data fetch or player wiring. A null/whitespace `Description` renders nothing at all (no block, no divider, no whitespace artifact; the guard lives here so every consumer gets it for free). Parameter: `Description` (`string?`).
- `WaveformVisualizer.razor`: The single WebGL2 lava-lamp visualizer engine. Hosts the waveform of whatever track is currently playing/selected. Three hosting modes: mode A (Mix detail — full-bleed centerpiece), mode B (Cut/Session detail — ambient layer behind hero+content via `ReleaseDetailScaffold`'s `Ambient` slot), mode C (NowPlaying hero panel — full-bleed background for the home hero's right side, mounted by `NowPlaying.razor` inside `.np-visualizer-bg`). `[Parameter] bool Fill` switches from fixed-viewport positioning to container-relative sizing (CSS-only; the renderer is identical in both modes). The bridge resolves the current track's `EntryKey` and re-fetches the high-res datum on track change. Subscribes to `WaveformVisualizerControlState.Changed` and pushes each updated dial to the WebGL module via JS interop. Follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`).
- `WaveformVisualizerControls.razor`: The waveform visualizer control panel (content hosted by `WaveformVisualizerControlPopover`). Phase 15 re-layout: a deterministic **three-row sectioned layout** encoding the visualizer's two subsystems. Row 1 (MODE, always visible): two iconographic lamp toggles (lava on/off, waveform on/off) left-aligned + collisions knob (conditional — only when both subsystems on) + color knob pinned far-right. Row 2 (LAVA, visible only when `LavaEnabled`): "LAVA:" section label + Gravity / Heat / FluidAmount / FluidViscosity knobs. Row 3 (WAVE, visible only when `WaveformEnabled`): "WAVE:" section label + scroll-speed `MudSlider` (not a knob) + width knob pinned far-right. Total: two lamp toggles, seven `RadialKnob`s, one `MudSlider`. Colour principle: lamp toggles / knob arcs / slider are green (`Color.Primary` — interactive); section labels / knob caption icons are light (static). Each control has a playful `MudTooltip`. `[Parameter] bool PanelChrome` scopes panel chrome (NowPlayingCard look — square corners, lighter-navy, thin border) to the popover mount; chrome classes live in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). `[Parameter] bool Visible` gates the rows via `@if` while the container holds reserved min-height. Owns no JS interop: mutates the injected `WaveformVisualizerControlState` and raises `Changed`. No control is a seek surface (read-only contract).
- `WaveformVisualizerControlPopover.razor`: Pairs the lava-lamp icon button with `WaveformVisualizerControls` as a **screen-centered tinted modal** (Phase 15). The primitive is `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) — **not** `MudPopover`; `AnchorOrigin`/`TransformOrigin` parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: `RadialKnob`'s `position:fixed` capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. `[Parameter] Size IconSize` controls the trigger-icon size (default `Large`). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in `.np-visualizer-controls` at the panel's top-right corner, not inside `NowPlayingCard`).
- `WaveformZoomMapping.cs`: Maps the `WaveformVisualizerControlState.Resolution` fraction to an integer zoom level for the WebGL renderer.
- `NowPlayingCard.razor`: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded `IStreamingPlayerService`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to `NowPlaying.razor`.
- `NowPlaying.razor`: Owns the home hero's right-side panel (`.now-playing-panel` — the outer wrapper formerly called `.hero-right` in `Home.razor`). Mounts `<WaveformVisualizer Fill="true">` as a full-bleed background inside `.np-visualizer-bg`, `<WaveformVisualizerControlPopover>` in `.np-visualizer-controls` (top-right corner), the three pulsing `.circle-deco` rings, and the content layer (hosts `<NowPlayingCard>` + `<NowPlayingStats>`). `Home.razor`'s `MudItem` renders `<NowPlaying />` directly with no wrapper. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose — needed because the player cascade is `IsFixed` (the provider's own re-render does not reach `NowPlaying`), so the subscription is the only way to re-render and re-propagate `ReleaseEntryKey`/`TrackId`/`TrackEntryKey` into `<WaveformVisualizer>` when the playing track changes.
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold.
- `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.
- `Services/`: Audio player + dark-mode services.
@@ -33,16 +40,17 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 1664 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
- `MixVisualizerControlState`: Scoped session-persistent holder for the Mix visualizer's **eight** control positions: `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`. Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`MixWaveformVisualizer`) subscribes and pushes the affected uniform. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)``Stream` with optional Range header support for seek-beyond-buffer.
- `Services/ITrackDataService`: Contract used by the visualizer bridge and other consumers. Includes `GetTrackWaveform(entryKey)` → high-res `WaveformProfileDto` (calls `GET api/track/{entryKey}/waveform/high-res`); used by `WaveformVisualizer` to re-fetch the datum on track change.
- `ViewModels/`: Component state.
- `TracksViewModel`: Scoped. Holds current page, page size, sort column, descending flag. `SetPage(pageNumber)` calls `TrackClient.GetPageAsync` and updates. Registered in `Startup.ConfigureDomainServices`.
- `TrackDetailViewModel`: Scoped. Holds loaded track, loading flag, not-found flag. `Load(entryKey)` fetches via `ITrackDataService` and resets all flags per call (prevents cross-navigation bleed). Registered in `Startup.ConfigureDomainServices`.
- `Common/`: Shared utilities.
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
- `DDIcons.cs`: Hand-rolled SVG icons (gas-lamp lit/unlit for dark mode toggle).
- `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B).
- `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`.
- `_Imports.razor`: Global using statements and component imports.
@@ -98,7 +106,7 @@ New modules in `DeepDrftPublic/Interop/audio/`:
- `DarkModeServiceBase`: Holds the cookie name constant (`"darkMode"`).
- `DarkModeCookieService`: Reads/writes the cookie via JS (`document.cookie` interop). Calls `DarkModeSettings.IsDarkMode = value` when the cookie changes or user toggles the button.
- Server-side `DarkModeService` (in `DeepDrftPublic`, **not here**): Reads the cookie during prerender, seeds the `DarkModeSettings` instance, rounds it through `PersistentComponentState` to the client.
- `MainLayout.razor`: Wraps entire layout in `CascadingValue` of `DarkModeSettings`, so all children see the current dark-mode state. The dark-mode toggle button (hand-rolled lit/unlit gas-lamp icon from `DDIcons.cs`) calls `DarkModeCookieService.ToggleDarkModeAsync()`.
- `MainLayout.razor`: Wraps entire layout in `CascadingValue` of `DarkModeSettings`, so all children see the current dark-mode state. The dark-mode toggle button (hand-rolled lit/unlit gas-lamp icon from `DeepDrftShared.Client/Common/DDIcons.cs`) calls `DarkModeCookieService.ToggleDarkModeAsync()`.
The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie.
@@ -113,7 +121,7 @@ Component state lives in ViewModels (registered scoped in DI). Components render
- Bespoke `PaletteLight` / `PaletteDark` defined inline in `MainLayout.razor` (MudBlazor theme objects).
- CSS classes prefixed `deepdrft-` live in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (shared across server and client).
- Custom SVG icons: `Common/DDIcons.cs` (hand-rolled gas-lamp, etc.).
- Custom SVG icons: `DeepDrftShared.Client/Common/DDIcons.cs` (hand-rolled gas-lamp, lava-lamp, etc. — shared across public and CMS surfaces).
## Development commands
@@ -83,27 +83,4 @@ public class ReleaseClient
? ApiResult<ReleaseDto>.CreatePassResult(release)
: ApiResult<ReleaseDto>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches the high-res waveform datum for a Mix release, addressed by its public EntryKey. A 404
/// means no datum is stored (not yet generated, or not a Mix) — a valid state, so it returns a pass
/// result with a null value. Any other non-success status is a genuine failure.
/// </summary>
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
{
var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, JsonOptions);
return profile is not null
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
}
}
@@ -129,6 +129,33 @@ public class TrackClient
: ApiResult<List<GenreSummaryDto>>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches the per-track high-res waveform datum, addressed by the track's EntryKey (phase-12 §5b).
/// A 404 means no high-res datum is stored (a track not yet backfilled) — a valid state, so it
/// returns a pass result with a null value and the visualizer blanks gracefully. Any other
/// non-success status is a genuine failure.
/// </summary>
public async Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey)
{
var response = await _http.GetAsync($"api/track/{Uri.EscapeDataString(trackEntryKey)}/waveform/high-res");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return profile is not null
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
{
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
@@ -29,7 +29,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// error banner.
//
// _miniDock is the minimized FAB container. We observe it in minimized state so
// --player-height stays non-zero (the FAB's actual height) and the MixWaveformVisualizer
// --player-height stays non-zero (the FAB's actual height) and the WaveformVisualizer
// clips to the top of the FAB rather than extending to the viewport bottom (fix §1).
// The player-spacer's .minimized class uses a hardcoded 60px and ignores the var,
// so publishing the FAB height here does not regress the spacer.
@@ -125,7 +125,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// The Fixed embed is already in normal flow — no spacer/clip needed.
// For the docked player: we observe in BOTH expanded and minimized states
// so --player-height always reflects the live height of whichever element
// is visible. This keeps the MixWaveformVisualizer clipped to the top of
// is visible. This keeps the WaveformVisualizer clipped to the top of
// the footer in both states (fix §1).
// expanded → observe _playerRoot (full player bar, reflows across breakpoints)
// minimized → observe _miniDock (floating FAB container, ~5660px)
@@ -1,166 +0,0 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inject MixVisualizerControlState ControlState
@* The Mix visualizer controls. EIGHT continuous RadialKnobs — scroll speed, gradient rotation speed,
lava gravity, lava heat, fluid amount, fluid viscosity, collision strength, waveform width — each its
own dedicated control with a Material-icon caption. The single "bubbles" knob is split into
fluid-amount + fluid-viscosity (Phase 10 §5).
Visibility (Phase 10 §4): the host ALWAYS renders this component now and feeds the lava-lamp toggle
into the @Visible parameter. THIS component decides knob visibility — it @if-gates the knobs but keeps
the container's reserved size, so the content below the controls bar never pops when the lamp toggles.
The gating is Blazor @if (matching the established "@if-gated knob band, no CSS hide/glass/animation"
convention) — the knobs are simply not rendered when hidden, while a min-height container holds the
layout. No collapse animation, no glass surface, no CSS visibility-hiding of populated knobs.
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 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) 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-bar">
@if (Visible)
{
<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.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<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.Primary" />
<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.Primary" />
<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.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<div class="mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<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.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
<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.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="mix-visualizer-control-icon" />
</div>
}
</div>
@code {
/// <summary>
/// Whether the knob band is shown. The host wires its lava-lamp toggle straight into this — the host
/// always renders this component, and THIS component decides knob visibility (Phase 10 §4). When
/// false the knobs are @if-gated out but the container holds its reserved height (CSS min-height), so
/// content below the bar never pops as the lamp toggles.
/// </summary>
[Parameter] public bool Visible { 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 OnFluidAmountChanged(double value)
{
ControlState.FluidAmount = value;
ControlState.NotifyChanged();
}
private void OnFluidViscosityChanged(double value)
{
ControlState.FluidViscosity = value;
ControlState.NotifyChanged();
}
private void OnCollisionStrengthChanged(double value)
{
ControlState.CollisionStrength = value;
ControlState.NotifyChanged();
}
private void OnWaveformWidthChanged(double value)
{
ControlState.WaveformWidth = value;
ControlState.NotifyChanged();
}
}
@@ -1,36 +0,0 @@
/* The eight-knob band. Phase 10 §4: the host ALWAYS renders this component and the component @if-gates
the knobs on its Visible parameter. So the container is permanent and reserves its height whether or
not the knobs are present — content below the bar never pops on toggle. No collapse machinery, no
transitions, no glass surface. A plain transparent horizontal flex row of the eight knobs that wraps
to a second line only if the band is genuinely too narrow.
min-height reserves one knob-row's worth of space (knob Size=64 + icon caption + gaps + margins) so
the empty (hidden) state occupies the same vertical box the populated single-row state does. On very
narrow viewports a populated band may wrap to a second row and exceed this floor — the no-pop
guarantee is exact for the common single-row (desktop) layout. */
.mix-visualizer-controls-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 0.85rem 1rem;
margin: 0.5rem 0;
min-height: 6rem;
}
/* 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.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. 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 {
color: var(--mud-palette-primary);
opacity: 0.78;
}
@@ -1,355 +0,0 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Full-page scrolling Mix waveform background. Standalone and reusable: give it a
/// <see cref="ReleaseEntryKey"/> and it fetches its own loudness datum. The rendering itself — a windowed,
/// bottom-to-top, playback-coupled scroll with a glassy theme-aware gradient — lives in the
/// MixVisualizer.ts interop module; this component is the bridge that feeds it datum, playback
/// position, zoom, and theme, and owns the module lifecycle.
///
/// Strictly read-only (spec §D): no seek, no two-way write-back. <see cref="PlaybackPosition"/> is a
/// one-way input. The live playback signal on the Mix detail page comes from the cascaded player
/// service (which also supplies the mix duration needed for the time↔sample mapping); the
/// <see cref="PlaybackPosition"/> parameter is the composability fallback for hosts that have no
/// player cascade (e.g. an embed) and want to drive position themselves.
/// </summary>
public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
{
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
[Inject] public required MixVisualizerControlState ControlState { get; set; }
[Inject] public required ILogger<MixWaveformVisualizer> Logger { get; set; }
// Live playback + the mix duration come from the cascaded streaming player when present. The
// cascade is IsFixed, so we subscribe to its multicast StateChanged side-channel to learn about
// position/play-state ticks (same pattern as WaveformSeeker / SpectrumVisualizer).
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// Live dark-mode state. Toggling re-themes the gradient without a reload: the cascade re-renders
// us, and OnAfterRender pushes fresh palette colours into the module.
[CascadingParameter] public DarkModeSettings? DarkMode { get; set; }
/// <summary>The opaque public EntryKey of the Mix release whose waveform datum to fetch and render.</summary>
[Parameter] public required string ReleaseEntryKey { get; set; }
/// <summary>
/// The id of this mix's playable track. Used to gate the cascaded player as the live source: we
/// only couple to playback when the player is on THIS track, so a different track playing
/// elsewhere leaves this backdrop at its at-rest slice instead of scrolling to the wrong audio.
/// Null leaves the visualizer in the at-rest state (no player coupling).
/// </summary>
[Parameter] public long? TrackId { get; set; }
/// <summary>
/// Normalized playback head in [0, 1]. One-way input only — the component never writes back.
/// Used as the position source for hosts with no cascaded player (composability fallback);
/// when a matching player is cascaded, its live position takes precedence.
/// </summary>
[Parameter] public double PlaybackPosition { get; set; }
// Bridge-level diagnostics. Mirrors the JS-side DEBUG flag in MixVisualizer.ts: when true the
// 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 true temporarily to diagnose.
private static readonly bool Debug = false;
private const string Tag = "[MixVisualizer]";
private static void DebugLog(string message)
{
if (Debug) Console.WriteLine($"{Tag} {message}");
}
private ElementReference _canvas;
private IJSObjectReference? _module;
private IJSObjectReference? _handle;
private IStreamingPlayerService? _subscribedService;
private WaveformProfileDto? _profile;
private string? _loadedReleaseKey;
// Whether we are subscribed to the shared control state's Changed event. The controls row (a
// sibling component) mutates ControlState and raises Changed; we push the affected uniforms.
private bool _subscribedToControls;
// The profile reference last sent to the module, plus whether it went with a real duration.
// Tracked so a per-tick playback push never re-decodes the (up to ~1.2 MB) datum in JS — we only
// push the datum when its identity or duration-availability actually changes.
private WaveformProfileDto? _pushedProfile;
private bool _pushedWithDuration;
// Theme last pushed to the module, so we only re-push on an actual change.
private bool? _lastIsDark;
protected override void OnInitialized()
{
// Subscribe once to the shared control state. The controls row mutates it and raises Changed;
// we are the sole owner of the JS module handle, so we do the uniform pushes here. This keeps
// the handle single-owned (no handle sharing, no service-locator) — the scoped state object is
// the decoupling seam between the foreground controls and this backdrop bridge.
if (!_subscribedToControls)
{
ControlState.Changed += OnControlStateChanged;
_subscribedToControls = true;
}
}
protected override async Task OnParametersSetAsync()
{
// Subscribe to the player's multicast side-channel once, to re-render on position/play ticks.
// Log whether the cascade is even present: a null PlayerService here means the visualizer
// never couples to playback (no StateChanged events ever reach OnPlayerStateChanged).
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService is not null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
DebugLog($"subscribed to player StateChanged. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
}
else if (PlayerService is null)
{
DebugLog($"NO player cascade — playback will never couple. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
}
// ReleaseEntryKey is the only fetch input; fetch once per key. Position/zoom/theme changes
// re-render but must not refetch, and a release with no datum must not refetch either — so the
// guard keys on the fetched key, not on whether a profile came back.
if (_loadedReleaseKey == ReleaseEntryKey) return;
_loadedReleaseKey = ReleaseEntryKey;
DebugLog($"fetching mix waveform datum for ReleaseEntryKey={ReleaseEntryKey}…");
var result = await ReleaseData.GetMixWaveform(ReleaseEntryKey);
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
{
_profile = profile;
DebugLog($"datum fetch OK — {profile.BucketCount} buckets, base64 length {profile.Data.Length}.");
}
else
{
// No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still
// renders its content over a plain background.
_profile = null;
DebugLog(result.Success
? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseEntryKey={ReleaseEntryKey}) — backdrop stays blank."
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank.");
}
// Push the (possibly new) datum to the module if it is already created.
await PushDatumAsync();
}
private void OnPlayerStateChanged() => InvokeAsync(async () =>
{
// Position/play-state changed: push it to the module (cheap; no re-fetch, no full re-render
// needed for the canvas itself, but StateHasChanged keeps the slider/visibility in sync).
// Log the gating inputs so a "ribbon never couples" failure shows exactly why: whether the
// player is on THIS track (IsActivePlayer), and what duration/position/play-state it reports.
var currentTrackId = PlayerService?.CurrentTrack is { } ct ? ct.Id.ToString() : "null";
DebugLog($"player StateChanged — IsActivePlayer={IsActivePlayer} (player.CurrentTrack.Id={currentTrackId}, TrackId={TrackId?.ToString() ?? "null"}), player.IsPlaying={PlayerService?.IsPlaying}, player.Duration={PlayerService?.Duration?.ToString("F2") ?? "null"}.");
await PushPlaybackAsync();
StateHasChanged();
});
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
_module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./js/visualizer/MixVisualizer.js");
_handle = await _module.InvokeAsync<IJSObjectReference>("create", _canvas);
}
catch (JSException ex)
{
Logger.LogWarning(ex, "MixWaveformVisualizer: failed to load the visualizer module; rendering a plain backdrop.");
return;
}
// 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 knob positions the listener left them at.
await PushControlsAsync();
await PushDatumAsync();
await PushPlaybackAsync();
await PushThemeIfChangedAsync();
return;
}
// On every subsequent render (e.g. dark-mode toggle), re-theme if it changed.
await PushThemeIfChangedAsync();
}
// 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();
});
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary>
/// Push the eight 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. Each value is its own dedicated dial:
/// <list type="bullet">
/// <item>scroll speed [0,1] is mapped onto the useful zoom band via
/// <see cref="MixZoomMapping.ScrollKnobToSeconds"/> and pushed through <c>setScrollSpeed</c>
/// (higher speed → tighter window → faster scroll);</item>
/// <item>gradient rotation speed → <c>setGradientRotationSpeed</c> (live OKLab anchor rotation);</item>
/// <item>gravity / heat / collision strength → their dedicated lava-physics dials;</item>
/// <item>fluid amount → <c>setFluidAmount</c> (blob count + volume); fluid viscosity →
/// <c>setFluidViscosity</c> (cohesion / sphere-restoration) — the Phase 10 split of the
/// former single density knob;</item>
/// <item>waveform width → the ribbon-extent uniform.</item>
/// </list>
/// </summary>
private async Task PushControlsAsync()
{
if (_handle is null) return;
// Scroll speed is a normalized [0,1] axis; map it onto the useful zoom band (Phase 10 retune —
// the knob's full travel now covers the 60%100% zoom range, dropping the dead slow/wide end).
var visibleSeconds = MixZoomMapping.ScrollKnobToSeconds(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("setFluidAmount", ControlState.FluidAmount);
await _handle.InvokeVoidAsync("setFluidViscosity", ControlState.FluidViscosity);
await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength);
await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
}
/// <summary>
/// Push the datum to the module, but only when it actually changed — a different profile, or the
/// mix duration becoming available for the first time. Idempotent so the per-tick playback path
/// can call it without re-decoding the (large) base64 datum in JS every frame.
/// </summary>
private async Task PushDatumAsync()
{
if (_handle is null) return;
var haveDuration = _profile is not null && PlayerDurationSeconds is > 0;
// No change since the last push? Nothing to do.
if (ReferenceEquals(_profile, _pushedProfile) && haveDuration == _pushedWithDuration)
return;
if (!haveDuration)
{
// The most common stuck state: a datum is loaded but no positive player duration has
// arrived, so we cannot map samples↔time and push an empty datum. Spell out which half
// is missing so the broken link is unambiguous in the console.
DebugLog($"datum push deferred (empty) — profile={(_profile is null ? "null" : "loaded")}, playerDuration={PlayerDurationSeconds?.ToString("F2") ?? "null"} (needs IsActivePlayer + duration>0).");
}
if (haveDuration)
{
// The mix duration must come from the player (no DTO field carries it); without a
// positive duration we cannot map samples↔time, so we hold off until it arrives.
DebugLog($"datum push (REAL) — base64 length {_profile!.Data.Length}, duration {PlayerDurationSeconds!.Value:F2}s.");
await _handle.InvokeVoidAsync("setDatum", _profile.Data, PlayerDurationSeconds.Value);
}
else
{
await _handle.InvokeVoidAsync("setDatum", string.Empty, 0d);
}
_pushedProfile = _profile;
_pushedWithDuration = haveDuration;
}
private async Task PushPlaybackAsync()
{
if (_handle is null)
{
DebugLog("PushPlayback skipped — module handle not created yet.");
return;
}
// Duration arrives via the player after the initial (duration-less) datum push; the
// idempotent PushDatumAsync re-pushes exactly once when it first becomes available.
await PushDatumAsync();
DebugLog($"setPlayback → position={CurrentPositionSeconds:F2}s, isPlaying={IsPlaying}.");
await _handle.InvokeVoidAsync("setPlayback", CurrentPositionSeconds, IsPlaying);
}
private async Task PushThemeIfChangedAsync()
{
if (_handle is null) return;
var isDark = DarkMode?.IsDarkMode ?? false;
if (_lastIsDark == isDark) return;
_lastIsDark = isDark;
// The module reads the gradient stops directly from the canvas's computed --mud-palette-*
// vars (canvas gradients can't resolve var(), so resolution must happen in JS). The bespoke
// light/dark themes swap those vars on toggle; we just tell the module to re-read.
await _handle.InvokeVoidAsync("refreshTheme");
}
// ── Live signal sources. The matching player wins; PlaybackPosition is the no-player fallback. ─
/// <summary>True only when the cascaded player is loaded with THIS mix's track.</summary>
private bool IsActivePlayer =>
PlayerService is { CurrentTrack: not null }
&& TrackId is { } id
&& PlayerService.CurrentTrack.Id == id;
private double? PlayerDurationSeconds =>
IsActivePlayer && PlayerService!.Duration is > 0 ? PlayerService.Duration : null;
private bool IsPlaying => IsActivePlayer && (PlayerService?.IsPlaying ?? false);
private double CurrentPositionSeconds
{
get
{
// Prefer the matching player's absolute time. Otherwise fall back to the one-way
// PlaybackPosition ([0,1]) scaled by whatever duration we have; with no duration the
// position is unusable, so show the at-rest slice (0).
if (IsActivePlayer)
return PlayerService!.CurrentTime;
if (PlayerDurationSeconds is { } dur)
return Math.Clamp(PlaybackPosition, 0, 1) * dur;
return 0;
}
}
public async ValueTask DisposeAsync()
{
if (_subscribedService is not null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
if (_subscribedToControls)
{
ControlState.Changed -= OnControlStateChanged;
_subscribedToControls = false;
}
if (_handle is not null)
{
try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { }
try { await _handle.DisposeAsync(); } catch (JSDisconnectedException) { }
_handle = null;
}
if (_module is not null)
{
try { await _module.DisposeAsync(); } catch (JSDisconnectedException) { }
_module = null;
}
}
}
@@ -1,11 +1,67 @@
@* Pulsing rings *@
<div class="circle-deco"></div>
<div class="circle-deco"></div>
<div class="circle-deco"></div>
@using DeepDrftPublic.Client.Services
@implements IDisposable
<div class="now-playing-content">
<NowPlayingCard />
@* Hero-right panel (owns the navy backdrop + clipping, formerly Home.razor's .hero-right wrapper). *@
<div class="now-playing-panel">
@* Full-bleed waveform background. The visualizer mounts with Fill="true" (position:absolute; inset:0),
so .np-visualizer-bg is the positioned, sized ancestor that lets the lava fill the whole hero-right
panel. Driven by the live cascaded player — 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
background visualizes, it never seeks. Sits behind the rings + content (z-order via stacking below). *@
<div class="np-visualizer-bg">
<WaveformVisualizer Fill="true"
ReleaseEntryKey="@(Player?.CurrentTrack?.Release?.EntryKey ?? string.Empty)"
TrackId="@Player?.CurrentTrack?.Id"
TrackEntryKey="@Player?.CurrentTrack?.EntryKey" />
</div>
@* Stat row - hard-coded for now. TODO Phase 2: wire to real track count / identity model. *@
<NowPlayingStats />
</div>
@* The lava-lamp trigger lands in the panel's top-right corner (full parity, §8e). Above the canvas
and pointer-enabled so the icon is clickable even though the visualizer layer is
pointer-events:none. The panel itself opens screen-centered (Phase 15 §4 — no per-host anchor),
so only the icon size is host-specific now. *@
<div class="np-visualizer-controls">
<WaveformVisualizerControlPopover IconSize="Size.Small" />
</div>
@* Pulsing rings *@
<div class="circle-deco"></div>
<div class="circle-deco"></div>
<div class="circle-deco"></div>
<div class="now-playing-content">
<NowPlayingCard />
@* Stat row - hard-coded for now. TODO Phase 2: wire to real track count / identity model. *@
<NowPlayingStats />
</div>
</div>
@code {
[CascadingParameter] public IStreamingPlayerService? Player { get; set; }
private IStreamingPlayerService? _subscribedPlayer;
protected override void OnParametersSet()
{
if (Player != null && !ReferenceEquals(Player, _subscribedPlayer))
{
if (_subscribedPlayer != null)
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
Player.StateChanged += OnPlayerStateChanged;
_subscribedPlayer = Player;
}
}
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
public void Dispose()
{
if (_subscribedPlayer != null)
{
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
_subscribedPlayer = null;
}
}
}
@@ -3,6 +3,41 @@
50% { opacity: 0.4; transform: translate(-50%, -50%) scale(1.03); }
}
/* Hero-right panel (migrated from Home.razor's .hero-right). Owns the navy backdrop, clips the
full-bleed visualizer + ring overflow, and is the positioned ancestor for the inset:0 layers. */
.now-playing-panel {
background: var(--deepdrft-navy);
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
height: 100%;
}
@media (max-width: 960px) {
.now-playing-panel { min-height: 50vh; }
}
/* Full-bleed waveform background. WaveformVisualizer Fill="true" renders its layer as
position:absolute; inset:0, so this box only needs to be a positioned, sized ancestor. z-index:0
pins it to the bottom of the panel's stacking order, beneath the rings and the now-playing-content. */
.np-visualizer-bg {
position: absolute;
inset: 0;
z-index: 0;
}
/* The lava-lamp popover trigger overlays the panel's top-right corner (full parity, §8e). Above the
canvas (z-index) and pointer-enabled so the icon is clickable even though the visualizer layer below
it is pointer-events:none. */
.np-visualizer-controls {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 3;
}
.now-playing-content {
position: relative;
z-index: 2;
@@ -22,4 +57,4 @@
.circle-deco:nth-child(1) { width: 320px; height: 320px; animation-delay: 0s; }
.circle-deco:nth-child(2) { width: 520px; height: 520px; animation-delay: 0.8s; }
.circle-deco:nth-child(3) { width: 720px; height: 720px; animation-delay: 1.6s; }
.circle-deco:nth-child(3) { width: 720px; height: 720px; animation-delay: 1.6s; }
@@ -1,4 +1,5 @@
@using DeepDrftPublic.Client.Services
@implements IDisposable
<div class="now-playing">
<div class="np-label"><span class="np-dot"></span>Now Playing</div>
<div class="np-title">@(Player?.CurrentTrack?.TrackName ?? "Nothing playing")</div>
@@ -7,40 +8,34 @@
? $"{Player.CurrentTrack.Release?.Artist} · {Player.CurrentTrack.Release?.Title ?? "Single"}"
: "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>
</div>
}
else
{
<div class="waveform-placeholder"></div>
}
</div>
@code {
[CascadingParameter] public IStreamingPlayerService? Player { get; set; }
}
private IStreamingPlayerService? _subscribedPlayer;
protected override void OnParametersSet()
{
if (Player != null && !ReferenceEquals(Player, _subscribedPlayer))
{
if (_subscribedPlayer != null)
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
Player.StateChanged += OnPlayerStateChanged;
_subscribedPlayer = Player;
}
}
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
public void Dispose()
{
if (_subscribedPlayer != null)
{
_subscribedPlayer.StateChanged -= OnPlayerStateChanged;
_subscribedPlayer = null;
}
}
}
@@ -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; }
@@ -50,23 +45,3 @@
color: rgba(250, 250, 248, 0.45);
letter-spacing: 0.08em;
}
.waveform-bars {
display: flex;
align-items: center;
gap: 3px;
margin-top: 1.2rem;
}
.waveform-bar {
width: 3px;
background: var(--deepdrft-green-accent);
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);
}
@@ -0,0 +1,25 @@
@namespace DeepDrftPublic.Client.Controls
@* Shared presentational blurb block for release detail pages. Renders the release's short
Description paragraph in a uniform styled block placed just below the hero/header on every
medium (Session, Mix, Cut). Purely presentational — owns no data fetch and no player wiring.
A null/whitespace Description renders nothing at all: no block, no divider, no whitespace
artifact (the guard lives here so every consumer gets it for free). *@
@if (!string.IsNullOrWhiteSpace(Description))
{
<MudContainer MaxWidth="MaxWidth.Small">
<div class="deepdrft-release-description">
<div class="deepdrft-release-description-header">
<span class="deepdrft-release-description-label">Notes</span>
<div class="deepdrft-release-description-rule"></div>
</div>
<p class="deepdrft-release-description-text">@Description</p>
</div>
</MudContainer>
}
@code {
/// <summary>The release's short description blurb. Null/whitespace renders nothing.</summary>
[Parameter] public string? Description { get; set; }
}
@@ -5,7 +5,17 @@
hero visual and metadata block. The Cut/Session/Mix detail pages all compose this;
per-medium variance rides the Hero and MetaContent render fragments. *@
<div class="deepdrft-track-detail-container">
@* Ambient environment layer (Phase 12 §3c/§3f mode B): an optional full-bleed layer rendered BEHIND
the scaffold content. The visualizer mounted here positions itself fixed/inset:0 (its own CSS), so
this slot's only job is to render it before the content and put the content into a foreground
stacking context above it. Absent slot = no ambient layer and no foreground promotion → today's
plain background, byte-for-byte (Liskov). *@
@if (Ambient is not null)
{
@Ambient
}
<div class="deepdrft-track-detail-container @(Ambient is not null ? "deepdrft-track-detail-foreground" : null)">
@* Two-end top row: back link (left) | optional action (right), on one SpaceBetween row. The action
slot stays null for media that don't supply it (Track/Cut/Session), so SpaceBetween collapses to
@@ -51,6 +51,16 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
[Parameter] public RenderFragment? Hero { get; set; }
/// <summary>
/// Optional full-bleed ambient layer rendered BEHIND the scaffold content (Phase 12 §3c/§3f mode B).
/// A host that wants a living environment behind hero+content — e.g. Cut supplying a
/// <c>WaveformVisualizer</c> — places it here. The mounted layer positions itself fixed/inset:0
/// (its own CSS), so the scaffold only promotes its content into a foreground stacking context above
/// it. Absent = today's plain background, no regression (Liskov). Mode A (Mix) and mode C (the
/// NowPlaying card) mount the engine without this slot — see §3f.
/// </summary>
[Parameter] public RenderFragment? Ambient { get; set; }
/// <summary>
/// Optional body region rendered below the meta block — the Cut album's multi-track listing.
/// Single-track media leave it null.
@@ -3,3 +3,13 @@
justify-content: center;
margin-top: 1.5rem;
}
/* Foreground stacking context — applied only when an Ambient layer is present. Lifts the scaffold
content above the fixed full-bleed visualizer (z-index: 0) so hero + meta + body render over the
living waveform field (Phase 12 §3c — promotes the former Mix-bespoke .mix-detail-foreground into
the shared scaffold). Without an Ambient slot this class is absent and the container keeps its
default flow, so a slot-less host renders exactly as before. */
.deepdrft-track-detail-foreground {
position: relative;
z-index: 1;
}
@@ -16,6 +16,7 @@
@* The hero is the positioning context for every overlay row; the gradient shim and the
top/bottom overlays are absolutely positioned children of this wrapper. *@
<MudContainer MaxWidth="MaxWidth.Medium">
<div class="release-hero @Class">
@if (!string.IsNullOrEmpty(HeroImageKey))
{
@@ -80,6 +81,7 @@
</MudStack>
</div>
</div>
</MudContainer>
@code {
/// <summary>Background image entry key. Null renders the placeholder treatment.</summary>
@@ -1,16 +1,16 @@
@namespace DeepDrftPublic.Client.Controls
@* Full-page scrolling Mix waveform background (Phase 9, 8.K). A windowed slice of the mix's loudness
@* Full-page scrolling waveform background (Phase 9, 8.K). A windowed slice of the track's loudness
datum scrolls bottom-to-top, coupled to playback; a zoom slider controls the visible time-span (and
so the apparent scroll speed, Guitar-Hero style). Strictly read-only: it self-fetches its datum from
ReleaseEntryKey, takes playback as one-way input only, and never seeks or writes back. The rAF loop and all
scroll/zoom/compositing math live in the MixVisualizer.ts interop module; this component is a thin
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>
@* The viewing controls (resolution + the three Wave 2 controls) live in MixVisualizerControls,
@* The viewing controls (resolution + the three Wave 2 controls) live in WaveformVisualizerControls,
rendered in the mix-detail foreground row below the back button — NOT here. This component is now a
pure backdrop bridge; it pushes uniforms in response to the shared MixVisualizerControlState. *@
pure backdrop bridge; it pushes uniforms in response to the shared WaveformVisualizerControlState. *@
@@ -0,0 +1,480 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Scrolling waveform visualizer, track-cardinal (phase-12 §4/§5). It renders the high-res loudness
/// datum of whatever track is currently playing/selected: the datum is the track's, not the release's,
/// so the fetch resolves the current track's <c>EntryKey</c> (the live playing track when the cascaded
/// player is this visualizer's source — see <see cref="LivePlayerTrack"/> — else the host-supplied
/// <see cref="TrackEntryKey"/>) and re-fetches when that track identity changes — not when the release
/// changes. The release (<see cref="ReleaseEntryKey"/>) anchors which playing tracks this visualizer
/// follows (its own and any sibling in the same release), and is otherwise addressing context. The rendering itself — a windowed, bottom-to-top, playback-coupled scroll with a glassy
/// theme-aware gradient — lives in the WaveformVisualizer.ts interop module; this component is the bridge
/// that feeds it datum, playback position, zoom, and theme, and owns the module lifecycle.
///
/// Strictly read-only (spec §D): no seek, no two-way write-back. <see cref="PlaybackPosition"/> is a
/// one-way input. The live playback signal comes from the cascaded player service (which also supplies
/// the track duration needed for the time↔sample mapping); the <see cref="PlaybackPosition"/> parameter
/// is the composability fallback for hosts that have no player cascade (e.g. an embed) and want to drive
/// position themselves.
/// </summary>
public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
{
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
[Inject] public required WaveformVisualizerControlState ControlState { get; set; }
[Inject] public required ILogger<WaveformVisualizer> Logger { get; set; }
// Live playback + the mix duration come from the cascaded streaming player when present. The
// cascade is IsFixed, so we subscribe to its multicast StateChanged side-channel to learn about
// position/play-state ticks (same pattern as WaveformSeeker / SpectrumVisualizer).
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// Live dark-mode state. Toggling re-themes the gradient without a reload: the cascade re-renders
// us, and OnAfterRender pushes fresh palette colours into the module.
[CascadingParameter] public DarkModeSettings? DarkMode { get; set; }
/// <summary>
/// The opaque public EntryKey of the host release. Addressing context only (phase-12 §4) — the datum
/// is fetched per-track, not per-release. Carried for diagnostics and host identity; it no longer
/// drives the datum fetch.
/// </summary>
[Parameter] public required string ReleaseEntryKey { get; set; }
/// <summary>
/// The id of the host's selected/default playable track. Anchors the host's at-rest identity: at
/// rest (no live player) the visualizer renders this track's datum via <see cref="TrackEntryKey"/>.
/// It also keeps an unrelated track playing elsewhere from coupling to this visualizer — the live
/// player only becomes this visualizer's source when its track is part of this host (it IS this
/// track, or it shares this host's <see cref="ReleaseEntryKey"/>; see <see cref="LivePlayerTrack"/>).
/// Null leaves the visualizer in the at-rest state unless the player's track matches the release.
/// </summary>
[Parameter] public long? TrackId { get; set; }
/// <summary>
/// The EntryKey of the host's selected/default track — the datum to render when no live player is
/// this visualizer's source (e.g. a Mix detail page at rest, before playback starts). When the
/// cascaded player is this visualizer's live source (<see cref="LivePlayerTrack"/>), the playing
/// track's own EntryKey takes precedence so the datum follows live playback (a multi-track Cut, the
/// NowPlaying card). Null with no live player leaves the visualizer blank.
/// </summary>
[Parameter] public string? TrackEntryKey { get; set; }
/// <summary>
/// Normalized playback head in [0, 1]. One-way input only — the component never writes back.
/// Used as the position source for hosts with no cascaded player (composability fallback);
/// when a matching player is cascaded, its live position takes precedence.
/// </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
// which upstream link is broken when the ribbon stays blank — set true temporarily to diagnose.
private static readonly bool Debug = false;
private const string Tag = "[WaveformVisualizer]";
private static void DebugLog(string message)
{
if (Debug) Console.WriteLine($"{Tag} {message}");
}
private ElementReference _canvas;
private IJSObjectReference? _module;
private IJSObjectReference? _handle;
private IStreamingPlayerService? _subscribedService;
private WaveformProfileDto? _profile;
// The track EntryKey the loaded datum belongs to. The fetch-once guard keys on the current track's
// identity (phase-12 §4), not the release, so the datum re-fetches when the playing/selected track
// changes while the release stays fixed (a multi-track Cut, the NowPlaying card). Null until the
// first fetch; an in-flight fetch is tracked separately so concurrent ticks don't double-fetch.
private string? _loadedTrackKey;
private string? _fetchingTrackKey;
// Whether we are subscribed to the shared control state's Changed event. The controls row (a
// sibling component) mutates ControlState and raises Changed; we push the affected uniforms.
private bool _subscribedToControls;
// The profile reference last sent to the module, plus whether it went with a real duration.
// Tracked so a per-tick playback push never re-decodes the (up to ~1.2 MB) datum in JS — we only
// push the datum when its identity or duration-availability actually changes.
private WaveformProfileDto? _pushedProfile;
private bool _pushedWithDuration;
// Theme last pushed to the module, so we only re-push on an actual change.
private bool? _lastIsDark;
protected override void OnInitialized()
{
// Subscribe once to the shared control state. The controls row mutates it and raises Changed;
// we are the sole owner of the JS module handle, so we do the uniform pushes here. This keeps
// the handle single-owned (no handle sharing, no service-locator) — the scoped state object is
// the decoupling seam between the foreground controls and this backdrop bridge.
if (!_subscribedToControls)
{
ControlState.Changed += OnControlStateChanged;
_subscribedToControls = true;
}
}
protected override async Task OnParametersSetAsync()
{
// Subscribe to the player's multicast side-channel once, to re-render on position/play ticks.
// Log whether the cascade is even present: a null PlayerService here means the visualizer
// never couples to playback (no StateChanged events ever reach OnPlayerStateChanged).
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService is not null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
DebugLog($"subscribed to player StateChanged. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
}
else if (PlayerService is null)
{
DebugLog($"NO player cascade — playback will never couple. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
}
// Fetch the current track's datum if its identity changed since the last fetch (parameter set
// can change TrackEntryKey; the player side comes through OnPlayerStateChanged).
await EnsureDatumForCurrentTrackAsync();
}
/// <summary>
/// The EntryKey of the track whose datum to render: the live playing track's own EntryKey when the
/// cascaded player is this visualizer's source (<see cref="LivePlayerTrack"/>), otherwise the host's
/// selected/default <see cref="TrackEntryKey"/>. This is the single source of "which track's datum" —
/// both the fetch key and what re-arms the fetch-once guard. Following the *live* track (not the fixed
/// host <see cref="TrackId"/>) is what lets a multi-track Cut, or the NowPlaying card, re-fetch and
/// render the now-playing track as playback advances (phase-12 §4/§6c). At rest it degrades to the
/// host track — single-track hosts (Mix/Session), where the live track IS the host track, are
/// unchanged.
/// </summary>
private string? CurrentTrackKey =>
LivePlayerTrack?.EntryKey ?? TrackEntryKey;
/// <summary>
/// Fetch the current track's high-res datum, but only when the track identity changed since the last
/// fetch (phase-12 §4 — the guard re-arms on track change, not release change). Idempotent and
/// re-entrancy-guarded: callable from both OnParametersSetAsync (TrackEntryKey changed) and
/// OnPlayerStateChanged (the playing track changed) without double-fetching. A track with no stored
/// datum leaves the visualizer blank; the guard keys on the fetched key, not on whether a datum came
/// back, so a not-yet-backfilled track does not refetch on every tick.
/// </summary>
private async Task EnsureDatumForCurrentTrackAsync()
{
var trackKey = CurrentTrackKey;
// Nothing to fetch (no active player and no selected track): clear any stale datum and disarm.
if (string.IsNullOrEmpty(trackKey))
{
if (_loadedTrackKey is not null || _profile is not null)
{
_loadedTrackKey = null;
_profile = null;
await PushDatumAsync();
}
return;
}
// Already loaded (or loading) this exact track — don't refetch.
if (trackKey == _loadedTrackKey || trackKey == _fetchingTrackKey) return;
_fetchingTrackKey = trackKey;
DebugLog($"fetching high-res waveform datum for trackEntryKey={trackKey}…");
var result = await TrackData.GetTrackWaveform(trackKey);
// The current track may have advanced again while this fetch was in flight; if so, discard this
// result and let the newer track's fetch (already armed via _fetchingTrackKey) win.
if (_fetchingTrackKey != trackKey)
{
DebugLog($"discarding stale datum fetch for trackEntryKey={trackKey} — current track moved on.");
return;
}
_fetchingTrackKey = null;
_loadedTrackKey = trackKey;
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
{
_profile = profile;
DebugLog($"datum fetch OK — {profile.BucketCount} buckets, base64 length {profile.Data.Length}.");
}
else
{
// No datum (track not yet backfilled, or transport error) — blank visualizer; the host still
// renders its content over a plain background.
_profile = null;
DebugLog(result.Success
? $"datum fetch returned EMPTY/absent (no stored high-res datum for trackEntryKey={trackKey}) — visualizer stays blank."
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — visualizer stays blank.");
}
// Push the (possibly new) datum to the module if it is already created.
await PushDatumAsync();
}
private void OnPlayerStateChanged() => InvokeAsync(async () =>
{
// Position/play-state changed: push it to the module (cheap; no re-fetch, no full re-render
// needed for the canvas itself, but StateHasChanged keeps the slider/visibility in sync).
// Log the gating inputs so a "ribbon never couples" failure shows exactly why: whether the live
// player is this visualizer's source (LivePlayerTrack), and what duration/position/play-state it
// reports.
var currentTrackId = PlayerService?.CurrentTrack is { } ct ? ct.Id.ToString() : "null";
DebugLog($"player StateChanged — liveTrackKey={LivePlayerTrack?.EntryKey ?? "null"} (player.CurrentTrack.Id={currentTrackId}, TrackId={TrackId?.ToString() ?? "null"}, ReleaseEntryKey={ReleaseEntryKey}), player.IsPlaying={PlayerService?.IsPlaying}, player.Duration={PlayerService?.Duration?.ToString("F2") ?? "null"}.");
// The playing track may have changed (a multi-track Cut advancing, the NowPlaying card following
// playback) — re-fetch its datum if so. EnsureDatumForCurrentTrackAsync is guarded, so a tick that
// didn't change the track is a cheap no-op.
await EnsureDatumForCurrentTrackAsync();
await PushPlaybackAsync();
StateHasChanged();
});
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
_module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./js/visualizer/WaveformVisualizer.js");
_handle = await _module.InvokeAsync<IJSObjectReference>("create", _canvas);
}
catch (JSException ex)
{
Logger.LogWarning(ex, "WaveformVisualizer: failed to load the visualizer module; rendering a plain backdrop.");
return;
}
// Seed the module with the current state now that it exists. All control values (the eight
// dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
// so a mix opened mid-session seeds the module with the knob/toggle positions the listener
// left them at.
await PushControlsAsync();
await PushDatumAsync();
await PushPlaybackAsync();
await PushThemeIfChangedAsync();
return;
}
// On every subsequent render (e.g. dark-mode toggle), re-theme if it changed.
await PushThemeIfChangedAsync();
}
// The controls bar mutated a knob on the shared state and raised Changed. Push all ten control
// values (cheap scalar interop): the eight continuous dials plus the two subsystem enables. Each
// dial drives its own dedicated uniform 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();
});
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
/// <summary>
/// Push the control values to the module from the shared state — the eight continuous dials plus the
/// two Phase 15 subsystem enables. Used to seed on first render and to re-push when the controls
/// panel signals a change. Each value is its own dedicated dial / enable:
/// <list type="bullet">
/// <item>scroll speed [0,1] is mapped onto the useful zoom band via
/// <see cref="WaveformZoomMapping.ScrollKnobToSeconds"/> and pushed through <c>setScrollSpeed</c>
/// (higher speed → tighter window → faster scroll);</item>
/// <item>gradient rotation speed → <c>setGradientRotationSpeed</c> (live OKLab anchor rotation);</item>
/// <item>gravity / heat / collision strength → their dedicated lava-physics dials;</item>
/// <item>fluid amount → <c>setFluidAmount</c> (blob count + volume); fluid viscosity →
/// <c>setFluidViscosity</c> (cohesion / sphere-restoration) — the Phase 10 split of the
/// former single density knob;</item>
/// <item>waveform width → the ribbon-extent uniform;</item>
/// <item>lava / waveform enabled → <c>setLavaEnabled</c> / <c>setWaveformEnabled</c>, the genuine
/// per-subsystem draw-skip (no physics / no blob upload, ribbon SDF skipped — §10.1).</item>
/// </list>
/// </summary>
private async Task PushControlsAsync()
{
if (_handle is null) return;
// Scroll speed is a normalized [0,1] axis; map it onto the useful zoom band (Phase 10 retune —
// the knob's full travel now covers the 60%100% zoom range, dropping the dead slow/wide end).
var visibleSeconds = WaveformZoomMapping.ScrollKnobToSeconds(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("setFluidAmount", ControlState.FluidAmount);
await _handle.InvokeVoidAsync("setFluidViscosity", ControlState.FluidViscosity);
await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength);
await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
// Phase 15 — the two subsystem enables. "Off" is a genuine draw-skip in the module (no physics,
// no blob upload / ribbon SDF skipped), not a dim. Pushed through the same Changed seam as the
// dials, so a toggle re-reads here exactly as a knob does.
await _handle.InvokeVoidAsync("setLavaEnabled", ControlState.LavaEnabled);
await _handle.InvokeVoidAsync("setWaveformEnabled", ControlState.WaveformEnabled);
}
/// <summary>
/// Push the datum to the module, but only when it actually changed — a different profile, or the
/// mix duration becoming available for the first time. Idempotent so the per-tick playback path
/// can call it without re-decoding the (large) base64 datum in JS every frame.
/// </summary>
private async Task PushDatumAsync()
{
if (_handle is null) return;
var haveDuration = _profile is not null && PlayerDurationSeconds is > 0;
// No change since the last push? Nothing to do.
if (ReferenceEquals(_profile, _pushedProfile) && haveDuration == _pushedWithDuration)
return;
if (!haveDuration)
{
// The most common stuck state: a datum is loaded but no positive player duration has
// arrived, so we cannot map samples↔time and push an empty datum. Spell out which half
// is missing so the broken link is unambiguous in the console.
DebugLog($"datum push deferred (empty) — profile={(_profile is null ? "null" : "loaded")}, playerDuration={PlayerDurationSeconds?.ToString("F2") ?? "null"} (needs a live player source + duration>0).");
}
if (haveDuration)
{
// The mix duration must come from the player (no DTO field carries it); without a
// positive duration we cannot map samples↔time, so we hold off until it arrives.
DebugLog($"datum push (REAL) — base64 length {_profile!.Data.Length}, duration {PlayerDurationSeconds!.Value:F2}s.");
await _handle.InvokeVoidAsync("setDatum", _profile.Data, PlayerDurationSeconds.Value);
}
else
{
await _handle.InvokeVoidAsync("setDatum", string.Empty, 0d);
}
_pushedProfile = _profile;
_pushedWithDuration = haveDuration;
}
private async Task PushPlaybackAsync()
{
if (_handle is null)
{
DebugLog("PushPlayback skipped — module handle not created yet.");
return;
}
// Duration arrives via the player after the initial (duration-less) datum push; the
// idempotent PushDatumAsync re-pushes exactly once when it first becomes available.
await PushDatumAsync();
DebugLog($"setPlayback → position={CurrentPositionSeconds:F2}s, isPlaying={IsPlaying}.");
await _handle.InvokeVoidAsync("setPlayback", CurrentPositionSeconds, IsPlaying);
}
private async Task PushThemeIfChangedAsync()
{
if (_handle is null) return;
var isDark = DarkMode?.IsDarkMode ?? false;
if (_lastIsDark == isDark) return;
_lastIsDark = isDark;
// The module reads the gradient stops directly from the canvas's computed --mud-palette-*
// vars (canvas gradients can't resolve var(), so resolution must happen in JS). The bespoke
// light/dark themes swap those vars on toggle; we just tell the module to re-read.
await _handle.InvokeVoidAsync("refreshTheme");
}
// ── Live signal sources. The live player track wins; PlaybackPosition is the no-player fallback. ─
/// <summary>
/// The cascaded player's current track when that player is *this visualizer's* live source, else null.
/// A player track is this visualizer's source when it is part of what this host represents:
/// <list type="bullet">
/// <item>it IS the host's pinned track (<c>CurrentTrack.Id == TrackId</c>) — the single-track
/// Mix/Session case, preserved at exact parity; or</item>
/// <item>it shares the host's release (<c>CurrentTrack.Release.EntryKey == ReleaseEntryKey</c>) —
/// a multi-track Cut where the release is fixed but the playing track scrolls.</item>
/// </list>
/// The release-match disjunct is what lets the visualizer FOLLOW the live track as playback advances
/// past the host's fixed <see cref="TrackId"/> (phase-12 §4) instead of reverting to the host default;
/// the id-match disjunct guarantees single-track hosts behave identically even if the player track's
/// release graph is sparse. A track playing that is neither this track nor in this release is NOT our
/// source — the visualizer stays at the host's at-rest slice rather than scrolling to unrelated audio.
/// This is the single gate every live-signal accessor (datum key, duration, position, play-state)
/// shares, so they cannot disagree about which track is live.
/// </summary>
private TrackDto? LivePlayerTrack
{
get
{
if (PlayerService?.CurrentTrack is not { } track) return null;
if (TrackId is { } id && track.Id == id) return track;
if (!string.IsNullOrEmpty(track.Release?.EntryKey)
&& track.Release.EntryKey == ReleaseEntryKey) return track;
return null;
}
}
private double? PlayerDurationSeconds =>
LivePlayerTrack is not null && PlayerService!.Duration is > 0 ? PlayerService.Duration : null;
private bool IsPlaying => LivePlayerTrack is not null && (PlayerService?.IsPlaying ?? false);
private double CurrentPositionSeconds
{
get
{
// Prefer the live player's absolute time. Otherwise fall back to the one-way
// PlaybackPosition ([0,1]) scaled by whatever duration we have; with no duration the
// position is unusable, so show the at-rest slice (0).
if (LivePlayerTrack is not null)
return PlayerService!.CurrentTime;
if (PlayerDurationSeconds is { } dur)
return Math.Clamp(PlaybackPosition, 0, 1) * dur;
return 0;
}
}
public async ValueTask DisposeAsync()
{
if (_subscribedService is not null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
if (_subscribedToControls)
{
ControlState.Changed -= OnControlStateChanged;
_subscribedToControls = false;
}
if (_handle is not null)
{
try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { }
try { await _handle.DisposeAsync(); } catch (JSDisconnectedException) { }
_handle = null;
}
if (_module is not null)
{
try { await _module.DisposeAsync(); } catch (JSDisconnectedException) { }
_module = null;
}
}
}
@@ -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);
@@ -0,0 +1,64 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftShared.Client.Common
@* The single controls affordance, placed by an icon anywhere (Phase 12 §3d-revised, re-primitived
Phase 15 §4). Closed state is just the lava-lamp icon; clicking it floats the control panel as a
SCREEN-CENTERED, tinted MODAL over the whole surface. One panel, one host, reused on every host (Mix,
Cut, Session, NowPlaying) — the SOLID seam.
PRIMITIVE (Phase 15 §4): a centered MudOverlay, NOT an anchored MudPopover. The panel must read as
screen-centered regardless of where the lava-lamp icon sits (Mix corner, Cut/Session ambient,
NowPlaying corner). An anchored popover positions off the trigger's bounding rect — the wrong model
for "centered on the screen." So the icon is just an opener; the overlay hosts the panel in its centre
(the overlay is a full-viewport flex container — its content is centered by .waveform-visualizer-control-
overlay in the GLOBAL sheet, since the overlay portals out of this subtree). DarkBackground gives the
mild modal tint (alpha from the single --deepdrft-modal-scrim-alpha token, §10.5). There is therefore
no AnchorOrigin/TransformOrigin: a centered modal has no anchor (Phase 15 drops those parameters).
KNOB-DRAG SAFETY (Phase 15 §4, highest-risk detail): RadialKnob calls setPointerCapture on its own
knob element when a drag begins, so all pointermove/pointerup/pointercancel events for that pointer are
delivered to the knob element regardless of where the cursor moves. The synthesised click on pointerup
is also retargeted to the knob, not to whatever element is under the cursor — so releasing outside the
panel never fires the scrim's close handler. The full-viewport position:fixed; z-index:9999 div
RadialKnob renders while dragging is a belt-and-suspenders UX guard (blocks stray clicks on underlying
content) but is no longer the load-bearing anti-dismiss mechanism. AutoClose is left OFF (the default)
and dismissal is via the explicit scrim OnClick only. The panel stops click propagation so a click
INSIDE it never bubbles to the scrim's close handler.
The host owns NO control state and NO JS interop. The hosted WaveformVisualizerControls panel mutates
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This
host only toggles open/closed and centers the panel — it stays purely presentational. *@
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Visualizer settings"
aria-expanded="@_open" />
</MudTooltip>
@* The tinted modal scrim that also HOLDS the panel. DarkBackground = the mild tint; OnClick on the scrim
dismisses (knob-drag-safe, see header). The panel is the overlay's centered child; it stops click
propagation so an inside click is not a dismissal. Modal so focus/scroll stay on the panel. *@
<MudOverlay Visible="@_open"
DarkBackground="true"
Modal="true"
OnClick="@Close"
Class="waveform-visualizer-control-overlay">
<div class="waveform-visualizer-control-modal" @onclick:stopPropagation="true">
<WaveformVisualizerControls PanelChrome="true" />
</div>
</MudOverlay>
@code {
/// <summary>Trigger-icon size. Defaults Large to match the Phase 10 Mix lava-lamp button.</summary>
[Parameter] public Size IconSize { get; set; } = Size.Large;
private bool _open;
private void Toggle() => _open = !_open;
private void Close() => _open = false;
}
@@ -0,0 +1,268 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftShared.Client.Common
@using DeepDrftPublic.Client.Services
@inject WaveformVisualizerControlState ControlState
@* The waveform visualizer control PANEL (Phase 12 §3d-revised → Phase 15 re-layout → Phase 15 polish).
The control deck for the lava-lamp visualizer: a deterministic THREE-ROW, sectioned layout that encodes
what the visualizer composes — a LAVA field and a WAVEFORM ribbon, each independently toggleable (§3):
Row 1 (MODE, always): lava lamp-toggle, waveform toggle (new waveform glyph), [collisions knob — only
when BOTH on], then the color knob pinned far-right (applies to the whole field, always visible).
Row 2 (LAVA, only when lava on): "LAVA:" label + gravity / heat / fluid-amount / fluid-viscosity.
Row 3 (WAVE, only when waveform on): "WAVE:" label + scroll RadialKnob + width RadialKnob (far-right).
The two toggles have STRONG ACTIVE-STATE styling: when ON the toggle chip has a green-accent background
(unmistakably active); when OFF it is muted/dim. The lava toggle keeps the lava-lamp glyph; the waveform
toggle uses a new distinct waveform-bars glyph (DDIcons.Waveform / WaveformFilled). Green = interactive
(§5 colour principle); light = non-interactive. All colours are token-sourced — no hardcoded hex.
This is the PANEL CONTENT hosted inside WaveformVisualizerControlPopover, now a screen-centered tinted
MudOverlay (Phase 15 §4). Because the overlay PORTALS its content out of this component's DOM subtree,
Blazor CSS isolation does not reach the rendered panel — so panel chrome AND the row/section layout live
in the GLOBAL deepdrft-styles.css (.waveform-visualizer-control-panel*), not the scoped .razor.css. The
scoped .razor.css carries only the legacy inline-bar fallback (Mix's old non-portaled mount), which may
now be dead post-Phase-12 but is left in place — flagged, not cut (out of Phase 15 scope).
It owns NO JS interop: it mutates the shared, session-scoped WaveformVisualizerControlState and raises
its Changed event. The visualizer bridge (WaveformVisualizer) subscribes and pushes the affected dial /
subsystem-enable to the WebGL module. RadialKnob has no icon slot (its Label renders as SVG text) and no
aria capture, so each control's Material icon rides beside its knob as a caption and the accessible name
rides on the wrapping group div (§7); the playful MudTooltip rides alongside for sighted hover. *@
<div class="@($"{_panelChromeClass} mix-visualizer-controls-bar".TrimStart())">
@if (Visible)
{
@* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="Show the sound, or hide the ribbon.">
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
Color="Color.Primary"
OnClick="@ToggleWaveform"
aria-label="Waveform ribbon on or off"
aria-pressed="@ControlState.WaveformEnabled"/>
</div>
</MudTooltip>
@* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one
present, so visible only when BOTH are on (§3 truth table). *@
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
{
<MudTooltip Text="How hard the blobs body-check the beat.">
<div class="waveform-visualizer-control 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.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
}
@* </div> *@
@* Color applies to the whole field regardless of which subsystems are on, so it is pinned
far-right of row 1 and never reflows when collisions hides (§3). *@
<MudTooltip Text="How fast the lamp drifts through its colors.">
<div class="waveform-visualizer-control 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.Primary"/>
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
</div>
</MudTooltip>
<MudTooltip Text="Light the lamp — or let it go cold.">
<div class="wvc-toggle @(ControlState.LavaEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Lava field on or off">
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Color="Color.Primary"
OnClick="@ToggleLava"
aria-label="Lava field on or off"
aria-pressed="@ControlState.LavaEnabled"/>
</div>
</MudTooltip>
</MudStack>
@* ── Row 2 — WAVE section (only when waveform on). Both controls are RadialKnobs (scroll reverted
from MudSlider per Phase 15 polish); width pinned far-right via wvc-row-wave space-between. ── *@
@if (ControlState.WaveformEnabled)
{
<div class="wvc-row wvc-row-section wvc-row-wave">
<span class="wvc-section-label">WAVE:</span>
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How fast the sound rolls by.">
<div class="waveform-visualizer-control 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.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Color="Color.Surface" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
<div class="waveform-visualizer-control 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.Primary" />
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</MudStack>
</div>
}
@* ── Row 3 — LAVA section (only when lava on). ── *@
@if (ControlState.LavaEnabled)
{
<div class="wvc-row wvc-row-section">
<span class="wvc-section-label">LAVA:</span>
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
<MudTooltip Text="How heavy the wax feels — float, or sink.">
<div class="waveform-visualizer-control 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.Primary" />
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
<div class="waveform-visualizer-control 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.Primary" />
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="How much goo is in the lamp.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
<RadialKnob Value="@ControlState.FluidAmount"
ValueChanged="@OnFluidAmountChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
<MudTooltip Text="Runny and gooey, or tight little globes.">
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
<RadialKnob Value="@ControlState.FluidViscosity"
ValueChanged="@OnFluidViscosityChanged"
Min="0" Max="1" Step="0.001"
Size="64"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
</div>
</MudTooltip>
</MudStack>
</div>
}
}
</div>
@code {
/// <summary>
/// Whether the control deck is shown. The overlay host shows the panel whenever it is open, so the
/// default is <c>true</c>. Mix's legacy inline mount (if it survives) still feeds its lava-lamp toggle
/// into this — that mount always renders the component, and THIS component decides deck visibility
/// (Phase 10 §4): when false the rows are @if-gated out but the container holds its reserved height
/// (CSS min-height) so content below the inline bar never pops. Inside the overlay the host owns
/// open/closed, so the default keeps the panel populated.
/// </summary>
[Parameter] public bool Visible { get; set; } = true;
/// <summary>
/// When <c>true</c>, applies the <c>waveform-visualizer-control-panel</c> class to the root element,
/// enabling the global panel-chrome rules (NowPlayingCard chrome — square corners, lighter-navy
/// ground, thin light border — plus the row/section layout and pinned palette tokens). Set by
/// <see cref="WaveformVisualizerControlPopover"/>; Mix's inline mount leaves this <c>false</c> so the
/// chrome never leaks onto the inline bar.
/// </summary>
[Parameter] public bool PanelChrome { get; set; } = false;
private string _panelChromeClass => PanelChrome ? "waveform-visualizer-control-panel" : string.Empty;
// Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and pushes
// the affected dial / subsystem-enable. All dial 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. The two
// toggles flip a boolean (no value), driving the genuine per-subsystem draw-skip in the module (§6).
private void ToggleLava()
{
ControlState.LavaEnabled = !ControlState.LavaEnabled;
ControlState.NotifyChanged();
}
private void ToggleWaveform()
{
ControlState.WaveformEnabled = !ControlState.WaveformEnabled;
ControlState.NotifyChanged();
}
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 OnFluidAmountChanged(double value)
{
ControlState.FluidAmount = value;
ControlState.NotifyChanged();
}
private void OnFluidViscosityChanged(double value)
{
ControlState.FluidViscosity = value;
ControlState.NotifyChanged();
}
private void OnCollisionStrengthChanged(double value)
{
ControlState.CollisionStrength = value;
ControlState.NotifyChanged();
}
private void OnWaveformWidthChanged(double value)
{
ControlState.WaveformWidth = value;
ControlState.NotifyChanged();
}
}
@@ -0,0 +1,40 @@
/* SCOPED fallback for the LEGACY inline mount only (Mix's TopRowCenter bar). The popover-hosted panel's
chrome lives in the GLOBAL deepdrft-styles.css (.waveform-visualizer-control-panel*) because MudPopover
portals its content out of this component's DOM subtree, where Blazor CSS isolation cannot reach. These
scoped rules apply only when the panel is mounted inline (not portaled) — i.e. Mix's existing bar.
Phase 10 §4: the inline host ALWAYS renders this component and the component @if-gates the knobs on its
Visible parameter. So the container is permanent and reserves its height whether or not the knobs are
present — content below the bar never pops on toggle. A plain transparent horizontal flex row of the
eight knobs that wraps to a second line only if the band is genuinely too narrow.
min-height reserves one knob-row's worth of space (knob Size=64 + icon caption + gaps + margins) so the
empty (hidden) state occupies the same vertical box the populated single-row state does. The
popover-panel rule in the global sheet overrides this min-height (a popover does not reserve height). */
.mix-visualizer-controls-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 0.85rem 1rem;
margin: 0.5rem 0;
min-height: 6rem;
}
/* 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 eight read as a tidy bar. */
.mix-visualizer-control {
display: flex;
flex-direction: column;
align-items: center;
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. Tinted to the primary accent and the overlay-label
opacity so it matches the session-hero NowPlaying captions (§7e). The portaled popover panel tints the
same icons via the global sheet instead. */
.mix-visualizer-control ::deep .mix-visualizer-control-icon {
color: var(--mud-palette-primary);
opacity: 0.78;
}
@@ -1,13 +1,13 @@
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Pure mapping between the Mix visualizer's zoom slider position [0, 1] and the visible time-span in
/// Pure mapping between the waveform visualizer's zoom slider position [0, 1] and the visible time-span in
/// seconds. The span range is wide (0.333 s … 30 s, ~90×), so the mapping is logarithmic — equal
/// slider travel changes the span by an equal *ratio*, which feels even to the hand. Slider
/// orientation: fraction 0 = most zoomed-out (longest span), fraction 1 = most zoomed-in (the
/// 0.333 s quarter-note-@-180-BPM anchor). Extracted from the component so the math is unit-testable.
/// </summary>
public static class MixZoomMapping
public static class WaveformZoomMapping
{
/// <summary>Shortest span (max zoom): one quarter note at 180 BPM = 60/180 s. Hard anchor.</summary>
public const double MinVisibleSeconds = 60.0 / 180.0;
@@ -1,6 +1,6 @@
.deepdrft-footer {
/* position:relative + z-index:1 creates a stacking context that paints above the
MixWaveformVisualizer backdrop (z-index:0), keeping footer text fully legible. */
WaveformVisualizer backdrop (z-index:0), keeping footer text fully legible. */
position: relative;
z-index: 1;
background: var(--deepdrft-white);
+1
View File
@@ -36,6 +36,7 @@ public static class Pages
new() { Name = "Mixes", Route = "/mixes", Icon = Icons.Material.Filled.GraphicEq },
],
},
new() { Name = "About", Route = "/about", Icon = Icons.Material.Filled.Groups },
];
public static readonly List<PageRoute> AllPages =
+369
View File
@@ -0,0 +1,369 @@
@page "/about"
@using DeepDrftPublic.Client.Controls
@implements IAsyncDisposable
@inject IJSRuntime JsRuntime
<PageTitle>The Collective - Deep DRFT</PageTitle>
@* ──────────────────────────────────────────────────────────────────────────────
THE LINER NOTES — a numbered three-movement editorial essay.
This page deliberately does NOT reuse Home's section grammar (centred dividers,
symmetric 4/8 splits, the medium-card grid). Its backbone is a persistent left
"rail" — a continuous vertical hairline (the narrative spine) carrying oversized
Bodoni movement numerals (01/02/03) and mono marginalia — with the content column
running asymmetrically to its right. Movement boundaries are rendered as a
self-contained SVG waveform stroke (the DeepDrft visualizer motif, hand-authored
here — NOT the live WaveformVisualizer component).
The numeral active-highlight (green on the movement in view) is progressive
enhancement via IntersectionObserver: without JS the numerals still render
statically in low-opacity navy. See Interop/about/about-rail.ts.
────────────────────────────────────────────────────────────────────────────── *@
@* ── HERO — the page opener. Reuses the .hero-* type scale with About's own words.
NOT DeepDrftHero (that hard-codes the Deep/DRFT masthead + streaming CTA). ── *@
<section class="hero">
<MudGrid Spacing="0" Style="height: 100%;">
<MudItem xs="12" md="6">
<div class="hero-left">
<div class="hero-eyebrow @AnimClass">Charleston, South Carolina</div>
<h1 class="hero-title @AnimClass">The<br /><em>Collective</em></h1>
<p class="hero-desc @AnimClass">
Two people, many hats. We bring the heart and soul of Midwest deep house to Charleston &mdash; informed by the founders of the style, and promising to push it forward.
</p>
</div>
</MudItem>
<MudItem xs="12" md="6">
@* IMG SLOT A — hero duo portrait, inset within the content column. *@
<div class="hero-image-pane">
<ParallaxImage Image1="img/dd-duo-2-bw.jpg"
Image2="img/dd-duo-2.jpeg"
Alt1="Deep DRFT — two-person electronic music collective"
NaturalWidth="2048"
NaturalHeight="1365"
WindowHeightFraction="0.9"
ImageHeight="auto"
ImageWidth="100%"
ParallaxSpeed="0.25" />
</div>
</MudItem>
</MudGrid>
</section>
@* ════════════════ MOVEMENT ONE — THE PEOPLE (pathos) ════════════════ *@
<div class="movement" data-movement="1" @ref="_movementOne">
<div class="rail" aria-hidden="true">
<div class="rail-line"></div>
<div class="rail-numeral">01</div>
<div class="rail-margin">Charleston, SC</div>
</div>
<div class="movement-content">
@* Waveform movement divider — a static SVG oscillation stroke carrying the
movement tag. The folded-in D3 signature motif. *@
<div class="wave-divider">
<svg class="wave-stroke" viewBox="0 0 1200 40" preserveAspectRatio="none" aria-hidden="true">
<path d="@WavePath" />
</svg>
<span class="wave-tag">The People</span>
</div>
@* People intro — prose hangs at the rail's left edge; the sharp line breaks
left into the margin at large serif scale. *@
<div class="movement-intro">
<div class="movement-label">The Collective</div>
<h2 class="movement-title">Two of Us, No Fixed <em>Roles</em></h2>
<p class="movement-prose">
We met trading synthesizers and found out we were seeking the same thing. Two of us, no fixed roles &mdash; we both write, arrange, produce, mix, record in the field, build the visuals, and make the tools when the tools don't exist yet.
</p>
</div>
@* Member bio pair — framed portrait insets with rail-side captions. Each
composes with the body absent (Khabran ships with an empty body slot, the
same null-renders-nothing discipline as ReleaseDescription). *@
<div class="bio-pair">
@foreach (var member in _members)
{
<article class="bio-card">
<div class="bio-portrait">
@if (member.PortraitImage1 is not null)
{
<ParallaxImage Image1="@member.PortraitImage1"
Image2="@member.PortraitImage2"
Alt1="@($"{member.Name} — portrait")"
NaturalWidth="1365"
NaturalHeight="1365"
WindowHeightFraction="1.0"
ImageHeight="auto"
ImageWidth="100%"
ParallaxSpeed="0.3" />
}
else
{
@* Graceful-degrade placeholder until a portrait file lands. *@
<div class="bio-portrait-placeholder" aria-hidden="true"></div>
}
</div>
<div class="bio-caption">@member.Name &middot; @member.Role</div>
<div class="bio-meta">
<div class="bio-name">@member.Name</div>
@if (!string.IsNullOrWhiteSpace(member.Bio))
{
@foreach (var para in member.Bio.Split("\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
<p class="bio-body">@para</p>
}
}
</div>
</article>
}
</div>
</div>
</div>
@* ════════════════ MOVEMENT TWO — THE PROCESS (logos) ════════════════ *@
<div class="movement" data-movement="2" @ref="_movementTwo">
<div class="rail" aria-hidden="true">
<div class="rail-line"></div>
<div class="rail-numeral">02</div>
<div class="rail-margin">the live rig</div>
</div>
<div class="movement-content">
<div class="wave-divider">
<svg class="wave-stroke" viewBox="0 0 1200 40" preserveAspectRatio="none" aria-hidden="true">
<path d="@WavePath" />
</svg>
<span class="wave-tag">The Process</span>
</div>
@* Dark band — gear-stage cards. The navy ground carries the analytical register. *@
<div class="process-band">
<div class="process-label">How It's Made</div>
<h2 class="process-title">Digital, Analog, <em>Whatever Moves</em></h2>
<p class="process-standfirst">
It doesn't matter how &mdash; digital or analog, hard or soft, bought or built &mdash; as long as it moves the room. The soul in this music is designed, not extracted; assembled, not distilled.
</p>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" /><line x1="3" y1="9" x2="21" y2="9" /><line x1="9" y1="9" x2="9" y2="21" /></svg>
</div>
<div class="feature-title">Sketch</div>
<div class="feature-desc">A loop starts on the Force or the MPC, hands on the pads. The idea has to survive first contact before anything else gets built around it.</div>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24"><line x1="4" y1="21" x2="4" y2="14" /><line x1="4" y1="10" x2="4" y2="3" /><line x1="12" y1="21" x2="12" y2="12" /><line x1="12" y1="8" x2="12" y2="3" /><line x1="20" y1="21" x2="20" y2="16" /><line x1="20" y1="12" x2="20" y2="3" /><line x1="1" y1="14" x2="7" y2="14" /><line x1="9" y1="8" x2="15" y2="8" /><line x1="17" y1="16" x2="23" y2="16" /></svg>
</div>
<div class="feature-title">Arrange</div>
<div class="feature-desc">Sometimes into Ableton, sometimes start-to-finish in REAPER. The track gets shaped wherever it wants to go &mdash; we follow the take, not the template.</div>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="3" /><line x1="12" y1="3" x2="12" y2="6" /><line x1="12" y1="18" x2="12" y2="21" /></svg>
</div>
<div class="feature-title">Studio</div>
<div class="feature-desc">A deep bench of synths, drum machines, and pedals; digital and analog, hard and soft, some of it built by hand. If the sound we need doesn't exist yet, we make the thing that makes it.</div>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24"><path d="M3 12h3l2-7 4 14 2-7h3" /><circle cx="20" cy="12" r="1.5" /></svg>
</div>
<div class="feature-title">Live Rig</div>
<div class="feature-desc">No laptop, no safety net. A full spread of hardware patched together and played 100% live &mdash; sequenced, twisted, and pushed in the moment. Built for the room, the warehouse, the night that doesn't repeat.</div>
</div>
</div>
</div>
@* IMG SLOT D — hands-on-gear inset, the literal proof-of-effort image,
captioned in the rail rather than run full-bleed. *@
<figure class="movement-figure">
<ParallaxImage Image1="img/dd-mixer-2-bw.jpeg"
Image2="img/dd-mixer-2.jpg"
Alt1="Deep DRFT — hands on the gear"
NaturalWidth="2048"
NaturalHeight="1365"
WindowHeightFraction="0.45"
ImageHeight="auto"
ImageWidth="100%"
ParallaxSpeed="0.35" />
<figcaption class="figure-caption">the live rig</figcaption>
</figure>
</div>
</div>
@* ════════════════ MOVEMENT THREE — THE PRODUCT (ethos) ════════════════ *@
<div class="movement" data-movement="3" @ref="_movementThree">
<div class="rail" aria-hidden="true">
<div class="rail-line"></div>
<div class="rail-numeral">03</div>
<div class="rail-margin">in the swamp</div>
</div>
<div class="movement-content">
<div class="wave-divider">
<svg class="wave-stroke" viewBox="0 0 1200 40" preserveAspectRatio="none" aria-hidden="true">
<path d="@WavePath" />
</svg>
<span class="wave-tag">The Product</span>
</div>
<div class="movement-intro">
<div class="movement-label">The Output</div>
<h2 class="movement-title">Classics, with a <em>Twist</em></h2>
<p class="movement-prose">
Everything ends up here, in the catalogue. It's proof people in Charleston are pushing the sound of the club.
</p>
</div>
@* Medium triptych — one-line frame of each medium; definitions, not a re-pitch.
A stacked editorial list rather than Home's card grid. *@
<ul class="medium-list">
<li class="medium-row">
<a href="/cuts">
<span class="medium-row-name">Cuts</span>
<span class="medium-row-desc">Studio work, composed and finished.</span>
</a>
</li>
<li class="medium-row">
<a href="/sessions">
<span class="medium-row-name">Sessions</span>
<span class="medium-row-desc">Live, caught once, never the same twice.</span>
</a>
</li>
<li class="medium-row">
<a href="/mixes">
<span class="medium-row-name">Mixes</span>
<span class="medium-row-desc">Uninterrupted sets, start to finish.</span>
</a>
</li>
</ul>
@* The live turn — "on the street, in the swamp": the identity beyond releases.
A left-breaking pull-quote at large serif scale. *@
<blockquote class="pull-quote">
<span class="pull-eyebrow">Beyond the Releases</span>
<p>
But that's just the releases. We're also out there &mdash; on the street, in the swamp, with a PA, a generator, and a bunch of good vibes.
</p>
</blockquote>
</div>
</div>
@* ── Closing CTA into the catalogue ── *@
<section class="cta-banner">
<div class="cta-text">
<h2 class="cta-headline">Hear the<br /><em>Proof</em></h2>
<p class="cta-sub">The catalogue is the evidence. Start listening.</p>
</div>
<div class="cta-actions">
<a class="btn-white" href="/archive">Explore the Archive</a>
<a class="btn-outline-white" href="/cuts">Hear a Cut</a>
</div>
</section>
@code {
private string AnimClass => RendererInfo.IsInteractive ? string.Empty : "fade-up";
// A static sine path for the movement-divider waveform stroke. Authored as plain
// SVG markup — independent of the live WaveformVisualizer component. The viewBox is
// 1200×40; the curve oscillates around the vertical midline (y=20).
private static readonly string WavePath = BuildWavePath();
private ElementReference _movementOne;
private ElementReference _movementTwo;
private ElementReference _movementThree;
private IJSObjectReference? _railModule;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || !RendererInfo.IsInteractive)
{
return;
}
try
{
// Progressive enhancement only: lights the active movement's numeral green
// as it scrolls into view. Numerals render statically without this.
_railModule = await JsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/about/about-rail.js");
await _railModule.InvokeVoidAsync("observe", _movementOne, _movementTwo, _movementThree);
}
catch (JSException)
{
// Module failed to load — numerals stay statically navy. Nothing actionable.
_railModule = null;
}
}
public async ValueTask DisposeAsync()
{
if (_railModule is not null)
{
try
{
await _railModule.InvokeVoidAsync("unobserve");
await _railModule.DisposeAsync();
}
catch (JSException)
{
// Runtime already gone (navigation/teardown) — nothing to clean up.
}
_railModule = null;
}
}
// Builds an evaluated-at-compile-time sine path string for the divider stroke.
private static string BuildWavePath()
{
// 8 full cycles across the 1200-wide viewBox, amplitude 14 around midline 20.
const int width = 1200;
const int steps = 96;
const double midline = 20;
const double amplitude = 14;
const double cycles = 8;
var sb = new System.Text.StringBuilder("M 0 20");
for (var i = 1; i <= steps; i++)
{
var x = width * (double)i / steps;
var y = midline - amplitude * System.Math.Sin(cycles * 2 * System.Math.PI * i / steps);
sb.Append(System.Globalization.CultureInfo.InvariantCulture, $" L {x:0.##} {y:0.##}");
}
return sb.ToString();
}
// Member bios. Khabran's body is an intentional empty slot — the card composes
// without it (graceful degrade). Daniel's copy is verbatim per spec COPY C,
// including the two typos he chose to keep ("embarked in", "metalhead at from").
// PortraitImage* are null until final portrait files land — the card renders a
// placeholder treatment in their absence.
private record Member(
string Name,
string Role,
string? Bio,
string? PortraitImage1 = null,
string? PortraitImage2 = null);
private readonly Member[] _members =
[
new(
Name: "Khabran Peters",
Role: "Production · Sound Design · Live",
Bio: "Raised on the Chicago underground, this artist cut their teeth on DJ Assault and DJ Funk. They started DJing young, learning to read a room long before they opened a DAW. After fifteen years as a visual artist, they moved into music production.\n\nNow based in Charleston, their sound carries the city's late-night feel but keeps the kinetic edge of its Midwest roots—deep one minute, fast the next. As much indie sensibility as booty-house grit.\n\nThe work is hardware-first, with software kept to remixes and edits. Onstage they stay out of the way and let the tracks do the talking. Polished without being precious—built by someone who cares more about the craft than the spotlight.",
PortraitImage1: "img/dd-khabran-bw.jpeg",
PortraitImage2: "img/dd-khabran.jpeg"),
new(
Name: "Daniel Harvey",
Role: "Production · Sound Design · Live",
Bio: "Daniel started on drums at ten and embarked in electronic music at seventeen — synthesizers first. A metalhead at from a young age, he spent ten years as an engineer living near Detroit filling the nights with synthesized tones and rhythms, shaped most of all by modern underground Detroit techno. Art & engineering cannot be separated: custom plugins, hardware recording & performance rigs, the tools behind the tracks are just as important as the finished sound. To him the science and the math matter as much as the beauty — tension and release, built deliberately.",
PortraitImage1: "img/dd-daniel-bw.jpeg",
PortraitImage2: "img/dd-daniel.jpeg"),
];
}
+709
View File
@@ -0,0 +1,709 @@
/* About.razor scoped styles — "The Liner Notes".
This page diverges from Home by composition, not vocabulary. The backbone is a
persistent left RAIL (a continuous vertical hairline carrying oversized Bodoni
movement numerals + mono marginalia) with the content column offset asymmetrically
to its right. Movement boundaries are rendered as a hand-authored SVG waveform
stroke (the D3 motif folded in). Palette tokens, type stack, the dark Process band,
the feature-card grid, the CTA, and the bw↔colour ParallaxImage crossfade are all
reused from the site's existing vocabulary — only the structure is new.
Home's borrowed primitives that this redesign supersedes (.section-divider /
.divider-line centred rules, the symmetric .section-header-grid 4/8 split, the
.medium-card grid, .section-split) are intentionally NOT re-declared here. */
/* ── Animations (from DeepDrftHero.razor.css) ── */
@keyframes fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: none; }
}
.fade-up {
opacity: 0;
animation: fade-up 0.8s ease forwards;
}
/* ── HERO — the page opener (type scale from Home's .hero-*) ── */
.hero {
min-height: 100vh;
overflow: hidden;
}
.hero-left {
display: flex;
flex-direction: column;
justify-content: center;
padding: 6rem 3rem;
position: relative;
background: var(--deepdrft-white);
height: 100%;
}
.hero-image-pane {
display: flex;
flex-direction: column;
justify-content: center;
background: var(--deepdrft-white);
height: 100%;
}
@media (max-width: 960px) {
.hero { min-height: auto; }
.hero-left { padding: 4rem 1.5rem 3rem; }
}
.hero-eyebrow {
font-family: var(--deepdrft-font-mono);
font-size: 0.65rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
text-transform: uppercase;
margin-bottom: 1.8rem;
display: flex;
align-items: center;
gap: 1rem;
animation-delay: 0.1s;
}
.hero-eyebrow::before {
content: '';
display: block;
width: 2.5rem;
height: 1px;
background: var(--deepdrft-green-accent);
}
.hero-title {
font-family: var(--deepdrft-font-display);
font-size: clamp(4.5rem, 8vw, 8.5rem);
font-weight: 300;
line-height: 0.92;
letter-spacing: -0.02em;
color: var(--deepdrft-navy);
margin-bottom: 0.5rem;
animation-delay: 0.22s;
}
.hero-title em {
font-style: italic;
color: var(--deepdrft-green);
}
.hero-desc {
font-family: var(--deepdrft-font-body);
font-size: 0.92rem;
line-height: 1.75;
color: var(--deepdrft-navy);
opacity: 0.7;
max-width: 36ch;
margin-bottom: 3rem;
animation-delay: 0.44s;
}
/* ══════════════════ THE RAIL + SPINE — the signature device ══════════════════
Each movement is a two-column grid: a narrow rail column on the left carrying the
continuous vertical hairline (the narrative spine), the oversized Bodoni numeral,
and the mono marginalia; the content column to its right. The rail column is
~14% of the page on desktop and collapses to an inline header on mobile. */
.movement {
display: grid;
grid-template-columns: minmax(140px, 14%) minmax(0, 1fr);
background: var(--deepdrft-white);
align-items: start;
}
.rail {
position: relative;
align-self: stretch;
padding: 4rem 0 4rem 3rem;
}
/* The continuous vertical hairline — Home's horizontal .divider-line reoriented,
the same --deepdrft-border token, running the length of the movement. */
.rail-line {
position: absolute;
top: 0;
bottom: 0;
left: 3rem;
width: 1px;
background: var(--deepdrft-border);
}
/* Oversized Bodoni movement numeral. Sticks near the top of the viewport as the
movement scrolls, low-opacity navy by default; the active movement lights green
(toggled by the IntersectionObserver — see Interop/about/about-rail.ts). */
.rail-numeral {
position: sticky;
top: 6rem;
font-family: var(--deepdrft-font-display);
font-size: clamp(5rem, 10vw, 9rem);
font-weight: 300;
line-height: 1;
letter-spacing: -0.04em;
color: var(--deepdrft-navy);
opacity: 0.14;
padding-left: 1.4rem;
transition: color 0.5s ease, opacity 0.5s ease;
}
.movement.is-active .rail-numeral {
color: var(--deepdrft-green-accent);
opacity: 0.95;
}
/* Mono marginalia — a rotated caption set against the spine, the way a magazine
annotates a photo. Reuses the mono eyebrow idiom. */
.rail-margin {
position: sticky;
top: 16rem;
margin-top: 2rem;
padding-left: 1.4rem;
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--deepdrft-muted);
writing-mode: vertical-rl;
transform: rotate(180deg);
transform-origin: center;
height: 12rem;
}
/* ── The content column — asymmetric, left-anchored prose ── */
.movement-content {
padding: 4rem 3rem 5rem 1rem;
min-width: 0;
}
/* ══════════════════ WAVEFORM MOVEMENT DIVIDER (D3 motif) ══════════════════
A self-contained SVG oscillation stroke with the mono movement tag sitting on it.
Replaces Home's flat .divider-line rule between movements. */
.wave-divider {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 4rem;
}
.wave-stroke {
flex: 1;
height: 28px;
min-width: 0;
overflow: visible;
}
.wave-stroke path {
fill: none;
stroke: var(--deepdrft-green-accent);
stroke-width: 1.4;
opacity: 0.7;
vector-effect: non-scaling-stroke;
}
.wave-tag {
flex-shrink: 0;
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--deepdrft-navy);
white-space: nowrap;
}
/* ── Movement intro — prose hanging at a consistent left edge ── */
.movement-intro {
max-width: 60ch;
margin-bottom: 5rem;
}
.movement-label {
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
text-transform: uppercase;
margin-bottom: 1.4rem;
}
.movement-title {
font-family: var(--deepdrft-font-display);
font-size: clamp(2.6rem, 5vw, 4.2rem);
font-weight: 300;
line-height: 1.02;
color: var(--deepdrft-navy);
margin-bottom: 2rem;
}
.movement-title em {
font-style: italic;
color: var(--deepdrft-green);
}
.movement-prose {
font-family: var(--deepdrft-font-body);
font-size: 0.95rem;
line-height: 1.85;
color: var(--deepdrft-navy);
opacity: 0.72;
max-width: 56ch;
}
/* ── Member bio pair — framed portrait insets with rail-side captions ──
Assembled from the existing type tokens (display serif name, mono caption/role,
body prose). The cards are offset/staggered rather than an even grid. */
.bio-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 3rem;
align-items: start;
}
/* Stagger the second card downward so the pair reads as editorial layout, not a
symmetric grid. */
.bio-card:nth-child(2) {
margin-top: 4rem;
}
.bio-card {
display: flex;
flex-direction: column;
}
.bio-portrait {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
border: 1px solid var(--deepdrft-border);
border-radius: 50%;
}
/* Graceful-degrade slot shown until a portrait file lands. A flat tonal panel in
the navy family, matching the circular portrait frame. */
.bio-portrait-placeholder {
width: 100%;
aspect-ratio: 1 / 1;
background:
linear-gradient(160deg,
color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-white)) 0%,
color-mix(in srgb, var(--deepdrft-navy) 16%, var(--deepdrft-white)) 100%);
}
/* The marginalia caption — mono, sits directly under the framed portrait. */
.bio-caption {
font-family: var(--deepdrft-font-mono);
font-size: 0.56rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-muted);
margin-top: 0.9rem;
padding-left: 0.1rem;
}
.bio-meta {
padding-top: 1.4rem;
}
.bio-name {
font-family: var(--deepdrft-font-display);
font-size: 2rem;
font-weight: 300;
line-height: 1.1;
color: var(--deepdrft-navy);
margin-bottom: 1rem;
}
.bio-body {
font-family: var(--deepdrft-font-body);
font-size: 0.85rem;
line-height: 1.8;
color: var(--deepdrft-navy);
opacity: 0.7;
}
.bio-body + .bio-body {
margin-top: 0.75em;
}
/* ── Inset framed figure (gear shot) with rail-side caption ── */
.movement-figure {
margin: 5rem 0 0;
}
.movement-figure ::deep .parallax-window {
border: 1px solid var(--deepdrft-border);
}
.figure-caption {
font-family: var(--deepdrft-font-mono);
font-size: 0.56rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-muted);
margin-top: 0.9rem;
}
/* ══════════════════ THE PROCESS — dark band (reused vocabulary) ══════════════════ */
.process-band {
background: var(--deepdrft-navy);
padding: 4.5rem 3rem;
color: var(--deepdrft-white);
}
.process-label {
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
text-transform: uppercase;
margin-bottom: 1.2rem;
}
.process-title {
font-family: var(--deepdrft-font-display);
font-size: clamp(2.4rem, 4.5vw, 3.8rem);
font-weight: 300;
line-height: 1.02;
color: var(--deepdrft-white);
}
.process-title em {
font-style: italic;
color: var(--deepdrft-green-accent);
}
.process-standfirst {
font-family: var(--deepdrft-font-body);
font-size: 0.92rem;
line-height: 1.8;
color: rgba(250, 250, 248, 0.55);
max-width: 56ch;
margin-top: 2rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
border: 1px solid rgba(250, 250, 248, 0.08);
margin-top: 3.5rem;
}
.feature-card {
padding: 2.5rem;
border-right: 1px solid rgba(250, 250, 248, 0.08);
border-bottom: 1px solid rgba(250, 250, 248, 0.08);
transition: background 0.3s;
}
/* 2×2 grid: kill the right border on the right column and the bottom border on the
last row so the outer frame stays clean. */
.feature-card:nth-child(2n) { border-right: none; }
.feature-card:nth-child(n + 3) { border-bottom: none; }
.feature-card:hover { background: rgba(250, 250, 248, 0.04); }
.feature-icon {
width: 2.5rem;
height: 2.5rem;
border: 1px solid rgba(250, 250, 248, 0.15);
margin-bottom: 1.8rem;
display: flex;
align-items: center;
justify-content: center;
}
.feature-icon svg {
width: 1rem;
height: 1rem;
stroke: var(--deepdrft-green-accent);
fill: none;
stroke-width: 1.5;
}
.feature-title {
font-family: var(--deepdrft-font-display);
font-size: 1.3rem;
font-weight: 400;
color: var(--deepdrft-white);
margin-bottom: 0.8rem;
line-height: 1.2;
}
.feature-desc {
font-family: var(--deepdrft-font-body);
font-size: 0.8rem;
line-height: 1.7;
color: rgba(250, 250, 248, 0.45);
}
/* ══════════════════ THE PRODUCT — medium list + pull-quote ══════════════════
A stacked editorial definition list, not Home's card grid. */
.medium-list {
list-style: none;
margin: 0 0 5rem;
padding: 0;
border-top: 1px solid var(--deepdrft-border);
max-width: 60ch;
}
.medium-row {
border-bottom: 1px solid var(--deepdrft-border);
}
.medium-row a {
display: flex;
align-items: baseline;
gap: 1.5rem;
padding: 1.6rem 0.4rem;
text-decoration: none;
transition: padding-left 0.25s ease;
}
.medium-row a:hover { padding-left: 1.2rem; }
.medium-row-name {
flex-shrink: 0;
font-family: var(--deepdrft-font-display);
font-size: 1.5rem;
font-weight: 400;
color: var(--deepdrft-navy);
min-width: 7rem;
}
.medium-row a:hover .medium-row-name { color: var(--deepdrft-green-accent); }
.medium-row-desc {
font-family: var(--deepdrft-font-body);
font-size: 0.85rem;
line-height: 1.6;
color: var(--deepdrft-navy);
opacity: 0.6;
}
/* The sharp pull-quote — breaks LEFT into the rail margin at large serif scale. */
.pull-quote {
margin: 0;
margin-left: -7rem;
max-width: 70ch;
}
.pull-eyebrow {
display: block;
font-family: var(--deepdrft-font-mono);
font-size: 0.6rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--deepdrft-green-accent);
margin-bottom: 1.4rem;
}
.pull-quote p {
font-family: var(--deepdrft-font-display);
font-size: clamp(1.8rem, 3.4vw, 2.9rem);
font-weight: 300;
line-height: 1.15;
color: var(--deepdrft-navy);
}
/* ══════════════════ CLOSING CTA (reused vocabulary) ══════════════════ */
.cta-banner {
background: var(--deepdrft-navy);
padding: 6rem 3rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 3rem;
position: relative;
overflow: hidden;
}
.cta-banner::before {
content: 'DRFT';
position: absolute;
right: -2rem;
top: 50%;
transform: translateY(-50%);
font-family: var(--deepdrft-font-display);
font-size: 22rem;
font-weight: 300;
color: rgba(250, 250, 248, 0.03);
line-height: 1;
pointer-events: none;
user-select: none;
letter-spacing: -0.05em;
}
.cta-text {
position: relative;
z-index: 1;
}
.cta-headline {
font-family: var(--deepdrft-font-display);
font-size: clamp(2.5rem, 5vw, 5rem);
font-weight: 300;
color: var(--deepdrft-white);
line-height: 1;
margin-bottom: 1rem;
}
.cta-headline em {
font-style: italic;
color: var(--deepdrft-green-accent);
}
.cta-sub {
font-family: var(--deepdrft-font-body);
font-size: 0.88rem;
color: rgba(250, 250, 248, 0.4);
letter-spacing: 0.05em;
}
.cta-actions {
display: flex;
gap: 1rem;
align-items: center;
position: relative;
z-index: 1;
flex-shrink: 0;
flex-wrap: wrap;
}
.btn-white {
font-family: var(--deepdrft-font-mono);
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-navy);
background: var(--deepdrft-white);
border: none;
padding: 1rem 2.2rem;
cursor: pointer;
text-decoration: none;
transition: background 0.25s, color 0.25s;
display: inline-block;
}
.btn-white:hover {
background: var(--deepdrft-green-accent);
color: var(--deepdrft-white);
}
.btn-outline-white {
font-family: var(--deepdrft-font-mono);
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-white);
background: transparent;
border: 1px solid rgba(250, 250, 248, 0.3);
padding: 1rem 2.2rem;
cursor: pointer;
text-decoration: none;
transition: border-color 0.25s;
display: inline-block;
}
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
/* ══════════════════ RESPONSIVE COLLAPSE ══════════════════
Below 960px the rail collapses: the spine + vertical numeral can't survive a
narrow viewport, so the numeral goes inline above each movement (horizontal,
left-aligned) and the marginalia folds away. Content goes single-column. */
@media (max-width: 960px) {
.movement {
display: block;
}
.rail {
padding: 2.5rem 1.5rem 0;
}
/* Spine becomes a short horizontal accent under the inline numeral. */
.rail-line {
position: static;
width: 3rem;
height: 2px;
margin-top: 1rem;
background: var(--deepdrft-border);
}
.rail-numeral {
position: static;
opacity: 0.18;
padding-left: 0;
font-size: clamp(3.5rem, 16vw, 5.5rem);
}
.movement.is-active .rail-numeral {
opacity: 0.95;
}
/* Marginalia is editorial chrome the narrow column can't host — drop it. */
.rail-margin {
display: none;
}
.movement-content {
padding: 2.5rem 1.5rem 3.5rem;
}
.process-band { padding: 3.5rem 1.5rem; }
/* Pull-quote can't break into a rail that no longer exists. */
.pull-quote {
margin-left: 0;
max-width: 100%;
}
}
@media (max-width: 720px) {
/* Bio pair stacks; drop the stagger so cards align cleanly. */
.bio-pair {
grid-template-columns: 1fr;
gap: 3.5rem;
}
.bio-card:nth-child(2) {
margin-top: 0;
}
}
@media (max-width: 599px) {
.features-grid { grid-template-columns: 1fr; }
.feature-card {
border-right: none;
border-bottom: 1px solid rgba(250, 250, 248, 0.08);
}
.feature-card:last-child { border-bottom: none; }
.medium-row a {
flex-direction: column;
gap: 0.4rem;
}
.cta-banner {
flex-direction: column;
align-items: flex-start;
gap: 2rem;
padding: 4rem 1.5rem;
}
.cta-actions {
width: 100%;
}
.btn-white,
.btn-outline-white {
flex: 1;
text-align: center;
min-width: 140px;
}
.cta-banner::before {
font-size: 8rem;
right: -0.5rem;
}
}
@@ -43,6 +43,21 @@ else
BackHref="/cuts"
BackLabel="All cuts"
ShowShareRow="false">
<Ambient>
@* Ambient living waveform behind the album hero + track list (Phase 12 §3c/§3f mode B).
Cut is multi-track: anchor to the release's EntryKey and default to the first track by
TrackNumber. The bridge follows the live playing track within the release automatically
(keys on TrackId match OR shared ReleaseEntryKey), so the field re-renders to whichever
track the listener starts; TrackEntryKey is the at-rest datum before playback. *@
<WaveformVisualizer ReleaseEntryKey="@release.EntryKey"
TrackId="@firstTrack?.Id"
TrackEntryKey="@firstTrack?.EntryKey" />
</Ambient>
<TopRightAction>
@* Lava-lamp icon → popover panel (full parity, §3d-revised). Sits top-right across from the
back link, clear of the header's own Play/Share affordances below. *@
<WaveformVisualizerControlPopover />
</TopRightAction>
<Header>
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
<div class="cut-detail-header">
@@ -100,6 +115,8 @@ else
</div>
</Header>
<BodyContent>
@* Blurb sits between the header and the track-list divider. *@
<ReleaseDescription Description="@release.Description" />
<MudDivider Class="cut-detail-divider" />
@if (ViewModel.Tracks.Count == 0)
{
+4 -6
View File
@@ -14,9 +14,7 @@
</MudItem>
<MudItem xs="12" md="6">
<div class="hero-right">
<NowPlaying />
</div>
<NowPlaying />
</MudItem>
</MudGrid>
<ParallaxImage Image1="img/dd-duo-hero-bw.jpeg"
@@ -146,10 +144,10 @@
<MudItem xs="12" md="6">
<div class="split-right">
<ParallaxImage Image1="img/kp-shoulder-bw.jpeg"
<ParallaxImage Image1="img/dd-pedals.jpeg"
InvertDirection
NaturalWidth="1365"
NaturalHeight="2048"
NaturalWidth="1159"
NaturalHeight="1196"
WindowHeightFraction="0.5"
ImageHeight="auto"
ImageWidth="100%"
@@ -19,20 +19,9 @@
height: 100%;
}
.hero-right {
background: var(--deepdrft-navy);
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
height: 100%;
}
@media (max-width: 960px) {
.hero { min-height: auto; }
.hero-left { padding: 4rem 1.5rem 3rem; }
.hero-right { min-height: 50vh; }
}
/* ── DIVIDER ── */
+14 -29
View File
@@ -35,8 +35,11 @@ else
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
playback only when the player is on this mix's track. *@
<MixWaveformVisualizer ReleaseEntryKey="@release.EntryKey" TrackId="@ViewModel.Track?.Id" />
playback only when the player is on this mix's track; TrackEntryKey is the datum to render at rest
(before playback) — the mix's single track, so the lava shows immediately on page load (§4). *@
<WaveformVisualizer ReleaseEntryKey="@release.EntryKey"
TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" />
<div class="mix-detail-foreground">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
@@ -52,27 +55,12 @@ else
ShowHeader="false"
ShowMeta="false"
ShowShareRow="false">
<TopContent>
@* The eight-knob band lives in its own full-width area below the back/lamp top row.
Phase 10 §4: the control is ALWAYS rendered; the lava-lamp toggle feeds its Visible
parameter, and the control itself @if-gates the knobs while holding the container's
reserved height — so content below never pops on toggle. The band mutates the shared
MixVisualizerControlState; the backdrop bridge pushes the dials. A knob drag does not
toggle it — the lamp's click does. *@
<MixVisualizerControls Visible="@_controlsExpanded" />
</TopContent>
<TopRightAction>
@* Lava-lamp button top-right, across from the back link. Toggles the knob band below the
row. The icon swaps to its FILLED variant while the band is shown (§7f / Part B). *@
<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>
@* Lava-lamp icon → popover panel, top-right across from the back link (Phase 12
§3d-revised). Replaces the former inline TopContent knob-bar: the icon IS the toggle
and the popover IS the panel. Mix takes the cleanest anchor case (§8e) — the popover's
default bottom-right anchor opens down over the full-bleed field. *@
<WaveformVisualizerControlPopover />
</TopRightAction>
<Hero>
@* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The
@@ -97,6 +85,10 @@ else
</PlayContent>
</ReleaseHeroOverlay>
</Hero>
<BodyContent>
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
<ReleaseDescription Description="@release.Description" />
</BodyContent>
</ReleaseDetailScaffold>
</MudContainer>
</div>
@@ -125,11 +117,4 @@ else
await PlayerService.SelectTrackStreaming(track);
}
}
// Lava-lamp knob-band visibility. Pure presentation over MixVisualizerControlState — gates whether
// the seven-knob MixVisualizerControls is rendered into the TopContent band; toggling it touches no
// control value or bridge push. The lava-lamp button's filled/outline glyph is driven off this flag.
private bool _controlsExpanded;
private void ToggleSettings() => _controlsExpanded = !_controlsExpanded;
}
@@ -40,11 +40,26 @@ else
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page">
@* Ambient living waveform behind the hero overlay (Phase 12 §3e option b / §3f mode B). Session does
NOT compose ReleaseDetailScaffold, so it mounts the shared engine directly with its own thin
full-bleed wrapper — the engine is single-source either way, only the mount differs (§3b). The
visualizer positions itself fixed/inset:0; the session-detail-foreground class lifts the content
above it. The bridge follows the live playing track; TrackEntryKey is the at-rest datum. *@
<WaveformVisualizer ReleaseEntryKey="@release.EntryKey"
TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" />
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; All sessions
</MudLink>
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page session-detail-foreground">
<div class="session-detail-top-row">
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; All sessions
</MudLink>
@* Lava-lamp icon → popover panel (full parity, §3e/§3d-revised). Anchored top-right, clear of
the hero overlay and the share/play affordances overlaid on the hero below. *@
<WaveformVisualizerControlPopover />
</div>
@* The overlay shows the cover thumbnail only when it differs from the resolved hero image —
when there is no dedicated hero, heroImage already falls back to release.ImagePath, so the
@@ -68,6 +83,8 @@ else
</PlayContent>
</ReleaseHeroOverlay>
<ReleaseDescription Description="@release.Description" />
</MudContainer>
}
@@ -6,3 +6,20 @@
padding-top: 2rem;
padding-bottom: 4rem;
}
/* Lifts the session content above the fixed full-bleed waveform layer (z-index: 0). Session mounts the
visualizer directly (it does not compose ReleaseDetailScaffold), so the foreground stacking context
lives here rather than on the scaffold (Phase 12 §3e option b). The class lands on the MudContainer's
rendered root, so ::deep is required to reach it. */
::deep .session-detail-foreground {
position: relative;
z-index: 1;
}
/* Back link (left) | lava-lamp popover trigger (right) on one row, mirroring the scaffold's top row.
The popover icon clears the hero overlay below and the share/play affordances overlaid on it. */
.session-detail-top-row {
display: flex;
align-items: center;
justify-content: space-between;
}
@@ -24,11 +24,4 @@ public interface IReleaseDataService
/// <summary>Single release resolved by its opaque public EntryKey, with both metadata satellites (nulls for non-matching media).</summary>
Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey);
/// <summary>
/// The Mix waveform datum for a release addressed by its public EntryKey. Success with a value
/// when present; success with a null value when no datum is stored (a valid state, not a failure);
/// failure on any other transport error.
/// </summary>
Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey);
}
@@ -30,6 +30,14 @@ public interface ITrackDataService
Task<ApiResult<TrackDto>> GetTrack(string trackId);
/// <summary>
/// The per-track high-res waveform datum, addressed by the track's EntryKey (phase-12 §5b — the
/// datum is the track's; the release is only addressing context). Success with a value when a
/// high-res datum is stored; success with a null value when none is (a not-yet-backfilled track —
/// a valid state, not a failure, the visualizer blanks); failure on any other transport error.
/// </summary>
Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey);
/// <summary>
/// Fetches a random track from the public library for instant play. Success with a value on a
/// hit; success with a null value when the library is empty (a valid state, not a failure);
@@ -31,7 +31,4 @@ public class ReleaseClientDataService : IReleaseDataService
public Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
=> _releaseClient.GetByEntryKey(entryKey);
public Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
=> _releaseClient.GetMixWaveform(entryKey);
}
@@ -39,6 +39,9 @@ public class TrackClientDataService : ITrackDataService
public Task<ApiResult<TrackDto>> GetTrack(string trackId)
=> _trackClient.GetTrack(trackId);
public Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey)
=> _trackClient.GetTrackWaveform(trackEntryKey);
public Task<ApiResult<TrackDto?>> GetRandomTrack()
=> _trackClient.GetRandom();
}
@@ -1,8 +1,8 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Holds the Mix visualizer's eight 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
/// Holds the waveform visualizer's eight continuous-control positions plus two subsystem on/off
/// toggles 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 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 (lava reframe §7c).
@@ -11,27 +11,27 @@ namespace DeepDrftPublic.Client.Services;
/// parameters: this is a plain scoped value holder, so widening it (the Phase 10 split of the single
/// density knob into fluid-amount + fluid-viscosity) 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 <c>DefaultVisibleSeconds</c> /
/// WaveformVisualizer.ts; keep the two in sync, as the <c>DefaultVisibleSeconds</c> /
/// <c>DEFAULT_VISIBLE_SECONDS</c> pair does.
///
/// <para>
/// <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 raises <see cref="Changed"/>; the bridge component (<c>WaveformVisualizer</c>) subscribes
/// 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
public sealed class WaveformVisualizerControlState
{
// ── The eight control defaults (Phase 10). Each mirrors a DEFAULT_* anchor in
// MixVisualizer.ts; keep the two in sync, as the default-sync discipline requires.
// WaveformVisualizer.ts; keep the two in sync, as the 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 scroll-speed dial. Normalized [0,1] → mapped C#-side to a visible time-span in seconds
/// via <see cref="MixZoomMapping"/>, then sent to MixVisualizer.ts as a seconds value via
/// via <see cref="WaveformZoomMapping"/>, then sent to WaveformVisualizer.ts as a seconds value via
/// <c>setScrollSpeed</c>. The TS-side anchor is <c>DEFAULT_VISIBLE_SECONDS</c>. Opens mid-range
/// (0 = slow/wide window, 1 = fast/tight window).
/// </summary>
@@ -39,26 +39,26 @@ public sealed class MixVisualizerControlState
/// <summary>
/// Default gradient-rotation-speed dial. Mirrors <c>DEFAULT_GRADIENT_ROTATION_SPEED</c> in
/// MixVisualizer.ts. Normalized [0,1] → slow→fast anchor-rotation; drives the live OKLab
/// WaveformVisualizer.ts. Normalized [0,1] → slow→fast anchor-rotation; drives the live OKLab
/// three-colour gradient. 0.45 opens with a clearly-visible ~7 s colour cycle (Phase 10 §3.2).
/// </summary>
public const double DefaultGradientRotationSpeed = 0.45;
/// <summary>
/// Default lava-gravity dial. Mirrors <c>DEFAULT_LAVA_GRAVITY</c> in MixVisualizer.ts. Normalized
/// Default lava-gravity dial. Mirrors <c>DEFAULT_LAVA_GRAVITY</c> in WaveformVisualizer.ts. Normalized
/// [0,1]; 0 = near-weightless float, 1 = wax falls + settles fast. Tuned to Daniel's ~20% sweet spot.
/// </summary>
public const double DefaultLavaGravity = 0.2;
/// <summary>
/// Default lava-heat dial. Mirrors <c>DEFAULT_LAVA_HEAT</c> in MixVisualizer.ts. Normalized [0,1];
/// Default lava-heat dial. Mirrors <c>DEFAULT_LAVA_HEAT</c> in WaveformVisualizer.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 DefaultLavaHeat = 1.0;
/// <summary>
/// Default fluid-amount dial. Mirrors <c>DEFAULT_FLUID_AMOUNT</c> in MixVisualizer.ts. The first
/// Default fluid-amount dial. Mirrors <c>DEFAULT_FLUID_AMOUNT</c> in WaveformVisualizer.ts. The first
/// half of the Phase 10 "bubbles" split. Normalized [0,1]; 0 = few small blobs, 1 = many larger
/// blobs (more wax in the container — blob count + per-blob volume).
/// </summary>
@@ -66,26 +66,39 @@ public sealed class MixVisualizerControlState
/// <summary>
/// Default fluid-viscosity / cohesion dial. Mirrors <c>DEFAULT_FLUID_VISCOSITY</c> in
/// MixVisualizer.ts. The second half of the Phase 10 "bubbles" split. Normalized [0,1]; 1 = high
/// WaveformVisualizer.ts. The second half of the Phase 10 "bubbles" split. Normalized [0,1]; 1 = high
/// cohesion (crisp spheres that snap back), 0 = low cohesion (deforms freely, stays gooey/merged).
/// </summary>
public const double DefaultFluidViscosity = 0.6;
/// <summary>
/// Default collision-strength dial. Mirrors <c>DEFAULT_COLLISION_STRENGTH</c> in MixVisualizer.ts.
/// Default collision-strength dial. Mirrors <c>DEFAULT_COLLISION_STRENGTH</c> in WaveformVisualizer.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.
/// Default waveform-width dial. Mirrors <c>DEFAULT_WAVEFORM_WIDTH</c> in WaveformVisualizer.ts.
/// Normalized [0,1], mapped onto the useful 10%95% ribbon-extent band (Phase 10 §3.7); 0.5 opens
/// mid-band. Narrowing clears room for the lava.
/// </summary>
public const double DefaultWaveformWidth = 0.5;
/// <summary>
/// Default lava-subsystem on-state. <c>true</c> so the lava field is on out of the box — the
/// current behavior. Backs the row-1 lava lamp toggle (Phase 15 §6). Has no TS-side anchor: the
/// bridge pushes it as an enable/disable, not a tuning dial.
/// </summary>
public const bool DefaultLavaEnabled = true;
/// <summary>
/// Default waveform-subsystem on-state. <c>true</c> so the waveform ribbon is on out of the box.
/// Backs the row-1 waveform lamp toggle (Phase 15 §6).
/// </summary>
public const bool DefaultWaveformEnabled = true;
/// <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>
/// time-span via <see cref="WaveformZoomMapping"/>; the standalone resolution/zoom control is gone.</summary>
public double ScrollSpeed { get; set; } = DefaultScrollSpeed;
/// <summary>Gradient anchor-rotation rate, normalized [0,1]. Drives the live OKLab gradient.</summary>
@@ -110,6 +123,20 @@ public sealed class MixVisualizerControlState
/// <summary>Waveform-band horizontal extent, normalized [0,1]. Narrowing clears room for the lava.</summary>
public double WaveformWidth { get; set; } = DefaultWaveformWidth;
/// <summary>
/// Whether the lava field is drawn. When <c>false</c> the lava subsystem is genuinely not rendered
/// (the bridge skips its physics + uploads no blobs — no render cost, Phase 15 §6/§10.1), not dimmed.
/// Also gates the row-1/row-2 control visibility (§3).
/// </summary>
public bool LavaEnabled { get; set; } = DefaultLavaEnabled;
/// <summary>
/// Whether the waveform ribbon is drawn. When <c>false</c> the ribbon subsystem is genuinely not
/// rendered (the bridge disables the ribbon SDF + drops its collision boundary — no render cost,
/// Phase 15 §6/§10.1), not dimmed. Also gates the row-1/row-3 control visibility (§3).
/// </summary>
public bool WaveformEnabled { get; set; } = DefaultWaveformEnabled;
/// <summary>
/// Raised whenever any control value changes. The visualizer bridge subscribes to push the
/// affected dial(s). Mutators set the property then raise this; subscribers re-read the values.
+3 -3
View File
@@ -26,9 +26,9 @@ public static class Startup
services.AddScoped<ReleaseDetailViewModel>();
services.AddScoped<CutDetailViewModel>();
// Mix visualizer controls — scoped so the four slider positions persist across navigation
// within a session and reset on a fresh page load (see MixVisualizerControlState).
services.AddScoped<MixVisualizerControlState>();
// Waveform visualizer controls — scoped so the eight slider positions persist across navigation
// within a session and reset on a fresh page load (see WaveformVisualizerControlState).
services.AddScoped<WaveformVisualizerControlState>();
}
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
@@ -47,11 +47,6 @@ public class ReleaseProxyController : ControllerBase
return await RelayJson(query, "release list");
}
/// <summary>Proxies the Mix waveform datum, addressed by the release's opaque EntryKey. A 404 (no datum stored) passes through verbatim.</summary>
[HttpGet("{entryKey}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
=> await RelayJson($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform", $"release {entryKey} mix waveform", ct);
/// <summary>Proxies a single release, addressed by its opaque EntryKey. A 404 (no such release) passes through verbatim.</summary>
[HttpGet("{entryKey}")]
public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
@@ -319,4 +319,40 @@ public class TrackProxyController : ControllerBase
return Content(json, "application/json");
}
}
/// <summary>
/// Proxies a track's high-res waveform datum (JSON) from DeepDrftAPI — the per-track datum the lava
/// visualizer fetches for the current track (phase-12 §5b). Unauthenticated, same posture as the
/// 512-bucket profile forward above; the "high-res" suffix selects the TrackWaveforms datum. Small
/// JSON, buffered and relayed; a 404 (no high-res datum stored — track not yet backfilled) passes
/// through so the visualizer blanks gracefully.
/// </summary>
[HttpGet("{trackId}/waveform/high-res")]
public async Task<ActionResult> GetHighResWaveform(string trackId, CancellationToken ct = default)
{
var path = $"api/track/{Uri.EscapeDataString(trackId)}/waveform/high-res";
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/{TrackId}/waveform/high-res failed", trackId);
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/{TrackId}/waveform/high-res returned {Status}", trackId, (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
}
@@ -0,0 +1,65 @@
/**
* About-page rail active-numeral highlight.
*
* The Liner Notes layout carries one oversized Bodoni numeral per movement in a
* persistent left rail. This module lights the numeral of whichever movement is
* currently in view by toggling `.is-active` on the movement element; the CSS does
* the colour transition. Pure progressive enhancement — the numerals render
* statically in low-opacity navy without this, so a load failure or a no-JS client
* degrades to a still-legible page.
*
* One observer at a time, re-pointed on each `observe` call. The active movement is
* the one nearest the top of the viewport among those currently intersecting, which
* keeps a single numeral lit during the scroll rather than flickering between
* adjacent movements at the boundary.
*/
let observer: IntersectionObserver | null = null;
let observed: Element[] = [];
function refreshActive(): void {
let best: Element | null = null;
let bestTop = Number.POSITIVE_INFINITY;
for (const el of observed) {
const rect = el.getBoundingClientRect();
const viewportH = window.innerHeight || document.documentElement.clientHeight;
// In view at all (any overlap with the viewport).
const inView = rect.bottom > 0 && rect.top < viewportH;
if (!inView) continue;
// Prefer the movement whose top is closest to (but not far below) the fold.
const distance = Math.abs(rect.top);
if (distance < bestTop) {
bestTop = distance;
best = el;
}
}
for (const el of observed) {
el.classList.toggle('is-active', el === best);
}
}
export function observe(...elements: Element[]): void {
unobserve();
observed = elements.filter(Boolean);
if (observed.length === 0) return;
// IntersectionObserver only tells us *that* visibility changed; the actual
// "which is nearest the fold" decision is recomputed from live rects so the
// choice stays correct mid-scroll.
observer = new IntersectionObserver(() => refreshActive(), {
threshold: [0, 0.25, 0.5, 0.75, 1],
});
for (const el of observed) observer.observe(el);
// Seed once so the first movement lights before any scroll.
refreshActive();
}
export function unobserve(): void {
observer?.disconnect();
observer = null;
for (const el of observed) el.classList.remove('is-active');
observed = [];
}
@@ -1,5 +1,5 @@
/**
* MixVisualizer the scrolling Mix waveform background (Phase 10 + Lava Reframe).
* WaveformVisualizer the scrolling waveform background (Phase 10 + Lava Reframe).
*
* What this renders: a *windowed* slice of a mix's loudness profile, scrolling
* bottom-to-top, coupled to playback position. New audio enters at the bottom,
@@ -60,7 +60,7 @@ export const MAX_VISIBLE_SECONDS = 30;
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
// WaveformVisualizerControlState.cs — keep the two in sync, exactly as the
// 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).
@@ -359,7 +359,7 @@ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;
// ── Diagnostics ──────────────────────────────────────────────────────────────────
//
// Set true to surface the init/draw/datum/playback seams to the browser console
// (all prefixed `[MixVisualizer]`). The error/warn paths fire regardless of this
// (all prefixed `[WaveformVisualizer]`). The error/warn paths fire regardless of this
// flag — they only trigger on the abnormal path. The verbose `log` paths (datum
// 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
@@ -367,7 +367,7 @@ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;
// NOTE: defaults to false; set true temporarily to surface verbose seams in-browser.
const DEBUG = false;
const TAG = '[MixVisualizer]';
const TAG = '[WaveformVisualizer]';
function debugLog(...args: unknown[]): void {
if (DEBUG) console.log(TAG, ...args);
}
@@ -507,7 +507,7 @@ interface Playback {
pushWallClockMs: number;
}
export interface MixVisualizerHandle {
export interface WaveformVisualizerHandle {
setDatum(samplesBase64: string, durationSeconds: number): void;
setPlayback(positionSeconds: number, isPlaying: boolean): void;
/** Visible time-span in seconds — the scroll-speed control, mapped from [0,1] on the C# side. */
@@ -527,6 +527,19 @@ export interface MixVisualizerHandle {
setCollisionStrength(value: number): void;
/** [0,1]. Waveform-band horizontal extent (1 = full ribbon, lower narrows). */
setWaveformWidth(value: number): void;
/**
* Enable/disable the LAVA subsystem (Phase 15). When disabled the wax is genuinely NOT rendered:
* the per-frame physics step is skipped and zero blobs are uploaded (uBlobCount = 0), so the
* shader's blob loop unions nothing no render cost, not a dimmed/visible=false uniform (§10.1).
*/
setLavaEnabled(enabled: boolean): void;
/**
* Enable/disable the WAVEFORM-ribbon subsystem (Phase 15). When disabled the ribbon SDF is skipped
* in the shader (uWaveformEnabled = 0 makes waveformSdf return "fully outside") and its CPU
* collision boundary is dropped (sampleLoudnessAt reads 0), so the ribbon contributes nothing to
* the surface and the wax stops bouncing off an invisible wall a genuine skip, not a dim (§10.1).
*/
setWaveformEnabled(enabled: boolean): void;
/** Re-read the palette CSS vars off the canvas (call after a dark-mode toggle). */
refreshTheme(): void;
dispose(): void;
@@ -613,6 +626,8 @@ uniform float uPlayheadSeconds; // current playback position (per-frame)
uniform float uTimeSeconds; // monotonic clock (per-frame) — drives field morph
uniform float uVisibleSeconds; // zoom: window time-span (per change)
uniform float uWaveformWidth; // [0,1] R2: scales the ribbon half-width (narrow the band for lava room)
uniform float uWaveformEnabled; // [0,1] Phase 15: 1 = ribbon drawn, 0 = ribbon subsystem skipped (no
// contribution to the surface — see waveformSdf's early-out)
uniform float uCohesion; // [0,1] Phase 10: fluid viscosity/cohesion — high = crisp spheres,
// low = gooey/deformed (drives the smin blend width + wobble below)
// NOTE: the lava physics params (gravity/heat/collision/density) are NOT shader uniforms
@@ -877,6 +892,10 @@ vec3 anchorAtPhase(float phase) {
// distance to that vertical ribbon band. Loudness at neighbour rows is NOT re-stacked
// here (the per-row geometry from Wave 1 is already smooth); the band is the ribbon.
float waveformSdf(vec2 p, float aspect, float nowYn, float secondsPerHeight) {
// Phase 15: ribbon subsystem off → return "fully outside" so the smin union ignores it entirely
// (a far positive distance never pulls the surface toward the centre line). This is the genuine
// skip — the ribbon contributes nothing, rather than being drawn-then-hidden.
if (uWaveformEnabled < 0.5) return 1e9;
// Mix-time at this row: rows below the now-line are future audio, above are past.
float t = uPlayheadSeconds + (p.y - nowYn) * secondsPerHeight;
float amp = sampleAt(t); // loudness 0..1 at this row
@@ -1028,13 +1047,13 @@ void main() {
/** Compile one shader stage, throwing with the info log on failure. */
function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
const shader = gl.createShader(type);
if (!shader) throw new Error('MixVisualizer: gl.createShader returned null.');
if (!shader) throw new Error('WaveformVisualizer: gl.createShader returned null.');
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`MixVisualizer: shader compile failed: ${log}`);
throw new Error(`WaveformVisualizer: shader compile failed: ${log}`);
}
return shader;
}
@@ -1044,7 +1063,7 @@ function linkProgram(gl: WebGL2RenderingContext): WebGLProgram {
const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
const program = gl.createProgram();
if (!program) throw new Error('MixVisualizer: gl.createProgram returned null.');
if (!program) throw new Error('WaveformVisualizer: gl.createProgram returned null.');
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
@@ -1054,13 +1073,13 @@ function linkProgram(gl: WebGL2RenderingContext): WebGLProgram {
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`MixVisualizer: program link failed: ${log}`);
throw new Error(`WaveformVisualizer: program link failed: ${log}`);
}
return program;
}
/** The no-op handle returned when WebGL2 is unavailable or setup fails. */
function noopHandle(): MixVisualizerHandle {
function noopHandle(): WaveformVisualizerHandle {
return {
setDatum() {},
setPlayback() {},
@@ -1072,12 +1091,14 @@ function noopHandle(): MixVisualizerHandle {
setFluidViscosity() {},
setCollisionStrength() {},
setWaveformWidth() {},
setLavaEnabled() {},
setWaveformEnabled() {},
refreshTheme() {},
dispose() {},
};
}
export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
export function create(canvas: HTMLCanvasElement): WaveformVisualizerHandle {
// premultipliedAlpha so the translucent ribbon composites correctly over the
// page; antialias off (the soft-edge smoothstep handles AA in-shader, and MSAA
// would cost fill rate we don't need for a backdrop).
@@ -1129,6 +1150,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
timeSeconds: gl.getUniformLocation(program, 'uTimeSeconds'),
visibleSeconds: gl.getUniformLocation(program, 'uVisibleSeconds'),
waveformWidth: gl.getUniformLocation(program, 'uWaveformWidth'),
waveformEnabled: gl.getUniformLocation(program, 'uWaveformEnabled'),
cohesion: gl.getUniformLocation(program, 'uCohesion'),
durationSeconds: gl.getUniformLocation(program, 'uDurationSeconds'),
colorNavy: gl.getUniformLocation(program, 'uColorNavy'),
@@ -1167,6 +1189,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
let waveformWidth = DEFAULT_WAVEFORM_WIDTH;
// LIVE as of Wave R3 — drives the gradient anchor-rotation rate (Motion 1).
let gradientRotationSpeed = DEFAULT_GRADIENT_ROTATION_SPEED;
// Phase 15 — subsystem on/off. Default ON (mirrors C# DefaultLavaEnabled / DefaultWaveformEnabled),
// so out of the box both subsystems run exactly as before. "Off" is a genuine draw-skip: lava off
// skips stepPhysics + uploads zero blobs; waveform off skips the ribbon SDF (uWaveformEnabled) and
// its CPU collision boundary. With both off, draw() short-circuits to a clear — no SDF eval at all.
let lavaEnabled = true;
let waveformEnabled = true;
/** Effective ribbon-width fraction for the current width knob (Phase 10 §3.7): the knob's [0,1]
* travel maps onto the useful 10%95% band (full-width 100% read too wide; sub-10% vanished).
@@ -1365,6 +1393,9 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
* boundary matches the rendered waveform exactly. Reads the retained datum.samples.
*/
function sampleLoudnessAt(timeSeconds: number): number {
// Phase 15: waveform off → no ribbon boundary. Reporting zero loudness collapses the collision
// half-width to 0, so wax never bounces off an invisible wall (matches the skipped ribbon draw).
if (!waveformEnabled) return 0;
const d = datum;
if (!d || timeSeconds < 0 || timeSeconds >= d.durationSeconds) return 0;
const n = d.sampleCount;
@@ -1731,6 +1762,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Phase 15 — both subsystems off: there is nothing to draw. Short-circuit past the physics
// step, the blob upload, and the full-screen SDF evaluation entirely — a genuine no-render-cost
// empty field (§10.1), not a shader that runs and outputs transparent. The cleared (transparent)
// buffer above is the result. The gradient/playhead clocks are not advanced while fully off;
// they resume from their held value when a subsystem is turned back on (no visible snap, since
// an off field shows nothing to snap).
if (!lavaEnabled && !waveformEnabled) return;
gl.useProgram(program);
gl.bindVertexArray(vao);
@@ -1756,6 +1795,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
// separate dirty-tracking needed for scalars/vec3s).
gl.uniform1f(u.visibleSeconds, visibleSeconds);
gl.uniform1f(u.waveformWidth, effectiveWaveformWidth());
gl.uniform1f(u.waveformEnabled, waveformEnabled ? 1 : 0);
gl.uniform1f(u.cohesion, fluidViscosity);
gl.uniform1f(u.gradientPhase, gradientPhase);
gl.uniform3fv(u.colorNavy, theme.navy);
@@ -1769,8 +1809,15 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
const nowMs = performance.now();
const physicsDt = Math.max(0, (nowMs - lastPhysicsMs) / 1000);
lastPhysicsMs = nowMs;
stepPhysics(physicsDt);
const liveCount = packBlobs();
// Phase 15 — lava off: skip the CPU physics step AND upload zero blobs. The shader's blob loop
// (`for … if (i >= uBlobCount) break;`) then unions nothing, so no wax is drawn and no physics
// runs — a genuine subsystem skip (§10.1), not a hidden-but-simulated field. The wax keeps its
// last positions for free (we just stop integrating); turning lava back on resumes from there.
let liveCount = 0;
if (lavaEnabled) {
stepPhysics(physicsDt);
liveCount = packBlobs();
}
gl.uniform4fv(u.blobs, blobUpload);
gl.uniform1i(u.blobCount, liveCount);
@@ -2156,6 +2203,22 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
if (rafId === null) redrawOnce();
},
// Phase 15 — subsystem enables. "Off" is a genuine draw-skip (§10.1): lava off stops the physics
// step + uploads zero blobs (handled in draw()); waveform off skips the ribbon SDF + collision
// boundary. redrawOnce guards the fully-stopped (tab-hidden) case so the toggle lands a still
// frame when the loop resumes — including the both-off → cleared empty field.
setLavaEnabled(enabled: boolean): void {
lavaEnabled = enabled;
debugLog(`setLavaEnabled → ${enabled}.`);
if (rafId === null) redrawOnce();
},
setWaveformEnabled(enabled: boolean): void {
waveformEnabled = enabled;
debugLog(`setWaveformEnabled → ${enabled}.`);
if (rafId === null) redrawOnce();
},
refreshTheme(): void {
theme = readTheme();
if (rafId === null) redrawOnce();
Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

@@ -375,6 +375,174 @@ h2, h3, h4, h5, h6,
word-break: break-all;
}
/* =============================================================================
WAVEFORM VISUALIZER CONTROL PANEL (Phase 12 §3d-revised / §3g → Phase 15 re-layout)
The control deck hosted inside WaveformVisualizerControlPopover, now a screen-centered
tinted MudOverlay (Phase 15 §4). MudOverlay — like the former MudPopover — PORTALS its
content out of the component's DOM subtree, so Blazor CSS isolation never reaches the
rendered panel: its chrome, the three-row/section LAYOUT, the section labels, the slider,
and the toggles all live here in the global sheet, not in the scoped
WaveformVisualizerControls.razor.css. (The scoped file keeps only the legacy inline-bar
fallback Mix's old TopRowCenter mount used, which is not portaled.)
The waveform-visualizer-control-panel class is applied ONLY when the component's
PanelChrome="true" parameter is set — which the popover host does and Mix's inline mount
does NOT — so the chrome never leaks onto an inline bar.
CHROME (Phase 15 §5 — NowPlayingCard treatment): SQUARE corners, lighter-navy ground
(navy-mid), a thin LIGHT border (--deepdrft-border-light, the NowPlayingCard 0.12-alpha
light-on-dark idiom as a token). All token-sourced; no hardcoded hex.
COLOUR PRINCIPLE (§5 — green = interactive, light = non-interactive): the RadialKnob reads
--mud-palette-* for its arc/pointer/center/label; we pin --mud-palette-primary to the green
accent (interactive arcs/pointers) and --mud-palette-text-primary to light. Caption icons and
section labels are LIGHT (static). The slider track/thumb and the lamp toggles are green.
============================================================================= */
.waveform-visualizer-control-panel.mix-visualizer-controls-bar {
/* Greyed panel ground — desaturated charcoal so the blue slider reads against it (defect #1).
Token is tunable in deepdrft-tokens.css without touching this rule. */
background: var(--deepdrft-panel-ground);
/* Square corners + thin light border — NowPlayingCard chrome (§5). */
border: 1px solid var(--deepdrft-border-light);
border-radius: 0;
/* Optional backdrop blur — cheap on a small modal panel, nice over the visualizer (§5). */
backdrop-filter: blur(8px);
padding: 1rem 1.25rem;
/* Three-row sectioned deck: stack the rows top-to-bottom; conditional rows reserve no permanent
height (§3 reflow discipline). This OVERRIDES the inline-bar min-height + flex-wrap (which only
matter for Mix's non-portaled legacy mount). */
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
max-width: 420px;
/* Pin the MudBlazor palette vars the portaled RadialKnob + slider consume. */
--mud-palette-primary: var(--deepdrft-green-accent); /* knob arc/pointer + slider track/thumb (interactive) */
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */
--mud-palette-surface-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler */
--mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light */
}
/* ── Row layout (§3). Each row is a horizontal band. Row 1 (MODE) and row 3 (WAVE) use
space-between so the right-pinned control (color / width) hugs the far edge. Row 2 (LAVA) uses
flex-start so its label + four knobs group left rather than spreading edge-to-edge.
align-items: center so the section label and knobs vertically center with each other. ── */
.waveform-visualizer-control-panel .wvc-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.85rem 1rem;
}
/* Row 1 (MODE): two direct flex children — the left toggle group and the color knob tooltip wrapper.
space-between pins the color knob to the far right and keeps it there when collisions hides. */
.waveform-visualizer-control-panel .wvc-row-mode {
justify-content: space-between;
}
/* Row 2 (LAVA): label + four knobs group left — no right-pinned control. */
.waveform-visualizer-control-panel .wvc-row-section {
justify-content: flex-start;
}
/* Row 3 (WAVE): label + scroll-slider + width-knob tooltip wrappers are direct flex children.
space-between pins the width knob to the far right while the label + slider sit left. */
.waveform-visualizer-control-panel .wvc-row-wave {
justify-content: space-between;
}
/* The left group of row 1 (toggles + conditional collisions) flows left; the color knob is the
space-between right sibling, so it stays put when collisions hides (§3). */
.waveform-visualizer-control-panel .wvc-row-left {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.85rem 1rem;
}
/* ── Section label "LAVA:" / "WAVE:" (§3, §5). NowPlayingCard .np-label TYPOGRAPHY (mono, uppercase,
tracked), recoloured LIGHT — labels are static, so light by the colour principle (§5, §10.3). ── */
.waveform-visualizer-control-panel .wvc-section-label {
font-family: var(--deepdrft-font-mono);
font-size: 0.6rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--deepdrft-white);
align-self: center;
flex: 0 0 auto;
opacity: 0.85;
}
/* ── The toggles (§3 row 1). Two state classes control the active-state chip treatment:
ON (.wvc-toggle-on): green-accent filled chip — unmistakably active at a glance.
OFF (.wvc-toggle-off): fully transparent background, glyph at low opacity — clearly inactive.
The MudIconButton glyph is already driven green (Color.Primary → pinned green accent, interactive §5).
The chip background reinforces state without recolouring the glyph further. ── */
.waveform-visualizer-control-panel .wvc-toggle {
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: background 0.15s ease;
}
.waveform-visualizer-control-panel .wvc-toggle-on {
background: color-mix(in srgb, var(--deepdrft-green-accent) 28%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--deepdrft-green-accent) 55%, transparent);
}
.waveform-visualizer-control-panel .wvc-toggle-off .mud-icon-button {
opacity: 0.38;
}
/* Caption icons render LIGHT (§5/§9: static/decorative = light). !important beats the scoped
.mix-visualizer-control ::deep .mix-visualizer-control-icon rule (which sets green for the legacy
inline mount) when the icon also carries mix-visualizer-control-icon. Lamp toggles are MudIconButton
not MudIcon so they are unaffected — they stay green (interactive, Color.Primary). (defect #3) */
.waveform-visualizer-control-panel .waveform-visualizer-control-icon {
opacity: 0.85;
translate: 0 -1rem;
}
/* ── The modal overlay (Phase 15 §4). MudOverlay is already a full-viewport flex scrim that centers its
content (.mud-overlay { display:flex; align-items:center; justify-content:center }), which gives the
screen-centered panel on every host for free — we do NOT fight that positioning. We:
(a) Raise the overlay z-index above the header (100) and the player-dock footer (1200/1300) so the
scrim tints the ENTIRE viewport uniformly — header and footer included (defect #7). The panel
content needs z-index: auto (inherits from stacking context) so it sits above the scrim naturally;
the RadialKnob capture div at 9999 remains above everything.
(b) Set the mild tint from the SINGLE --deepdrft-modal-scrim-alpha token (§10.5, defect #6).
(c) Remove overflow-y:auto on the content wrapper — it was the source of the drag scrollbar (defect #2).
The panel's max-width/flex-column already contain its size; the outer overlay clips at 100vh.
(d) Suppress body scroll while the overlay is present so no page-scroll occurs during a drag (defect #2).
The overlay portals to the body, so these are plain global rules (no scope attribute). The doubled
.mud-overlay-scrim.mud-overlay-dark selector (0,2,0) outranks MudBlazor's own .mud-overlay-dark (0,1,0),
so the tint wins regardless of stylesheet load order. ── */
/* Raise the overlay itself above the sticky header (z-index:100) and the fixed player dock (z-index:1200).
Use 1400 so it sits above the minimized-dock FAB (1300) too. The panel content inherits this context
and stacks above the scrim; the RadialKnob capture div (z-index:9999) stays highest. */
.waveform-visualizer-control-overlay {
z-index: 1400 !important;
}
.waveform-visualizer-control-overlay .mud-overlay-scrim.mud-overlay-dark {
background-color: rgba(var(--deepdrft-scrim-rgb), var(--deepdrft-modal-scrim-alpha));
}
/* No overflow-y:auto — removing it eliminates the spurious scrollbar that appeared while dragging a
knob (defect #2). The panel's flex-column layout is self-contained and never overflows the overlay. */
.waveform-visualizer-control-overlay .mud-overlay-content {
max-height: 90vh;
overflow: visible;
}
/* Lock body scroll while the controls overlay is open so the page cannot be scrolled during a
knob drag (defect #2). :has() degrades gracefully in older browsers (no lock, no crash). */
body:has(.waveform-visualizer-control-overlay) {
overflow: hidden;
}
@media (max-width: 419.98px) {
.deepdrft-track-detail-meta {
flex-direction: column;
@@ -478,7 +646,6 @@ h2, h3, h4, h5, h6,
width: 260px;
flex: 0 0 auto;
overflow: hidden;
border: 3px solid var(--mud-palette-secondary);
box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent);
}
@@ -535,3 +702,51 @@ h2, h3, h4, h5, h6,
margin: 0 auto;
}
}
/* =============================================================================
RELEASE DESCRIPTION BLURB
Shared block rendered just below the hero/header on every release detail page
(Session, Mix, Cut). Theme-driven colours keep it legible in both palettes.
Borrows the eyebrow-label + divider-rule motif from the home page.
============================================================================= */
.deepdrft-release-description {
margin: 2rem 0 2.5rem;
}
/* Header row: eyebrow label left + thin rule filling the rest */
.deepdrft-release-description-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
/* Eyebrow label — mirrors .section-label / .split-eyebrow from Home */
.deepdrft-release-description-label {
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
text-transform: uppercase;
white-space: nowrap;
flex-shrink: 0;
}
/* Thin rule to the right of the label — mirrors .divider-line from Home */
.deepdrft-release-description-rule {
flex: 1;
height: 1px;
background: color-mix(in srgb, var(--deepdrft-muted) 35%, transparent);
}
/* Body paragraph — body font, display-serif feel via generous line-height */
.deepdrft-release-description-text {
margin: 0;
font-family: var(--deepdrft-font-display);
font-size: 1.1rem;
font-weight: 300;
line-height: 1.75;
color: var(--mud-palette-text-primary);
opacity: 0.85;
}
+34
View File
@@ -23,6 +23,40 @@ public static class DDIcons
</svg>
""";
/// <summary>
/// Audio waveform — outline variant, shown when the waveform subsystem is OFF.
/// Six vertical bars of varying height centred on a 24×24 viewBox, evoking a
/// classic sound-wave / spectrum display. Uses currentColor so it themes freely.
/// Inner markup only — no outer &lt;svg&gt; wrapper; MudBlazor supplies viewBox="0 0 24 24".
/// </summary>
public const string Waveform = """
<g>
<rect x="1" y="9" width="2" height="6" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/>
<rect x="5" y="5" width="2" height="14" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/>
<rect x="9" y="2" width="2" height="20" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/>
<rect x="13" y="6" width="2" height="12" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/>
<rect x="17" y="4" width="2" height="16" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/>
<rect x="21" y="9" width="2" height="6" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/>
</g>
""";
/// <summary>
/// Audio waveform — filled variant, shown when the waveform subsystem is ON.
/// Same bar layout as <see cref="Waveform"/> but bars are solid currentColor,
/// giving a strong visual contrast for the active (ON) state.
/// Inner markup only — no outer &lt;svg&gt; wrapper; MudBlazor supplies viewBox="0 0 24 24".
/// </summary>
public const string WaveformFilled = """
<g>
<rect x="1" y="9" width="2" height="6" rx="1" fill="currentColor"/>
<rect x="5" y="5" width="2" height="14" rx="1" fill="currentColor"/>
<rect x="9" y="2" width="2" height="20" rx="1" fill="currentColor"/>
<rect x="13" y="6" width="2" height="12" rx="1" fill="currentColor"/>
<rect x="17" y="4" width="2" height="16" rx="1" fill="currentColor"/>
<rect x="21" y="9" width="2" height="6" rx="1" fill="currentColor"/>
</g>
""";
/// <summary>
/// Lava lamp - the Mix visualizer settings glyph. Sourced from lava-lamp-svgrepo-com.svg
/// (SVG Repo, viewBox="0 0 50 50"). Wrapped in a scale(0.48) transform to fit MudBlazor's
@@ -1,15 +1,29 @@
@using System.Globalization
@inject IJSRuntime JS
@implements IAsyncDisposable
<!-- Global mouse capture container when dragging -->
<!-- Drag-shield: full-viewport overlay while dragging.
position:fixed; inset:0; z-index:9999 so it sits above all overlays (the controls modal is z-index:1400).
Provides the cursor hint and blocks stray clicks on underlying content during a drag.
Event delivery is NOT via this div — setPointerCapture on the knob element routes
pointermove/pointerup/pointercancel directly to the knob regardless of where the cursor is,
even outside the browser window. This overlay is a belt-and-suspenders UX guard only. -->
@if (_isDragging)
{
<div style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 9999; cursor: ns-resize;"
@onmousemove="@OnGlobalMouseMove" @onmouseup="@OnGlobalMouseUp">
<div style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 9999; cursor: ns-resize;">
</div>
}
<!-- Knob element owns pointer capture during drag.
@onpointerdown initiates drag and calls setPointerCapture via knob.js.
@onpointermove / @onpointerup / @onpointercancel are delivered here (not to the overlay)
for the captured pointer, even when the cursor has left the browser window. -->
<div class="radial-knob" style="width: @(Size)px; height: @(Size)px; display: inline-block; position: relative;"
@onmousedown="@OnMouseDown">
@ref="_knobRef"
@onpointerdown="@OnPointerDown"
@onpointermove="@OnPointerMove"
@onpointerup="@OnPointerUp"
@onpointercancel="@OnPointerCancel">
<!-- SVG Knob -->
<svg width="@Size" height="@Size" viewBox="0 0 100 100">
@@ -78,6 +92,9 @@
[Parameter] public MudBlazor.Color Color { get; set; } = MudBlazor.Color.Primary;
[Parameter] public bool HoldValue { get; set; } = false;
private ElementReference _knobRef;
private IJSObjectReference? _knobModule;
private bool _isDragging = false;
private double _lastMouseY = 0;
private double _dragValue = 0;
@@ -150,25 +167,36 @@
return y.ToString("F1", CultureInfo.InvariantCulture);
}
private async Task OnMouseDown(MouseEventArgs e)
private async Task OnPointerDown(PointerEventArgs e)
{
_isDragging = true;
_lastMouseY = e.ClientY;
_dragValue = Value; // Initialize drag value with current value
// Add global mouse event handlers using Blazor's event handling
// Load the JS module on first drag (lazy — avoids import cost until the user interacts).
_knobModule ??= await JS.InvokeAsync<IJSObjectReference>(
"import", "./_content/DeepDrftShared.Client/js/knob/knob.js");
// Capture the pointer on the knob element so pointermove/pointerup are delivered
// even when the cursor leaves the browser window mid-drag.
await _knobModule.InvokeVoidAsync("capturePointer", _knobRef, e.PointerId);
// The full-viewport capture div (rendered when _isDragging) is a belt-and-suspenders
// guard that blocks stray clicks on underlying content while dragging.
StateHasChanged();
}
private async Task OnGlobalMouseMove(MouseEventArgs e)
// Delivered to the knob element because setPointerCapture routes the captured pointer here.
private async Task OnPointerMove(PointerEventArgs e)
{
if (_isDragging)
{
await UpdateValueFromMouse(e);
await UpdateValueFromPointer(e);
}
}
private async Task OnGlobalMouseUp(MouseEventArgs e)
// pointerup implicitly releases pointer capture — no explicit releasePointerCapture needed.
private async Task OnPointerUp(PointerEventArgs e)
{
if (_isDragging && HoldValue)
{
@@ -183,9 +211,35 @@
StateHasChanged();
}
private async Task UpdateValueFromMouse(MouseEventArgs e)
// Pointer capture cancelled by OS (e.g. Alt+Tab, system gesture) — end drag cleanly.
// Delivered to the knob element (the capturing element). Release capture explicitly since
// the implicit release on pointerup does not apply to pointercancel.
private async Task OnPointerCancel(PointerEventArgs e)
{
// Calculate vertical delta from last mouse position
if (_knobModule != null)
{
try { await _knobModule.InvokeVoidAsync("releasePointer", _knobRef, e.PointerId); }
catch (JSException) { /* element may already be gone */ }
}
_isDragging = false;
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
if (_knobModule != null)
{
try { await _knobModule.DisposeAsync(); }
catch (JSDisconnectedException) { /* circuit torn down */ }
}
GC.SuppressFinalize(this);
}
private async Task UpdateValueFromPointer(PointerEventArgs e)
{
// Calculate vertical delta from last pointer position
double deltaY = _lastMouseY - e.ClientY; // Inverted: up = positive, down = negative
_lastMouseY = e.ClientY;
@@ -0,0 +1,20 @@
/**
* knob - pointer capture helpers for RadialKnob.
*
* setPointerCapture / releasePointerCapture are not exposed via Blazor's
* ElementReference, so the component delegates here via JS interop.
* Both functions are no-ops when the element reference is stale (e.g. the
* component was disposed between the JS call and the microtask).
*/
/** Capture the pointer on the given element so pointermove/pointerup are
* delivered even when the cursor leaves the browser window. */
export function capturePointer(el: Element, pointerId: number): void {
(el as HTMLElement).setPointerCapture(pointerId);
}
/** Release a previously captured pointer. Called on pointercancel.
* pointerup releases capture implicitly, but we call this on cancel too. */
export function releasePointer(el: Element, pointerId: number): void {
(el as HTMLElement).releasePointerCapture(pointerId);
}
@@ -17,6 +17,19 @@
--deepdrft-white: #FAFAF8;
--deepdrft-border: rgba(13, 27, 42, 0.10);
--deepdrft-border-green: rgba(26, 60, 52, 0.20);
/* Thin light-on-dark border, NowPlayingCard spirit (Phase 15 §5). One token instead of scattering
the rgba(250,250,248,0.12) literal NowPlayingCard uses inline. */
--deepdrft-border-light: rgba(250, 250, 248, 0.12);
/* Modal scrim base colour (RGB triple for use in rgba()) — panel dark-ground (#0D1B2A).
Deliberately NOT --deepdrft-navy (#112338); tokenised here so the scrim rule in
deepdrft-styles.css has no hardcoded literals. Change here once. */
--deepdrft-scrim-rgb: 13, 27, 42;
/* Modal scrim opacity — the SINGLE point of truth for the visualizer-controls overlay tint
(Phase 15 §4/§10.5). Mild so the panel reads as modal without a blackout. Change here once. */
--deepdrft-modal-scrim-alpha: 0.15;
/* Panel ground — muted, desaturated charcoal beneath the controls panel.
Tunable: increase blue channel (e.g. #1e2235) to recover warmth, lower (e.g. #191b20) to go darker. */
--deepdrft-panel-ground: #1a1c22;
/* Wireframe font stack */
--deepdrft-font-display: "Cormorant Garamond", Georgia, serif;
+3
View File
@@ -30,6 +30,9 @@
<ItemGroup>
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
<!-- Referenced for ProgressStreamContent (the upload progress/heartbeat HttpContent). It is plain
HttpContent with no browser/host dependency, unit-testable by serializing to a MemoryStream. -->
<ProjectReference Include="..\DeepDrftManager\DeepDrftManager.csproj" />
<!-- Referenced for the client-side queue orchestrator (QueueService / IQueueService).
The queue is pure domain logic, unit-testable against a fake IStreamingPlayerService
with no browser/JS. -->
@@ -1,93 +0,0 @@
using DeepDrftContent.Processors;
namespace DeepDrftTests;
/// <summary>
/// Behavioral tests for the duration-derived Mix bucket-count derivation. The contract: capture at a
/// constant time resolution (≈333 samples/sec) so a 333 ms max-zoom window holds enough samples on any
/// mix length, clamped to a sane floor (short/degenerate mixes) and an upper cap (extreme outliers).
/// </summary>
[TestFixture]
public class MixWaveformResolutionTests
{
[Test]
public void BucketCountForDuration_TypicalMix_CapturesAtTargetDensity()
{
// 3 minutes × 333/s = 59,940 — a typical short mix, comfortably inside [floor, cap].
var buckets = MixWaveformResolution.BucketCountForDuration(180.0);
Assert.That(buckets, Is.EqualTo((int)Math.Ceiling(180.0 * MixWaveformResolution.SamplesPerSecond)));
Assert.That(buckets, Is.EqualTo(59_940));
}
[Test]
public void BucketCountForDuration_SixtyMinuteMix_ProducesAboutOnePointTwoMillion()
{
// 60 min × 333/s = 1,198,800 ≈ 1.2M samples (≈1.2 MB datum), still under the cap.
var buckets = MixWaveformResolution.BucketCountForDuration(3600.0);
Assert.That(buckets, Is.EqualTo(1_198_800));
Assert.That(buckets, Is.LessThan(MixWaveformResolution.MaxBucketCount));
}
[Test]
public void BucketCountForDuration_OverHundredMinutes_ClampsToCap()
{
// 120 min × 333/s = 2,397,600 > cap → clamps to the cap.
var buckets = MixWaveformResolution.BucketCountForDuration(7200.0);
Assert.That(buckets, Is.EqualTo(MixWaveformResolution.MaxBucketCount));
}
[Test]
public void BucketCountForDuration_NearZeroDuration_HitsFloor()
{
// 0.1 s × 333/s = 34 buckets, far below the floor → clamps up to the floor.
var buckets = MixWaveformResolution.BucketCountForDuration(0.1);
Assert.That(buckets, Is.EqualTo(MixWaveformResolution.MinBucketCount));
}
[Test]
public void BucketCountForDuration_ZeroDuration_HitsFloor()
{
Assert.That(MixWaveformResolution.BucketCountForDuration(0.0), Is.EqualTo(MixWaveformResolution.MinBucketCount));
}
[Test]
public void BucketCountForDuration_NegativeOrNaN_HitsFloor()
{
Assert.Multiple(() =>
{
Assert.That(MixWaveformResolution.BucketCountForDuration(-5.0), Is.EqualTo(MixWaveformResolution.MinBucketCount));
Assert.That(MixWaveformResolution.BucketCountForDuration(double.NaN), Is.EqualTo(MixWaveformResolution.MinBucketCount));
});
}
[Test]
public void BucketCountForDuration_DurationAtFloorBoundary_ReturnsFloorThenGrows()
{
// floor / 333 = 6.15 s is the duration where the derived count meets the floor exactly.
const double floorBoundarySeconds = (double)MixWaveformResolution.MinBucketCount / MixWaveformResolution.SamplesPerSecond;
// Just below the boundary clamps to the floor; just above derives above the floor.
Assert.Multiple(() =>
{
Assert.That(MixWaveformResolution.BucketCountForDuration(floorBoundarySeconds - 0.1), Is.EqualTo(MixWaveformResolution.MinBucketCount));
Assert.That(MixWaveformResolution.BucketCountForDuration(floorBoundarySeconds + 1.0), Is.GreaterThan(MixWaveformResolution.MinBucketCount));
});
}
[Test]
public void BucketCountForDuration_DurationAtCapBoundary_ReturnsCap()
{
// cap / 333 = 6006.006 s is the duration where the derived count meets the cap exactly.
const double capBoundarySeconds = (double)MixWaveformResolution.MaxBucketCount / MixWaveformResolution.SamplesPerSecond;
Assert.Multiple(() =>
{
Assert.That(MixWaveformResolution.BucketCountForDuration(capBoundarySeconds + 1.0), Is.EqualTo(MixWaveformResolution.MaxBucketCount));
Assert.That(MixWaveformResolution.BucketCountForDuration(capBoundarySeconds - 10.0), Is.LessThan(MixWaveformResolution.MaxBucketCount));
});
}
}
+241
View File
@@ -0,0 +1,241 @@
using System.Net.Http;
using DeepDrftManager.Services;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for <see cref="ProgressStreamContent"/> — the single mechanism feeding both the CMS
/// upload progress meter and the idle/heartbeat timeout. Two concerns are anchored here:
/// (1) progress reporting is monotonic and sums to the total content length, and
/// (2) the idle deadline pattern the content drives (reset CancelAfter on each tick) cancels a
/// stalled write yet never fires while writes are progressing.
/// </summary>
[TestFixture]
public class ProgressStreamContentTests
{
// --- Progress reporting ---
[Test]
public async Task ReportsMonotonicallyIncreasingBytes_SummingToContentLength()
{
var payload = new byte[256 * 1024 + 7]; // non-chunk-aligned so the final partial read is exercised
Random.Shared.NextBytes(payload);
var reports = new List<long>();
var content = new ProgressStreamContent(
new MemoryStream(payload), payload.Length, written => reports.Add(written));
using var sink = new MemoryStream();
await content.CopyToAsync(sink);
Assert.That(reports, Is.Not.Empty, "Expected at least one progress tick.");
Assert.That(reports, Is.Ordered.Ascending, "Progress must be monotonically increasing.");
Assert.That(reports[^1], Is.EqualTo(payload.Length), "Final tick must equal total content length.");
Assert.That(sink.ToArray(), Is.EqualTo(payload), "Serialized bytes must match the source payload.");
}
[Test]
public void TryComputeLength_ReturnsProvidedContentLength()
{
const long declared = 987_654;
var content = new ProgressStreamContent(new MemoryStream(), declared, _ => { });
Assert.That(content.Headers.ContentLength, Is.EqualTo(declared));
}
// --- Idle/heartbeat cancellation ---
// The content does not own a timer; it owns the progress signal. The upload service wires that
// signal to CancellationTokenSource.CancelAfter(idle). These tests exercise that exact contract by
// driving the content with a stream whose read cadence we control.
[Test]
public async Task IdleTimeout_DoesNotFire_WhileWritesAreProgressing()
{
var idle = TimeSpan.FromMilliseconds(200);
using var idleCts = new CancellationTokenSource();
idleCts.CancelAfter(idle);
// Five chunks, each arriving well within the idle window: progress keeps resetting the deadline.
var source = new PacedStream(chunkCount: 5, chunkSize: 4096, delayPerChunk: TimeSpan.FromMilliseconds(50));
var content = new ProgressStreamContent(source, source.TotalLength, _ => idleCts.CancelAfter(idle));
using var sink = new MemoryStream();
await content.CopyToAsync(sink);
Assert.That(idleCts.IsCancellationRequested, Is.False,
"A steadily progressing upload must not trip the idle heartbeat.");
Assert.That(sink.Length, Is.EqualTo(source.TotalLength));
}
[Test]
public void IdleTimeout_Fires_WhenAStallExceedsTheIdleWindow()
{
var idle = TimeSpan.FromMilliseconds(150);
using var idleCts = new CancellationTokenSource();
idleCts.CancelAfter(idle);
// One quick chunk, then a stall longer than the idle window before the next read returns.
var source = new PacedStream(
chunkCount: 2, chunkSize: 4096,
delayPerChunk: TimeSpan.FromMilliseconds(10),
stallBeforeChunkIndex: 1, stallDuration: TimeSpan.FromMilliseconds(500));
var content = new ProgressStreamContent(source, source.TotalLength, _ => idleCts.CancelAfter(idle));
using var sink = new MemoryStream();
Assert.That(
async () => await content.CopyToAsync(sink, idleCts.Token),
Throws.InstanceOf<OperationCanceledException>(),
"A stall exceeding the idle window must cancel the in-flight copy.");
Assert.That(idleCts.IsCancellationRequested, Is.True);
}
// --- Two-phase deadline switching (Finding 1 regression test) ---
// After the last body byte is reported the idle timer must be disarmed and the response-wait
// budget must be armed. This test simulates the exact scenario that triggered Finding 1:
// the body streams quickly, then a long server-side lag (standing in for AudioProcessor +
// vault write + SQL persist) follows. The idle window is short; the response budget is long.
// With the fix the operation must complete; without it idleCts would fire during the lag.
[Test]
public async Task PostBodyLag_DoesNotTriggerIdleTimeout_WhenResponseBudgetIsLarger()
{
var idle = TimeSpan.FromMilliseconds(150);
var responseBudget = TimeSpan.FromMilliseconds(600);
using var idleCts = new CancellationTokenSource();
idleCts.CancelAfter(idle);
using var responseCts = new CancellationTokenSource();
// responseCts starts disarmed — same as in CmsTrackService.
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(idleCts.Token, responseCts.Token);
const long contentLength = 4096;
var source = new PacedStream(chunkCount: 1, chunkSize: (int)contentLength, delayPerChunk: TimeSpan.FromMilliseconds(10));
var content = new ProgressStreamContent(source, contentLength, written =>
{
if (written < contentLength)
{
idleCts.CancelAfter(idle);
}
else
{
// Body complete — disarm idle, arm response budget (mirrors CmsTrackService).
idleCts.CancelAfter(Timeout.InfiniteTimeSpan);
responseCts.CancelAfter(responseBudget);
}
});
using var sink = new MemoryStream();
await content.CopyToAsync(sink, sendCts.Token);
// Body is done. Simulate a slow server (longer than idle window, shorter than response budget).
var serverLag = TimeSpan.FromMilliseconds(300); // > idle (150 ms), < response budget (600 ms)
await Task.Delay(serverLag, sendCts.Token);
Assert.That(sendCts.IsCancellationRequested, Is.False,
"A post-body server lag within the response budget must not cancel the send token.");
Assert.That(idleCts.IsCancellationRequested, Is.False,
"The idle CTS must be disarmed after body completes.");
Assert.That(responseCts.IsCancellationRequested, Is.False,
"The response CTS must not have fired — server lag was within the response budget.");
}
[Test]
public async Task PostBodyLag_CancelsViaResponseCts_WhenResponseBudgetExceeded()
{
var idle = TimeSpan.FromMilliseconds(200);
var responseBudget = TimeSpan.FromMilliseconds(150);
using var idleCts = new CancellationTokenSource();
idleCts.CancelAfter(idle);
using var responseCts = new CancellationTokenSource();
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(idleCts.Token, responseCts.Token);
const long contentLength = 4096;
var source = new PacedStream(chunkCount: 1, chunkSize: (int)contentLength, delayPerChunk: TimeSpan.FromMilliseconds(10));
var content = new ProgressStreamContent(source, contentLength, written =>
{
if (written < contentLength)
{
idleCts.CancelAfter(idle);
}
else
{
idleCts.CancelAfter(Timeout.InfiniteTimeSpan);
responseCts.CancelAfter(responseBudget);
}
});
using var sink = new MemoryStream();
await content.CopyToAsync(sink, sendCts.Token);
// Simulate a slow server that exceeds the response budget.
var serverLag = TimeSpan.FromMilliseconds(400); // > response budget (150 ms)
Assert.That(
async () => await Task.Delay(serverLag, sendCts.Token),
Throws.InstanceOf<OperationCanceledException>(),
"A post-body lag exceeding the response budget must cancel via sendCts.");
Assert.That(responseCts.IsCancellationRequested, Is.True,
"responseCts must be the source of the cancellation, not idleCts.");
Assert.That(idleCts.IsCancellationRequested, Is.False,
"idleCts must remain disarmed — the response budget fired, not the idle window.");
}
/// <summary>
/// A read-only stream that yields a fixed number of equal chunks, pausing between reads to emulate
/// network pacing. Optionally inserts a longer stall before a given chunk to emulate a stalled link.
/// </summary>
private sealed class PacedStream : Stream
{
private readonly int _chunkCount;
private readonly int _chunkSize;
private readonly TimeSpan _delayPerChunk;
private readonly int _stallBeforeChunkIndex;
private readonly TimeSpan _stallDuration;
private int _chunksRead;
public PacedStream(int chunkCount, int chunkSize, TimeSpan delayPerChunk,
int stallBeforeChunkIndex = -1, TimeSpan stallDuration = default)
{
_chunkCount = chunkCount;
_chunkSize = chunkSize;
_delayPerChunk = delayPerChunk;
_stallBeforeChunkIndex = stallBeforeChunkIndex;
_stallDuration = stallDuration;
}
public long TotalLength => (long)_chunkCount * _chunkSize;
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (_chunksRead >= _chunkCount) return 0;
if (_chunksRead == _stallBeforeChunkIndex)
{
await Task.Delay(_stallDuration, cancellationToken);
}
else
{
await Task.Delay(_delayPerChunk, cancellationToken);
}
var count = Math.Min(_chunkSize, buffer.Length);
buffer.Span[..count].Clear();
_chunksRead++;
return count;
}
public override int Read(byte[] buffer, int offset, int count)
=> throw new NotSupportedException("Async-only paced stream.");
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => TotalLength;
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
@@ -0,0 +1,184 @@
using System.Text;
using DeepDrftContent.Constants;
using DeepDrftContent.Processors;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Integration tests for the per-track high-res waveform compute (phase-12 §5, Direction B). These
/// exercise the exact content-side path the upload, CMS generate action, and Mix trigger all funnel
/// through (<see cref="WaveformProfileService.ComputeAndStoreHighResAsync"/>) over a real
/// <see cref="FileDb"/> and a real <see cref="AudioProcessor"/> + <see cref="RmsLoudnessAlgorithm"/>.
/// The track's medium is irrelevant here — that is the point of the generalization: the content
/// service computes a datum from any track's audio, keyed by EntryKey, with no Mix coupling.
/// </summary>
[TestFixture]
public class WaveformProfileServiceTests
{
private string _testDir = string.Empty;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "WaveformProfileServiceTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
}
[TearDown]
public void TearDown()
{
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
private async Task<WaveformProfileService> CreateServiceAsync(FileDb fileDatabase)
{
await Task.CompletedTask;
return new WaveformProfileService(
fileDatabase,
new AudioProcessor(),
new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()),
NullLogger<WaveformProfileService>.Instance);
}
[Test]
public async Task ComputeAndStoreHighResAsync_NonMixTrack_StoresDatumInTrackWaveformsVault()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
Assert.That(fileDatabase, Is.Not.Null);
var service = await CreateServiceAsync(fileDatabase!);
// A 2-second mono 16-bit WAV — stands in for "any track" (Cut/Session/Mix alike). No release
// or medium is involved; the compute is keyed only by the supplied EntryKey.
const string entryKey = "cut-track-entry";
var wav = BuildMinimalPcmWav(durationSeconds: 2.0);
var stored = await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 2.0);
Assert.That(stored, Is.True, "High-res compute should succeed for a decodable PCM WAV");
var datum = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
Assert.That(datum, Is.Not.Null, "Datum must be retrievable from the track-waveforms vault by EntryKey");
// 2 s × 333/s = 666 buckets, below the floor → clamps to the floor (2048).
Assert.That(datum!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(2.0)));
Assert.That(datum.Length, Is.EqualTo(WaveformResolution.MinBucketCount));
}
[Test]
public async Task ComputeAndStoreHighResAsync_LongTrack_BucketCountIsDurationDerived()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
var service = await CreateServiceAsync(fileDatabase!);
// A 10-second WAV: 10 × 333 = 3330 buckets, above the 2048 floor — proves the count tracks
// duration rather than the fixed 512-bucket profile resolution.
const string entryKey = "long-track-entry";
var wav = BuildMinimalPcmWav(durationSeconds: 10.0);
var stored = await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0);
Assert.That(stored, Is.True);
var datum = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
Assert.That(datum, Is.Not.Null);
Assert.That(datum!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(10.0)));
Assert.That(datum.Length, Is.GreaterThan(new WaveformProfileOptions().BucketCount),
"The high-res datum must be denser than the fixed 512-bucket player-bar profile");
}
[Test]
public async Task HighResAndProfile_ForSameTrack_StoredInSeparateVaultsKeyedByEntryKey()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
var service = await CreateServiceAsync(fileDatabase!);
// The two datums a track carries (phase-12 §5): the 512-bucket player-bar profile and the
// duration-derived high-res visualizer datum. Both key off the same EntryKey but live in
// distinct vaults, so neither overwrites the other.
const string entryKey = "shared-key";
var wav = BuildMinimalPcmWav(durationSeconds: 10.0);
Assert.That(await service.ComputeAndStoreAsync(wav, entryKey), Is.True);
Assert.That(await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0), Is.True);
var profile = await service.GetProfileAsync(entryKey);
var highRes = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
Assert.Multiple(() =>
{
Assert.That(profile, Is.Not.Null);
Assert.That(highRes, Is.Not.Null);
Assert.That(profile!.Length, Is.EqualTo(new WaveformProfileOptions().BucketCount));
Assert.That(highRes!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(10.0)));
Assert.That(highRes.Length, Is.Not.EqualTo(profile.Length), "The two datums must differ in resolution");
});
}
[Test]
public async Task ComputeAndStoreHighResAsync_IsRerunnable_OverwritesPriorDatum()
{
var fileDatabase = await FileDb.FromAsync(_testDir);
var service = await CreateServiceAsync(fileDatabase!);
// The backfill / regenerate path must be re-runnable: a second compute for the same key
// overwrites cleanly rather than failing or duplicating.
const string entryKey = "rerun-key";
var wav = BuildMinimalPcmWav(durationSeconds: 10.0);
Assert.That(await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0), Is.True);
Assert.That(await service.ComputeAndStoreHighResAsync(wav, entryKey, durationSeconds: 10.0), Is.True,
"A re-run must succeed and overwrite the prior datum");
var datum = await service.GetProfileAsync(entryKey, VaultConstants.TrackWaveforms);
Assert.That(datum, Is.Not.Null);
Assert.That(datum!.Length, Is.EqualTo(WaveformResolution.BucketCountForDuration(10.0)));
}
// Builds a minimal standard-PCM mono 16-bit 44.1 kHz WAV with a full-scale square wave across the
// requested duration. Real PCM (not silence) so the loudness algorithm produces a non-degenerate
// envelope. Mirrors the chunk layout AudioProcessor expects (RIFF/WAVE/fmt /data).
private static byte[] BuildMinimalPcmWav(double durationSeconds)
{
const int sampleRate = 44100;
const ushort channels = 1;
const ushort bitsPerSample = 16;
const ushort blockAlign = channels * (bitsPerSample / 8);
const uint byteRate = sampleRate * blockAlign;
var frames = (int)(sampleRate * durationSeconds);
var data = new byte[frames * blockAlign];
for (var i = 0; i < frames; i++)
{
// Alternating full-scale square wave so RMS reads as loud, not silent.
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
data[i * 2] = (byte)(sample & 0xFF);
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
}
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
w.Write(Encoding.ASCII.GetBytes("RIFF"));
w.Write((uint)(36 + data.Length));
w.Write(Encoding.ASCII.GetBytes("WAVE"));
w.Write(Encoding.ASCII.GetBytes("fmt "));
w.Write(16u);
w.Write((ushort)1); // PCM
w.Write(channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
}
+94
View File
@@ -0,0 +1,94 @@
using DeepDrftContent.Processors;
namespace DeepDrftTests;
/// <summary>
/// Behavioral tests for the duration-derived high-res bucket-count derivation. The contract: capture at
/// a constant time resolution (≈333 samples/sec) so a 333 ms max-zoom window holds enough samples on any
/// track length, clamped to a sane floor (short/degenerate tracks) and an upper cap (extreme outliers).
/// Applies to every track (Mix, Session, Cut) under the per-track model — phase-12 §5.
/// </summary>
[TestFixture]
public class WaveformResolutionTests
{
[Test]
public void BucketCountForDuration_TypicalTrack_CapturesAtTargetDensity()
{
// 3 minutes × 333/s = 59,940 — a typical short track, comfortably inside [floor, cap].
var buckets = WaveformResolution.BucketCountForDuration(180.0);
Assert.That(buckets, Is.EqualTo((int)Math.Ceiling(180.0 * WaveformResolution.SamplesPerSecond)));
Assert.That(buckets, Is.EqualTo(59_940));
}
[Test]
public void BucketCountForDuration_SixtyMinuteTrack_ProducesAboutOnePointTwoMillion()
{
// 60 min × 333/s = 1,198,800 ≈ 1.2M samples (≈1.2 MB datum), still under the cap.
var buckets = WaveformResolution.BucketCountForDuration(3600.0);
Assert.That(buckets, Is.EqualTo(1_198_800));
Assert.That(buckets, Is.LessThan(WaveformResolution.MaxBucketCount));
}
[Test]
public void BucketCountForDuration_OverHundredMinutes_ClampsToCap()
{
// 120 min × 333/s = 2,397,600 > cap → clamps to the cap.
var buckets = WaveformResolution.BucketCountForDuration(7200.0);
Assert.That(buckets, Is.EqualTo(WaveformResolution.MaxBucketCount));
}
[Test]
public void BucketCountForDuration_NearZeroDuration_HitsFloor()
{
// 0.1 s × 333/s = 34 buckets, far below the floor → clamps up to the floor.
var buckets = WaveformResolution.BucketCountForDuration(0.1);
Assert.That(buckets, Is.EqualTo(WaveformResolution.MinBucketCount));
}
[Test]
public void BucketCountForDuration_ZeroDuration_HitsFloor()
{
Assert.That(WaveformResolution.BucketCountForDuration(0.0), Is.EqualTo(WaveformResolution.MinBucketCount));
}
[Test]
public void BucketCountForDuration_NegativeOrNaN_HitsFloor()
{
Assert.Multiple(() =>
{
Assert.That(WaveformResolution.BucketCountForDuration(-5.0), Is.EqualTo(WaveformResolution.MinBucketCount));
Assert.That(WaveformResolution.BucketCountForDuration(double.NaN), Is.EqualTo(WaveformResolution.MinBucketCount));
});
}
[Test]
public void BucketCountForDuration_DurationAtFloorBoundary_ReturnsFloorThenGrows()
{
// floor / 333 = 6.15 s is the duration where the derived count meets the floor exactly.
const double floorBoundarySeconds = (double)WaveformResolution.MinBucketCount / WaveformResolution.SamplesPerSecond;
// Just below the boundary clamps to the floor; just above derives above the floor.
Assert.Multiple(() =>
{
Assert.That(WaveformResolution.BucketCountForDuration(floorBoundarySeconds - 0.1), Is.EqualTo(WaveformResolution.MinBucketCount));
Assert.That(WaveformResolution.BucketCountForDuration(floorBoundarySeconds + 1.0), Is.GreaterThan(WaveformResolution.MinBucketCount));
});
}
[Test]
public void BucketCountForDuration_DurationAtCapBoundary_ReturnsCap()
{
// cap / 333 = 6006.006 s is the duration where the derived count meets the cap exactly.
const double capBoundarySeconds = (double)WaveformResolution.MaxBucketCount / WaveformResolution.SamplesPerSecond;
Assert.Multiple(() =>
{
Assert.That(WaveformResolution.BucketCountForDuration(capBoundarySeconds + 1.0), Is.EqualTo(WaveformResolution.MaxBucketCount));
Assert.That(WaveformResolution.BucketCountForDuration(capBoundarySeconds - 10.0), Is.LessThan(WaveformResolution.MaxBucketCount));
});
}
}
-148
View File
@@ -239,154 +239,6 @@ Sequenced as **eight waves**; the critical path is `11.A → 11.B → 11.C → 1
---
## Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire
Take the landed Mix waveform visualizer (the WebGL2 lava renderer + its eight-knob controls, Phase 10
reframe) and **make it the one track-cardinal visualizer** — serving Mix detail, all Release Detail
pages, *and* the home-page NowPlaying card — rendering the waveform of **whatever track is currently
playing/selected**, instead of a Mix-only treatment forked three ways. **Two deliverables, one engine in
three hosting modes, DRY/SOLID the explicit ask.** Full design, the extraction analysis, the per-track
model, Direction B compute, wave decomposition, and open questions:
`product-notes/phase-12-waveform-visualizer-generalization.md`.
**Keystone model correction (Daniel, 2026-06-17): the datum is PER-TRACK, not per-release.** *"Each track
in the release must get the metadata… the release is just the host."* Every track carries its own high-res
waveform datum; the visualizer renders the *currently playing/selected* track's datum, and the release is
merely the host surface. This *simplifies* the design — it aligns with the bridge already keying on
`TrackId`, and it **dissolves** the old "what is a multi-track Cut's waveform?" question (no release-level
datum to choose). Threaded through the datum source, the endpoint shape, the bridge, and acceptance.
**Central finding (verified read, 2026-06-17): the engine is already track-cardinal below the surface.**
`MixWaveformVisualizer`'s bridge keys on `ReleaseEntryKey` + `TrackId` (not Mix); the renderer is a pure
function of a loudness datum + duration; the controls/state are renderer-agnostic. The *only* genuinely
Mix-coupled surface is (1) the datum **fetch** (per-release, `GET api/release/{entryKey}/mix/waveform` 404s
unless `Medium == Mix`) and (2) the high-res datum **source** (the `mix-waveforms` vault, Mix-track-only).
Everything else is just *named* `Mix*`. So "generalize from Mix to all tracks" is a **rename + a per-track
high-res compute generalization, not a rebuild** — the renderer, bridge, controls, read-only contract all
carry forward from the Phase 10 reframe unchanged.
**Datum decision (Daniel, 2026-06-17): Direction B — high-res for ALL media.** Today every uploaded track
gets a **512-bucket** profile (`UnifiedTrackService.UploadAsync``waveform-profiles` vault, consumed by
the player-bar `WaveformSeeker`); only **Mix tracks** *additionally* get the duration-derived **high-res**
datum (~333 samples/sec, `mix-waveforms` vault, CMS-triggered). Direction B **generalizes the high-res
compute to every track**: the content compute path goes medium-neutral, the upload path computes a per-track
high-res datum for every new track, the CMS generate action generalizes off Mix-only, and a **backfill**
populates existing tracks. The cheaper road (serve the existing 512-bucket profile to non-Mix, zero new
compute — old "Direction A") is **declined** in favor of uniform high-res. So 12.B is no longer "a new
endpoint" — it is a content + upload + CMS + backfill + fetch slice (split into 12.B1 / 12.B2 below).
**Three hosting modes of the one engine (Daniel corrected "backdrop").** *"backdrop?? MIXES doesn't really
have a backdrop?"* — right: on Mix the visualizer is the full-bleed **centerpiece that IS the page**, not
something behind content. The one engine is hosted three ways (spec §3f): **mode A — visualizer-is-the-page**
(Mix detail, full-bleed centerpiece); **mode B — ambient environment** (Cut/Session detail, living
texture *behind* the hero+content — this is the only mode that is genuinely a "backdrop"); **mode C —
contained live element** (NowPlaying card, a bounded live readout, `Fill`-sized to the card). Same engine,
same datum contract — variance is entirely in hosting composition. **Controls (Daniel, full parity, §8b):**
the lava controls ride **every host** — Mix, Cut, Session, **and** the NowPlaying card — via the single
popover-hosted panel (below); controls are no longer a per-mode discriminator.
**Controls-hosting revision (Daniel, 2026-06-17 — supersedes the inline knob-bar model).** *"We have enough
[controls] now that I want to design a panel to be hosted in a popover for the visualizer controls. The
lava-lamp toggle should be wired to this popover, so anywhere we can put one Icon we can put the control
surface."* The eight knobs no longer ride an inline *bar* per page — they move into a **single
popover-hosted panel** triggered by the **lava-lamp icon** (click icon → panel pops over). This is **more
DRY than the per-page bar** (one `<icon → popover → panel>` composition reused verbatim, not three-to-four
per-host bar layouts) and it **dissolves §8b-followup**: with a popover, the small NowPlaying card places
the *same* icon as every other host and the panel floats on demand, so the "is the card too small for the
bar?" question evaporates — **full parity on all four surfaces, the popover way**. The SOLID seam: **one
panel component (`WaveformVisualizerControls` becomes the panel content), one popover host
(`WaveformVisualizerControlPopover`), placed by an icon anywhere.** Panel styled to the **NowPlaying Hero
look** — dark-navy ground, green-accent knobs, light icons, muted-navy filler — pulled from the
`deepdrft-tokens.css` source of truth (no hardcoded hex; spec §3g). New open item the popover creates: its
anchor/positioning per host (§8e) — a layout detail, not a presence decision.
**Deliverable 2 — NowPlayingHero overhaul (mode C).** `NowPlayingCard.razor` today animates **20 hardcoded
CSS-bounce bars** with no audio coupling (the "stochastic" visualizer). Replace them with the *same*
`WaveformVisualizer`, mounted inside the existing player cascade and pointed at the **current track** — so
the home card shows the **real** high-res waveform of the live track, Mix or not. The payoff of the
generalization: the NowPlaying card is *just another host* of the one engine. The one genuine engineering
wrinkle is that the renderer assumes full-viewport (`position: fixed; inset: 0`, clip-to-footer) and the
card needs it container-relative — recommend a `Fill` mode parameter (spec §6c).
**Design discipline.** Rename the engine to its abstraction (`MixWaveformVisualizer``WaveformVisualizer`,
etc.) — a `Mix`-named component on a Cut page is a lie that cements the wrong model. Variance rides
**composition** (a new optional `Ambient` slot on `ReleaseDetailScaffold` for mode B; Mix keeps its own
mode-A mount; the card is a mode-C contained mount; per-host control suppression), never a `switch (medium)`
in the engine (memory *One source, multiple views*; scaffold's "variance rides a slot, never a flag" idiom,
Phase 9 §5.3). The slot is named `Ambient` not `Backdrop` precisely because Mix doesn't use it. **The lava
controls are now one popover-hosted panel placed by the lava-lamp icon on every host** (Mix, Cut, Session,
NowPlaying card — full parity, the popover dissolving the old card-suppression sub-question); the panel and
its NowPlaying-Hero styling are built once and reused (memory *Design for adaptability up front* — the
popover seam makes "place the controls anywhere there's an icon" a zero-cost composition).
Sequenced as **six waves**: `12.A → {12.B1 → 12.B2, 12.E}`, then `(12.B2 ∧ 12.E) → (12.C ‖ 12.D)`
**12.B1 a parallel server-side track** and **12.E (the popover controls panel) a third parallel track**,
both startable cold day one off the rename.
- **12.A — Rename to the abstraction (mechanical, no behavior change).** `Mix*``Waveform*` across the
five C#/Razor files + the TS module + its import path + the DI registration. **Load-bearing
prerequisite** — every later wave references the generalized names. Acceptance: Mix detail identical;
diff is identifiers only.
- **12.B1 — Generalize the high-res compute to every track + backfill (Direction B, the data change).**
Generalize the duration-derived compute off Mix-only (`WaveformProfileService` / `MixWaveformResolution`),
store per-track keyed by `EntryKey` in a (renamed) `track-waveforms` vault, add per-track high-res compute
to `UnifiedTrackService.UploadAsync`, generalize the CMS generate action to any track, and run the
**Daniel-gated backfill** for existing tracks (§8a-new). **Independent of 12.A** (server/content-side).
The new load-bearing heavy. Acceptance: every track has a high-res datum; new uploads get one; the
generate action works for any track.
- **12.B2 — Per-track datum fetch + bridge rewire.** New track-cardinal `GET
api/track/{trackEntryKey}/waveform` (spec §5b); `GetTrackWaveform`; bridge resolves the *current track's*
`EntryKey` and re-fetches on **track** change (not release change). **Depends on 12.A + 12.B1.**
Acceptance: Mix renders the same high-res lava via the track-cardinal fetch; a non-Mix track returns
high-res.
- **12.E — Popover-hosted control panel (the controls revision).** Turn the renamed
`WaveformVisualizerControls` into the **panel content** and build `WaveformVisualizerControlPopover`
pairing the lava-lamp trigger icon with that panel as overlay content (`MudPopover`). Style the panel to
the **NowPlaying Hero look** from `deepdrft-tokens.css` (no hardcoded hex; spec §3g). Make the
state-scoping call (one shared `WaveformVisualizerControlState`). **Depends on 12.A only** — no per-track
datum needed, so runs **parallel to 12.B**. The unit every host then places. Acceptance: lava-lamp icon
opens a Hero-styled popover with all eight knobs; turning a knob drives the visualizer via the unchanged
`Changed` seam; one panel reused everywhere.
- **12.C — `Ambient` slot on `ReleaseDetailScaffold` + mount on detail pages (mode B, full parity).**
Promote the full-bleed / foreground-stacking / dynamic-footer-clip pattern into the scaffold as an optional
`Ambient` slot; Cut mounts the ambient layer **and places the lava-lamp icon → popover** (full parity);
Session mounts directly **also full-parity** (it doesn't compose the scaffold — spec §3e). Mix is
**unchanged as a layer** (mode A keeps its own full-bleed mount); its only controls change is swapping the
inline `TopRowCenter` bar for the lava-lamp icon → popover (12.E's affordance). **Depends on 12.B2 + 12.E.**
**§8b resolved (full parity) — no longer gated**; Cut and Session ship with both the ambient layer and the
popover controls.
- **12.D — NowPlayingHero rewire (mode C).** Replace the synthetic bars with a contained
`<WaveformVisualizer>` driven by the live cascaded player, pointed at the current track; add the
`Fill`/container-sizing mode (spec §6c); **place the lava-lamp icon → popover on the card** (full parity —
the popover dissolves the old suppression). **Depends on 12.A + 12.B2 + 12.E; independent of 12.C**
(different host). Acceptance: home card shows the real playing-track high-res waveform, at-rest when
nothing plays, and carries the lava-lamp icon → popover like every other host; no synthetic bars remain.
**Resolved by Daniel (2026-06-17), kept visible per file convention:** datum resolution → **Direction B**
(high-res all media; 512-bucket-fallback "Direction A" declined); multi-track-Cut datum → **dissolved by
the per-track model** (renders the current track's datum, no album-representative choice); Cut/Session
hosting + controls → **full parity (option 3)**: all three hosting modes ship **and** the lava controls ride
every host — the three-mode *layout* framing is retained, the change is that controls are no longer
Mix-suppressed (the old "mode 1 Mix-only" and "controls Mix-only" alternatives are both closed);
**controls hosting → popover-hosted panel** (2026-06-17 revision): the controls move from an inline knob bar
to a single popover-hosted panel triggered by the lava-lamp icon, placed identically on every host;
**§8b-followup is dissolved by this** — the NowPlaying card gets the icon → popover like everywhere else, so
full parity now spans all four surfaces (Mix, Cut, Session, NowPlaying card). **Open (created by the popover
revision + Direction B + per-track):** (a) **§8e — popover anchor/positioning per host**: where the
lava-lamp icon sits and the panel anchors on each host (Mix's `TopRightAction` corner is cleanest; the small
NowPlaying card is the tightest case and may look cramped) — recommend one popover with a per-host
`AnchorOrigin` parameter, not a fork; staff-engineer-owned layout call, flagged for a glance in review.
(b) **§8a-new — backfill shape + gate**: one-shot migration/script vs. a CMS
batch action over the generalized generate action (recommend the CMS action; Daniel-gated to *run* either
way; the fetch 404s gracefully for not-yet-backfilled tracks so it can ship before the backfill completes).
(c) **§8b-new — per-track high-res compute cost** (flag only): upload latency (recommend inline; deferral is
the escape hatch) + storage growth (every track now stores a high-res datum, a multi-track Cut stores N —
modest, surfaced not blocking). (d) **§8d — NowPlaying container-sizing + home-page perf** —
staff-engineer-owned (`Fill` mode; `isPlaying`-gated rAF means an idle home page pays nothing), flagged so
a lava lamp on the landing page is no surprise.
---
## Working with this file
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing.
+302
View File
@@ -0,0 +1,302 @@
# About Page — Visual Distinction Directions
Status: **OPEN — awaiting Daniel's pick.** Author: product-designer. Date: 2026-06-17.
Surface: public site only (`DeepDrftPublic.Client/Pages/About.razor` + `About.razor.css`).
Companion to `about-page.md` (the approved structure + copy spec). **That content does not change**
the three movements (People / Process / Product), all COPY blocks, the image-slot plan, and the route
are all signed off. This note is purely about the *visual treatment* of that content.
---
## The problem, stated precisely
The built page doesn't just *feel* like Home — it is Home's grammar, reused in Home's order. About
re-declares Home's section primitives verbatim (`About.razor.css` is explicit about this: the `.hero-*`,
`.section`, `.section-divider`, `.section-dark`, `.section-split`, `.medium-*`, `.cta-*`, `.feature-*`
classes are copied "verbatim from those sources"), and lays them down in the **same sequence** Home uses:
```
Home: hero-split → full-bleed band → divider → two-col section → dark band → split → CTA → trailing band
About: hero-split → full-bleed band → divider → two-col section → dark band → split → CTA → trailing band
```
Same primitives, same rhythm, same order → reads as a Home clone with different words. The original spec
*asked* for this ("built entirely in the Home page's visual language… No new visual vocabulary is
proposed") — that decision is what now needs revisiting. The spec optimised for *continuity*; it
overshot into *sameness*.
**The guardrails (unchanged):** same palettes (Charleston in the Day / Lowcountry Summer Nights), same
type families (Bodoni Moda / Cormorant / DM Sans), same voice, same gas-lamp/lava-lamp motif vocabulary,
same bw↔colour `ParallaxImage` crossfade idiom. Distinction comes from **composition, rhythm, structure,
and one signature device** — never from new colour, type, or anything that reads as a separate site.
**First-principles framing.** Home's job is *arrival and orientation* — it fans out: "here are the
mediums, here's what we offer, here's a taste." Its rhythm is a **grid of equal blocks** (3 medium cards,
4 feature cards, symmetric 50/50 splits). About's job is *argument and narrative* — a single sustained
read, People → Process → Product, one claim earned over a scroll. A narrative wants a **different
backbone than a grid**: a spine, a sequence, a sense of progression. That mismatch — grid rhythm forced
onto a narrative — is the deepest reason it feels borrowed. Each direction below picks a different
narrative backbone.
---
## What stays constant across all three directions
So the divergence is legible and the brand holds:
- **Palette tokens** — every `var(--deepdrft-*)` stays. No new colours.
- **Type stack** — display serif for titles, mono for eyebrows/labels, body for prose. The `<em>`
italic-serif emphasis on one word per title stays.
- **The bw↔colour `ParallaxImage` crossfade** — the brand's signature image behaviour. All three
directions use it; they differ in *how images are framed and placed*, not whether they crossfade.
- **The three divider tags** (The People / The Process / The Process) remain the movement boundaries —
though two directions restyle the divider itself into the signature device.
- **The dark band for Process** — the navy ground for the logos/analytical movement is a strong
mapping and survives in all three (its internal layout may change).
---
## Direction 1 — "The Liner Notes" (numbered editorial spine)
**The one-line idea:** About becomes a **numbered three-movement essay** — a record's liner notes, or a
gatefold sleeve — with a persistent left margin carrying oversized movement numerals and running
marginalia, while the content column runs asymmetrically to the right.
### The signature device
A **left margin rail**, ~1216% of viewport width, that runs the length of the page. It carries:
- **Oversized Bodoni numerals**`01` / `02` / `03` — one per movement, set in the display serif at
`clamp(5rem, 10vw, 9rem)`, weight 300, in a low-opacity navy (or green for the active movement as it
scrolls into view). These replace the centred `.section-divider` rule entirely.
- **A thin vertical rule** running continuously down the margin (the `--deepdrft-border` hairline,
vertical instead of Home's horizontal `.divider-line`) — a literal spine for the narrative.
- **Mono marginalia** — short rotated/stacked captions beside images and quotes ("Charleston, SC",
"Akai Force", "100% live"), the way a magazine annotates a photo. This is where image captions live,
giving photos an editorial frame Home never has.
The content column sits to the right of the rail and is **offset / asymmetric** — never the centred,
balanced 4/8 grid Home uses. Prose hangs at a consistent left edge; pull-quotes (the sharp lines —
"designed, not extracted; assembled, not distilled") break *left into the margin* at large serif scale.
### How it diverges from Home
- Home has **no persistent vertical structure** — every section is a fresh full-width block. The
continuous numbered spine is the single strongest "this is a different page" signal, visible at every
scroll position.
- Home's dividers are **horizontal centred rules**; here the divider *is* the vertical rail + numeral.
- Home is **centred and symmetric**; this is **left-anchored and asymmetric**.
### How it stays in-theme
- The numerals are Bodoni Moda — the exact display face Home's titles use. The marginalia is the same
mono eyebrow style (`.hero-eyebrow` / `.section-label`), just rotated/repositioned.
- The vertical rule is the same hairline token as `.divider-line`, reoriented.
- Palette, prose styling, dark Process band, crossfade images: all untouched.
### Photography that serves it
- **Portrait-orientation, editorially cropped, inset** (not full-bleed) — images sit *within* the
content column with generous margin air, captioned in the rail. The restraint is the point: a few
precisely-placed framed shots, not a wall of full-bleed bands.
- The two **member portraits** (C1/C2) are the natural anchors — tall 1365×2048 bw shots, colour on
hover via the crossfade, each captioned in the margin with name + role.
- One **landscape gear shot** in the Process movement, but inset and captioned ("the live rig"), not a
full-width band.
- **Best supplied as:** tall portraits + a couple of detail/gear shots; bw primary, colour crossfade.
Full-bleed shots are *not* what this direction wants — it's about framing and white space.
### Pros
- Strongest, most unmistakable identity of the three. Reads instantly as "the story page."
- The numbered-movement system makes the People/Process/Product structure *visible* without a heading
that says "Movement One" — exactly the spec's stated goal (§2: "legible without a heading").
- Marginalia gives bespoke photos an editorial home and solves image captioning elegantly.
- Most "magazine feature" of the set — flattering to a collective that wants to read as serious artists.
### Cons / cost
- **Highest implementation cost.** A persistent margin rail with scroll-reactive numerals is genuinely
new layout (CSS grid with a fixed rail column; optional `IntersectionObserver` for active-numeral
highlight). Not a recombination of existing primitives.
- Asymmetric margin rails are the **hardest to make responsive** — on mobile the rail must collapse
(numerals inline above each movement, marginalia folds under images). Needs deliberate breakpoint design.
- Risks feeling *too* distinct if executed heavy-handed — the guardrail is keeping it quiet.
**Relative cost: HIGH.** New layout system + responsive collapse + optional scroll observer.
---
## Direction 2 — "The Contact Sheet" (full-bleed cinematic, photography-led)
**The one-line idea:** Invert Home's text-first/image-as-accent balance. About becomes
**photography-led** — full-bleed duotone images are the structural spine, and text is overlaid on or
interleaved between them as captions and standfirsts. The collective shown, not just described.
### The signature device
**Edge-to-edge full-bleed image bands as the primary structure**, each treated as a **duotone** (navy
shadows → warm white highlights, derived from the existing palette tokens — *not* a new colour, a
two-tone map of colours already in the system). Text lives in two registers:
- **Overlaid** — movement titles set *over* a darkened full-bleed image (scrim gradient already exists
as `.medium-scrim`), the way a film title card or album back-cover works.
- **Interleaved tighter prose blocks** between image bands — narrower measure, more white space than
Home's `.section`, so the rhythm is image / breath / image rather than Home's block / block / block.
Home uses parallax bands as *punctuation between* text sections. This direction makes the **image bands
the sentences** and text the punctuation — a full inversion of the figure/ground relationship.
### How it diverges from Home
- Home is **text-dominant**, images are accents (one full-bleed band per major beat). This is
**image-dominant** — the page is mostly photography, text earns its place between.
- The **duotone treatment** is a consistent grade Home doesn't apply (Home shows straight bw + colour
crossfade). Duotone gives About a unified cinematic skin while staying inside the palette.
- Titles-over-image is a composition Home never uses (Home's titles always sit on a flat ground).
### How it stays in-theme
- Duotone is built *from* `--deepdrft-navy` and `--deepdrft-white` — it's a grade of the existing
palette, not a new one. (Crossfade-to-full-colour on hover can still pay off the bw↔colour idiom.)
- The scrim, the type, the `<em>` emphasis, the mono eyebrows — all reused.
- The dark Process band becomes the *natural* mode here rather than an exception — the whole page leans
darker and more cinematic, with the People/Product movements as the lighter relief.
### Photography that serves it
- **This direction lives or dies on photo quality and quantity** — it's the most photo-hungry. Needs
**strong full-bleed landscape shots**: the duo, the gear, the live/warehouse atmosphere, the street/swamp.
- Wants **atmosphere and environment**, not just portraits — wide shots with negative space for text
overlay (a title needs a dark quiet corner to sit in).
- **Best supplied as:** 57 high-res landscape shots, ideally already shot with negative space for
overlay; bw or colour (the build applies the duotone grade). Daniel's "NEW About-specific photos"
are the enabler here — if the inventory is rich and cinematic, this direction has the highest ceiling.
### Pros
- Most emotionally immediate — you *see* the people and the room, which is the entire pathos/ethos pitch
("real people in the Charleston scene"). Shows the proof rather than asserting it.
- Strong distinct identity from a different lever (imagery) than Direction 1 (structure).
- Moderate build — full-bleed bands and scrims already exist; the new work is the duotone grade and
overlay-text compositions, not a new layout engine.
### Cons / cost
- **Photo-dependent and therefore risky.** The one open item in the spec is *the photos aren't final
yet*. A photography-led direction that ships before great photos arrive degrades worse than a
text-led one — placeholders in a full-bleed-image page are very visible. Couples the page's success
to an unresolved dependency.
- Text-over-image hurts **readability and accessibility** if scrims/contrast aren't carefully tuned;
the bio prose (especially Daniel's long bio) doesn't want to live over an image — needs flat-ground
exceptions, which slightly dilutes the concept.
- Duotone grade needs either pre-processed assets or a CSS/SVG filter pass — a small new technique.
**Relative cost: MODERATE.** Reuses bands/scrims; new work is duotone grade + overlay compositions +
contrast tuning. But carries schedule risk via the photo dependency.
---
## Direction 3 — "The Offset Ledger" (asymmetric rhythm + signature textural divider)
**The one-line idea:** Keep Home's primitives largely intact, but **break the symmetry and re-pitch the
rhythm**, plus introduce **one signature recurring motif** — a distinctive textural/waveform divider —
that becomes About's fingerprint. The lightest-touch direction: distinct, but built from what's there.
### The signature device
Two moves working together:
1. **Asymmetric column rhythm.** Home's two-col section is a balanced 4/8 with the title left, prose
right, every time. About **alternates** the offset — title-left/prose-right, then prose-left/title-
right — and pushes the ratios off-balance (3/9, 9/3) so no two movements share a footprint. The
member bio pair goes from Home's even 6/6 grid to a **staggered/offset pair** (one card dropped
vertically against the other), breaking the "grid of equal blocks" feel.
2. **A signature divider motif** replacing Home's plain `.divider-line`. Instead of a flat hairline
rule between movements, About uses a **thin waveform/oscillation line** — a subtle SVG sine or a
miniature static waveform stroke — as its movement divider. This ties directly to the site's
existing lava-lamp/`WaveformVisualizer` identity (the waveform is *already* a core DeepDrft motif on
the detail pages) and makes the divider unmistakably About-and-DeepDrft, not generic. The mono
movement tag sits on this waveform rule instead of a straight line.
### How it diverges from Home
- Home is **rigorously symmetric** (equal grids, balanced splits, centred rules). About becomes
**deliberately off-balance** — the eye travels differently down the page even though the parts are familiar.
- The **waveform divider** is a recurring signature Home doesn't have — a small motif that recurs three
times and stamps the page.
- The staggered bio pair reads as *editorial layout* rather than *card grid*.
### How it stays in-theme
- It's the **most conservative** — same primitives, mostly re-proportioned. Lowest risk of reading as a
different site.
- The waveform motif isn't new vocabulary — it's *borrowed from elsewhere in the same product*
(the visualizer), so it deepens brand coherence rather than diluting it. Strong argument: it makes
About feel more DeepDrft, not less.
- Palette, type, images, dark band: untouched.
### Photography that serves it
- **Flexible — the least photo-demanding.** Works with the existing slot plan as-is (portrait bios,
landscape gear/atmosphere bands). The bespoke photos slot straight in; the offset rhythm just frames
them less symmetrically.
- The staggered bio pair wants **two portraits of slightly different crop/scale** so the stagger reads
as intentional composition, not misalignment. Portrait orientation, bw + colour crossfade as specced.
- **Best supplied as:** exactly the spec's current §8 inventory — no new photographic demands. Safest
against the open photo dependency.
### Pros
- **Lowest implementation cost and lowest risk.** Largely re-proportioning existing primitives + one
new divider motif. Ships closest to what's already built.
- The waveform divider is a genuinely *clever, cheap* signature — high identity-per-unit-effort, and it
reinforces the existing brand motif rather than inventing one.
- Decouples from the photo dependency — works today with placeholders, improves when photos land.
### Cons / cost
- **Weakest distinction of the three.** Asymmetry + a dived motif is subtle; a casual visitor might not
consciously register "different page" the way they would with Direction 1's numeral spine or
Direction 2's full-bleed imagery. Risk: solves the brief only partway.
- Asymmetric ratios still need responsive care (offsets collapse to stacked on mobile, where the whole
distinction partly evaporates — mobile sees a single column regardless).
- The waveform divider is the one bit carrying most of the load; if it's too subtle it does little, if
too loud it fights the editorial restraint. Narrow tuning window.
**Relative cost: LOW.** Re-proportion existing primitives + one new SVG divider component.
---
## Comparison at a glance
| | Direction 1 — Liner Notes | Direction 2 — Contact Sheet | Direction 3 — Offset Ledger |
|---|---|---|---|
| **Lever** | Structure (numbered spine) | Imagery (full-bleed duotone) | Rhythm + motif (asymmetry + waveform divider) |
| **Distinction strength** | Highest | High | Lowest |
| **Build cost** | High | Moderate | Low |
| **Photo dependency** | Low (framed portraits) | **High** (cinematic full-bleed) | Lowest (uses current plan) |
| **Brand-coherence risk** | Medium (most novel) | Medium (duotone is new grade) | Lowest (motif borrowed from product) |
| **Best if Daniel wants…** | a serious "story page" | to *show* the collective | a safe, quick, clever win |
---
## Recommendation
**Lead with Direction 1 (Liner Notes), with Direction 3's waveform divider folded in as its signature
motif.** They are not mutually exclusive — the numbered editorial spine (D1) is the structural backbone,
and the waveform-line divider (D3) is a natural, on-brand way to render the movement boundaries *within*
that spine. Together they give About the strongest, most coherent identity: a numbered narrative with a
DeepDrft-native (waveform) signature, built on the existing palette/type/image idioms.
**Why D1 over D2 as the lead:** Direction 2 is the most exciting *if the photography is exceptional and
ready* — but the spec's single remaining open item is precisely that the photos aren't final. Betting the
page's identity on the unresolved dependency is the wrong risk. D1's identity comes from *structure*,
which the build controls completely; great photos then make it better rather than being load-bearing for
it to work at all. D1 also best serves the page's actual job — a sustained narrative argument — where
D2's strength (immediate emotional imagery) is slightly at odds with the long-read bio prose.
**Why not lead with D3 alone:** it's the safe pick and the cheapest, and it's a legitimate choice if
Daniel wants minimal effort — but on its own it may under-deliver on "its own identity." Its best idea
(the waveform divider) is better deployed *inside* D1 than as the whole answer.
**The fallback ladder, by appetite:**
- **Most ambitious / strongest identity:** D1 + D3's divider (the recommendation).
- **If the new photos are genuinely cinematic and Daniel wants to lead with them:** D2.
- **If the appetite is "make it distinct but cheap and low-risk, ship soon":** D3 alone.
**Open question for Daniel (one):** how much build appetite does this have? D1 is real new layout work;
D3 is an afternoon. That single answer picks the lane. Everything else (palette, type, copy, image slots)
is already settled.
---
## Note on the current `About.razor.css` duplication
Independent of which direction is chosen: the current build **re-declares Home's primitives verbatim**
in `About.razor.css` (its own header comment says so). Whichever direction lands, this is the moment to
decide whether those shared primitives should be **promoted to the global `deepdrft-styles.css`** (so
About *consumes* them rather than copying — which §10 of the spec actually recommended) instead of
maintaining two divergent copies. Flagging as a structural concern for staff-engineer to weigh during
implementation — not a design call, but a cost the chosen direction inherits. (Surfaced for awareness;
not in product-designer's remit to resolve.)

Some files were not shown because too many files have changed in this diff Show More