147 Commits

Author SHA1 Message Date
daniel-c-harvey dc70be768a feat: Archive Searchbar Padding
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m11s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m36s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m6s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m32s
Deploy DeepDrftManager / Deploy (push) Successful in 1m28s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-17 06:50:05 -04:00
daniel-c-harvey ad94354632 feat: Adjust RMS Window 2026-06-17 06:42:21 -04:00
daniel-c-harvey 8331ccf6a3 Merge fix-share-embed into dev (anchor SharePopover via documented MudPopover RelativeWidth; restore track-mode embed affordance) 2026-06-17 06:42:10 -04:00
daniel-c-harvey 372e006be1 docs(phase-10): update smoothing reference to ~15 ms 2026-06-17 06:39:10 -04:00
daniel-c-harvey dcfb1fca9f Merge p10-reduce-smoothing into dev (waveform smoothing 50ms->15ms; DEBUG flags off) 2026-06-17 06:33:43 -04:00
daniel-c-harvey ea74aaaf2e fix(mix): reduce waveform smoothing to 15 ms; turn off DEBUG flags 2026-06-17 06:33:03 -04:00
daniel-c-harvey 54ef4c038e doc: MixVisualizerControls 2026-06-17 06:31:06 -04:00
daniel-c-harvey 394b07f404 fix(share): anchor SharePopover via documented MudPopover RelativeWidth
Use Fixed + RelativeWidth=Adaptive with BottomLeft/TopLeft origins to
anchor the menu under the share button; drop the inline-block shrink-wrap
container hack. Keep AutoClose off so the embed panel survives clicks.
2026-06-17 06:30:31 -04:00
daniel-c-harvey d6df0de63a docs(phase-12): fold popover-hosted controls into spec + plan
Controls move from an inline per-page knob bar to a single popover-hosted
panel triggered by the lava-lamp icon, placed identically on every host
(Mix, Cut, Session, NowPlaying card). Dissolves the NowPlaying-controls
question — full parity via the popover. Adds the popover panel wave, panel
styling from theme tokens, and a popover-anchor open item.
2026-06-17 06:07:49 -04:00
daniel-c-harvey 76060f60a8 docs(phase-10): record visualizer tuning landing — eight-knob controls, server-only smoothing, spec shipped 2026-06-17 06:04:01 -04:00
daniel-c-harvey b9f06bb7cd Merge p10-remove-ts-smoothing into dev (drop client-side datum smoothing; waveform smoothing stays the server's job) 2026-06-17 05:47:16 -04:00
daniel-c-harvey d105385006 style(releases): scope gallery container with ::deep, move card radius to cover, narrow mix hero to 480px 2026-06-17 05:46:40 -04:00
daniel-c-harvey e48baa5b27 refactor(mix-visualizer): remove client-side datum smoothing — waveform smoothing is the server's job 2026-06-17 05:38:56 -04:00
daniel-c-harvey cf47fee07e docs(phase-12): revise spec — Direction B, per-track datum, full-parity controls
Daniel resolved the open questions: high-res compute for all media (B); the
waveform datum is per-track, not per-release (release is just the host —
dissolves the multi-track-Cut question); full-parity lava controls on all
detail hosts. Splits 12.B into compute+backfill / fetch+bridge; renames the
scaffold slot to Ambient. NowPlaying-card controls left as open sub-question.
2026-06-17 05:33:34 -04:00
daniel-c-harvey a0b3255028 Merge p10-visualizer-tuning into dev (smooth waveform, bouncy wax↔waveform collision, 8-knob controls with fluid amount/viscosity split, Visible-gated knob band) 2026-06-17 05:23:18 -04:00
daniel-c-harvey d36aea212c docs(visualizer): fix five inaccurate comments — sub-unity restitution, uniform heat boost, progressive push-out, scroll-speed cross-ref, eight-knob bar 2026-06-17 05:20:12 -04:00
daniel-c-harvey efef23753b docs(phase-12): spec waveform-visualizer generalization + NowPlayingHero rewire
Generalize the Mix-only WebGL lava visualizer into one release-cardinal
WaveformVisualizer serving Mix detail, all Release Detail pages, and the
home NowPlaying card. Four waves; flags the non-Mix datum-resolution call.
2026-06-17 05:12:19 -04:00
daniel-c-harvey 4e34696719 feat(mix-visualizer): Phase 10 tuning — smooth waveform, bouncy collision, 8 knobs
Smooth the loudness contour (~50 ms envelope at preprocessing + decode-time, plus
smootherstep render reconstruction); retune wax↔waveform collision to bouncy/sub-unity
(no explosion/stuck/jitter); split the bubbles knob into fluid-amount + fluid-viscosity
(cohesion via uniform-only smin/wobble); retune scroll/gravity/heat/width ranges; make
the colour rotation visible and boost OKLab chroma; the controls bar now holds its
layout and hides only its knobs via a Visible parameter.
2026-06-17 05:12:15 -04:00
daniel-c-harvey ba1a1cd8ec Merge fix-sharepopover-anchor into dev (anchor release SharePopover under the share button via inline-block wrapper) 2026-06-17 04:11:30 -04:00
daniel-c-harvey bfdbf7568f fix: Mix Visualizer Controls Styles 2026-06-17 04:08:18 -04:00
daniel-c-harvey 4eba3b0bb3 docs(SharePopover): correct inline-block/relative comments — inline-block is load-bearing, relative is incidental 2026-06-16 21:13:06 -04:00
daniel-c-harvey 39fabc8d0d docs(phase-10): record Mix hero-overlay landing — ReleaseHeroOverlay in CLAUDE.md, PLAN→COMPLETED, spec marked shipped 2026-06-16 21:08:14 -04:00
daniel-c-harvey 371812b274 Merge p10-w1-mix-hero-overlay into dev (Mix detail: shared ReleaseHeroOverlay, cover-as-overlaid-600px-square hero, ShowHeader scaffold gate) 2026-06-16 20:54:51 -04:00
daniel-c-harvey a6d25344b4 feat(mix-detail): extract shared ReleaseHeroOverlay; Mix cover becomes overlaid 600px square hero (Direction B) 2026-06-16 20:53:25 -04:00
daniel-c-harvey 81ea5909d2 fix(share-popover): anchor popover to button via relative-positioned wrapper div 2026-06-16 20:49:58 -04:00
daniel-c-harvey 9cf6bb4cf2 docs(phase-10): spec Mix detail hero+meta overlay mirroring Sessions (shared ReleaseHeroOverlay recommended) 2026-06-16 20:34:13 -04:00
daniel-c-harvey 3e97e34aee Merge p10-controls-if-guard into dev (Blazor @if-gated knob band, no CSS hide/glass/animation) 2026-06-16 20:31:56 -04:00
daniel-c-harvey fc7c9e978f feat(mix-visualizer): gate knob controls with Blazor @if in TopContent band; drop CSS collapse, glass, and TopRowCenter slot 2026-06-16 20:31:42 -04:00
daniel-c-harvey daafae8af6 Merge p10-controls-inline into dev (Phase 10 reframe: in-flow controls container between back link and lava-lamp, TopRowCenter slot) 2026-06-16 20:15:38 -04:00
daniel-c-harvey 841822d8fe fix(mix-visualizer): move seven-knob controls in-flow between back link and lava lamp (Phase 10 reframe §7b) 2026-06-16 20:12:02 -04:00
daniel-c-harvey 1730aa0166 docs(public): document StatusCodePages middleware ordering constraint 2026-06-16 20:07:43 -04:00
daniel-c-harvey b7a60f24c5 docs(phase-10): respec Mix visualizer controls as in-flow container between back link and lava-lamp 2026-06-16 20:05:59 -04:00
daniel-c-harvey f7366b167c Merge fix-antiforgery-statuscodepages into dev (share-link 404 fix: session/mix release-mode share + /404 antiforgery ordering) 2026-06-16 20:01:36 -04:00
daniel-c-harvey c926937694 fix(share): correct share URLs for session/mix detail pages — release mode + /tracks/ plural 2026-06-16 18:58:32 -04:00
daniel-c-harvey d8d908d4a6 fix(public): move UseStatusCodePagesWithReExecute before UseAntiforgery to fix 404 re-execution antiforgery error 2026-06-16 18:43:56 -04:00
daniel-c-harvey b5fdb826b0 Merge p11-cleanup-residuals into dev (P11 residual cleanup: stale-id docs, test EntryKey shapes, dead CSS) 2026-06-16 18:19:38 -04:00
daniel-c-harvey dae8020a22 chore(p11): fix stale-id docs, align test EntryKey shapes, drop dead track-card-link CSS 2026-06-16 18:05:37 -04:00
daniel-c-harvey 919a800f4b Merge p10-reframe-w3-color into dev (Phase 10 Reframe W3: OKLab three-color gradient + live density-size) 2026-06-16 18:03:46 -04:00
daniel-c-harvey 79de2503c4 feat(visualizer): OKLab three-color gradient + live density-size dial (Phase 10 reframe R3) 2026-06-16 18:03:20 -04:00
daniel-c-harvey 5b3036ed83 docs: record 11.H landed — Phase 11 complete (11.A-11.H); two release migrations pending apply 2026-06-16 17:44:52 -04:00
daniel-c-harvey 946b1d7cf9 Merge p11-w5-release-entrykey into dev (P11 11.H: release EntryKey on the public addressing surface; migration authored, not applied) 2026-06-16 17:26:53 -04:00
daniel-c-harvey 56d94b7424 Merge p10-reframe-w4-controls into dev (Phase 10 Reframe W4: 7-knob inline controls, always-on lava loop, filled icon) 2026-06-16 17:18:44 -04:00
daniel-c-harvey 41ac7a5a93 Phase 10 reframe R4: seven-knob inline visualizer controls, always-on lava loop, filled lava-lamp icon 2026-06-16 17:17:14 -04:00
daniel-c-harvey f07d29cdcf feat(release): front int PK with app-minted GUID EntryKey on the public addressing surface (P11 W5, 11.H) 2026-06-16 17:11:55 -04:00
daniel-c-harvey bb4e169d0a docs: record 11.D (Archive URL filters + GenresView repoint) landed (P11 W4) 2026-06-16 13:11:52 -04:00
daniel-c-harvey fe28573b68 chore(assets): track lava-lamp source SVG (glyph source for the visualizer-controls icon) 2026-06-16 13:08:15 -04:00
daniel-c-harvey d5ea5f52ee Merge p11-w4-archive-url-filters into dev (P11 11.D: Archive filters in URL, GenresView repoint) 2026-06-16 12:56:20 -04:00
daniel-c-harvey 78df665480 Merge p10-reframe-w2-tune into dev (Phase 10 Reframe W2 tuning: flat coalescing fluid, up+out elastic throw, heat turbulence, waveform-width) 2026-06-16 12:48:32 -04:00
daniel-c-harvey a64a5598ae feat(visualizer): R2 lava tuning — flat fluid, melt, up+out throw, heat-driven turbulence, waveform-width knob 2026-06-16 12:48:17 -04:00
daniel-c-harvey 5fb7d85019 docs(phase-10-reframe): fold Wave R2 eval into lava spec + PLAN (7th control, flat coalescing fluid, up-and-out collision) 2026-06-16 12:47:44 -04:00
daniel-c-harvey ca5fc5649a feat(archive): bind search/medium/genre filters to the URL (11.D); repoint genre tiles to /archive 2026-06-16 12:39:40 -04:00
daniel-c-harvey 09309630cb Merge p10-reframe-w2-physics into dev (Phase 10 Reframe W2: CPU wax-blob lava physics + 2D collision) 2026-06-16 12:19:39 -04:00
daniel-c-harvey db7afe4ea7 feat(p10-reframe-w2): CPU wax-blob lava physics + 2D collision; smin metaball render 2026-06-16 12:19:30 -04:00
daniel-c-harvey e6a80b6086 docs(plan): lock P11 11.H decision — additive EntryKey string, track-pattern, migration-time backfill 2026-06-16 12:19:25 -04:00
daniel-c-harvey f35cbc82fe docs: record 11.C (retire+normalize) and 11.E (release Share) landed (P11 W3) 2026-06-16 12:03:01 -04:00
daniel-c-harvey ed7304af1f Merge p11-w3-t2-release-share into dev (P11 11.E: release-keyed SharePopover mode, Cut header Share) 2026-06-16 11:58:20 -04:00
daniel-c-harvey 0b2fee1520 Merge p11-w3-t1-retire-normalize into dev (P11 11.C: retire track-cardinal stack, fold Archive/Cuts cards into ReleaseGallery) 2026-06-16 11:58:06 -04:00
daniel-c-harvey cff18df783 Merge p10-reframe-w1-fix into dev (minimized-footer clip + lava-lamp SVG glyph) 2026-06-16 11:57:35 -04:00
daniel-c-harvey 2c4bd3a394 fix(p10-reframe-w1): clip visualizer to minimized FAB height; replace LavaLamp icon with SVG Repo glyph 2026-06-16 11:53:47 -04:00
daniel-c-harvey d899bc9456 docs(plan): add Phase 11 commitment 9 (release GUID identifiers, wave 11.H) 2026-06-16 11:43:11 -04:00
daniel-c-harvey ce437521ee feat(share): add release-keyed copy-link mode to SharePopover; wire Cut header (§3b, P11 W3 11.E) 2026-06-16 11:31:03 -04:00
daniel-c-harvey ef6d21b94e refactor(public): retire track-cardinal stack, fold Archive/Cuts cards into ReleaseGallery (P11 W3 §4) 2026-06-16 11:31:02 -04:00
daniel-c-harvey bef1e3adfb docs: record 11.B ReleaseRoutes resolver landed (P11 W2) 2026-06-16 11:18:16 -04:00
daniel-c-harvey 313551ac7c Merge p10-reframe-w1-clean into dev (Phase 10 Reframe W1: de-noise, dynamic footer clip, lava-lamp icon redraw) 2026-06-16 11:17:32 -04:00
daniel-c-harvey f08b412772 docs(product): fold Mix Visualizer lava reframe under Phase 10 (Waves R1-R4); inline knob-bar + icon redraw 2026-06-16 11:16:03 -04:00
daniel-c-harvey d98ead97c3 Merge p11-w2-releaseroutes-resolver into dev (P11 11.B: ReleaseRoutes.DetailHref resolver + repoint, /tracks/{id} redirect) 2026-06-16 11:13:22 -04:00
daniel-c-harvey ff37efea89 Phase 10 W1: de-noise Mix visualizer, clip to live player-bar height, redraw lava-lamp icon 2026-06-16 11:12:20 -04:00
daniel-c-harvey 55515981a9 feat(routing): add ReleaseRoutes.DetailHref resolver; repoint release click sites and add /tracks/{id} redirect (P11 W2 §2) 2026-06-16 10:56:28 -04:00
daniel-c-harvey 74b9c02722 docs(plan): add Phase 12 Mix Visualizer Lava Reframe spec; supersede Phase 10 effects/controls 2026-06-16 10:33:24 -04:00
daniel-c-harvey 96b13af95d docs: record §3.4 PlayAlbum queue seam closure (P11 W1 follow-up)
CutDetail Play affordances now consume IQueueService.PlayRelease; annotate
PLAN.md §11 landed note and add COMPLETED.md entry.
2026-06-16 10:28:19 -04:00
daniel-c-harvey f8f9844ef4 Merge p11-w1-playalbum-seam into dev (P11 W1: wire CutDetail Play to IQueueService, §3.4 seam closed) 2026-06-16 10:26:57 -04:00
daniel-c-harvey 6ac943ca09 feat(cuts): wire PlayAlbum/PlayTrack to IQueueService.PlayRelease (§3.4 seam, P11 W1)
Header Play loads full album at index 0; row play loads at that row's index with same-track
toggle preserved; null-safe cascade fallback to direct SelectTrackStreaming when queue absent.
2026-06-16 10:22:59 -04:00
daniel-c-harvey 364450885b Merge p10-w4-popover-knobs into dev (Phase 10 Wave 4: lava-lamp popover, RadialKnob controls, wider Mix detail body) 2026-06-16 00:48:12 -04:00
daniel-c-harvey fbb397228e Merge dev into p10-w4-popover-knobs (integrate concurrent Phase 11 scaffold changes)
# Conflicts:
#	DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs
2026-06-16 00:47:58 -04:00
daniel-c-harvey c2a3e53991 Merge p10-w3-effects-rework into dev (P10 W3 rework: vivid HSL field, time-driven bubbling, surface-born bubbles, working color-shift) 2026-06-16 00:40:41 -04:00
daniel-c-harvey 23b34004ff Merge p11-w1-description-schema into dev (P11 11.G: release Description field, migration authored) 2026-06-16 00:38:13 -04:00
daniel-c-harvey aedbe82d28 Merge p11-w1-queue-service into dev (P11 11.F: play-queue IQueueService + skip controls) 2026-06-16 00:37:31 -04:00
daniel-c-harvey 2bb7d86e63 fix(icons): strip outer <svg> wrapper from LavaLamp — MudBlazor supplies its own 2026-06-16 00:37:20 -04:00
daniel-c-harvey ff9c87c461 Merge p11-w1-cuts-detail into dev (P11 11.A: /cuts/{id} album-detail page) 2026-06-16 00:37:01 -04:00
daniel-c-harvey e59271aa00 feat(mix): lava-lamp popover with RadialKnob controls + wider Mix detail body (P10 W4) 2026-06-16 00:19:47 -04:00
daniel-c-harvey b27ec1b7d0 docs: record Phase 11 Wave 1 landed (11.A cuts page, 11.F queue, 11.G description)
Annotate PLAN.md §11 with landed tracks; add COMPLETED.md Phase 11 section;
document TrackNumber as a supported sortColumn in DeepDrftAPI/CLAUDE.md.
2026-06-16 00:19:41 -04:00
daniel-c-harvey c1ed2a9ba3 fix(visualizer): vivid HSL field, time-driven bubbling, surface-born bubbles, visible color-shift (P10 W3 rework) 2026-06-16 00:16:37 -04:00
daniel-c-harvey 294414d00a fix(queue): guard OnTrackEnded against direct-play cross-context advance
Only advance when player's CurrentTrack.Id matches queue's Current.Id;
direct-play call sites (SessionDetail, StreamNowButton, resume) that
supersede the queue no longer spuriously advance the album. Adds
regression test covering the scenario.
2026-06-16 00:13:51 -04:00
daniel-c-harvey 2b42e01cd0 feat(player): add IQueueService orchestrating album playback above the single-slot player (P11 11.F)
Queue owns ordered tracks, current index, skip-fwd/back, and auto-advance via the player's TrackEnded hook; binds through Attach (no ctor growth, no service-locator). Player-bar skip controls; empty-queue play unchanged. Adds QueueService unit tests.
2026-06-16 00:04:44 -04:00
daniel-c-harvey 26d7a05ba4 docs: record Phase 10 Wave 3 (in-shader effects) landed 2026-06-16 00:01:05 -04:00
daniel-c-harvey cfacc9f79a feat(release): add plain-text Description field plumbed CMS->DTO->release (11.G)
New nullable Description column (max 4000) on ReleaseEntity, rides the Genre write channel through upload + edit; multiline CMS input. Migration authored, not applied.
2026-06-16 00:00:06 -04:00
daniel-c-harvey 07ddc69cee feat(public): add /cuts/{id} album-detail page
Compose ReleaseDetailScaffold via Header + BodyContent slots for the Cut
album view: left meta + Play/Share, right theme-bordered cover, TrackNumber-
ordered track list with per-row play. CutDetailBase carries the multi-track
prerender bridge.
2026-06-15 23:59:19 -04:00
daniel-c-harvey 779e1f569c Merge p10-w3-shader-effects into dev (Phase 10 Wave 3: four in-shader effects — gradient field, bubblyness, lava-lamp detach, glass) 2026-06-15 23:56:07 -04:00
daniel-c-harvey 5011fb43f0 perf(shader): hoist playhead texture tap; clamp neighbour sdRoundBox corner radius 2026-06-15 23:55:16 -04:00
daniel-c-harvey a9d6445881 feat(visualizer): four in-shader Mix effects — morphing navy-moss field, bubblyness, lava-lamp detach, glass (P10 W3) 2026-06-15 23:42:44 -04:00
daniel-c-harvey 56e205082d docs(plan): add release Description field as commitment 8 / wave 11.G
Verified no Description column exists on ReleaseEntity/ReleaseDto (mirror
image of commitment 5, which was already built). Specs the new base-release
column + EF migration (Daniel-gated), DTO/converter/write-path plumbing,
CMS multiline input, and detail-page text block. Schema lands as 11.G;
render rides 11.A plus a Session/Mix touch.
2026-06-15 23:38:51 -04:00
daniel-c-harvey 31e00e6abd docs(plan): spec Phase 10 Wave 4 — Mix detail popover controls, RadialKnobs, lava-lamp icon, wider body 2026-06-15 23:38:26 -04:00
daniel-c-harvey e9f4411fdf docs(plan): revise Phase 11 — ordinal, full stack retirement, shared cards, release-share, queue
Fold Daniel's 2026-06-15 decisions into PLAN.md §11 and the product note:
4→7 commitments, six waves. Headline: the track ordinal already shipped
in Phase 8, so commitment 5 is verify-and-consume, not a new migration.
Queue half of §1.3 absorbed; preload stays deferred.
2026-06-15 23:30:28 -04:00
daniel-c-harvey 22c2ae5ecb chore: RadialKnob control 2026-06-15 23:26:11 -04:00
daniel-c-harvey b7bd6ba04f docs: record Phase 10 Wave 2 (controls row + control state) landed 2026-06-15 23:22:02 -04:00
daniel-c-harvey 1e6129401b Merge p10-w2-controls-row into dev (Phase 10 Wave 2: visualizer controls row + unified MixVisualizerControlState) 2026-06-15 23:20:28 -04:00
daniel-c-harvey bf00b7f22f feat(visualizer): controls row + unified MixVisualizerControlState; 3 inert uniforms wired (P10 W2) 2026-06-15 23:15:44 -04:00
daniel-c-harvey 913861860b docs(plan): shape Phase 11 — Public Site Enhancements
Add Phase 11 to PLAN.md and a full design spec under product-notes:
Cuts gain a /cuts/{id} album detail page; release-title click resolves
medium to a dedicated detail page; redundant /tracks?album view retired;
Archive filters move into the URL. Includes gap analysis and open
questions for Daniel.
2026-06-15 23:09:16 -04:00
daniel-c-harvey e0f371cda6 Merge p10-w1-jitter-cleanup into dev (P10 W1: startup-jitter easing, diagnostics gated off, review remediation) 2026-06-15 22:46:46 -04:00
daniel-c-harvey 44a15bf67d fix(review): const→static readonly Debug silences CS0162; update stale rAF comment to renderedPlayhead() 2026-06-15 22:44:20 -04:00
daniel-c-harvey 65e5e09245 fix(visualizer): ease playhead re-anchor to kill startup jitter; gate diagnostics off (P10 W1) 2026-06-15 22:32:02 -04:00
daniel-c-harvey d73e94a12f Merge p10-w1-fps-smoothness into dev (P10 W1: wall-clock playhead interpolation for smooth 60 FPS scroll) 2026-06-15 22:16:52 -04:00
daniel-c-harvey df4381b4d8 fix(visualizer): interpolate Mix playhead on wall clock so ribbon scrolls at 60 FPS, not 10 Hz push cadence 2026-06-15 22:16:45 -04:00
daniel-c-harvey ad8cb7dbc0 Merge p10-w1-resolution-slider into dev (P10 W1: fix zoom slider stacking-context occlusion) 2026-06-15 21:54:43 -04:00
daniel-c-harvey 652c90979d fix(visualizer): lift zoom slider out of fixed backdrop's stacking context so it receives pointer events again (P10 W1) 2026-06-15 21:54:22 -04:00
daniel-c-harvey 1ad501ff11 Merge mix-play-track-selection-fix into dev (forward releaseId through TrackProxyController so WASM-path mix/session Play resolves the release's own track) 2026-06-15 21:23:35 -04:00
daniel-c-harvey c9b8dfcf3f fix(proxy): forward releaseId filter in TrackProxyController.GetPage so WASM-path mix/session track resolution is not stripped at the proxy boundary 2026-06-15 21:07:50 -04:00
daniel-c-harvey 2bacf58241 Merge session-detail-hero-overlay into dev (Session detail hero-overlay redesign, NowPlaying-themed) 2026-06-15 20:38:09 -04:00
daniel-c-harvey 83c0425133 docs: note SessionDetail hero-overlay composition and scaffold divergence 2026-06-15 19:35:36 -04:00
daniel-c-harvey 0758bfe7f1 fix(css): add ::deep to .session-detail-page so Blazor isolation scope pierces MudContainer boundary 2026-06-15 19:32:34 -04:00
daniel-c-harvey 79e7bb4799 Merge p10-w1-renderer-fix2 into dev (P10 W1: 2-D datum texture fixes GL_MAX_TEXTURE_SIZE overflow + bridge diagnostics) 2026-06-15 19:29:11 -04:00
daniel-c-harvey 45bf5e5d37 fix(visualizer): lay Mix datum across a 2-D R8 texture to respect GL_MAX_TEXTURE_SIZE; manual texelFetch lerp avoids row-wrap seam 2026-06-15 19:28:52 -04:00
daniel-c-harvey 3c7f28b2eb redesign(public): session detail as hero-overlay composition, NowPlaying-themed 2026-06-15 19:27:12 -04:00
daniel-c-harvey 61d53dacff Merge p10-w1-renderer-fix into dev (P10 W1: blank ribbon at rest + WebGL init/draw diagnostics) 2026-06-15 17:45:50 -04:00
daniel-c-harvey 06b58304c5 fix(visualizer): blank Mix ribbon at rest + init/draw diagnostics (P10 W1) 2026-06-15 17:45:21 -04:00
daniel-c-harvey b3283d0bd2 docs: record Phase 10 Wave 1 (WebGL2 renderer swap) landed 2026-06-15 13:43:19 -04:00
daniel-c-harvey cb6f75be5f Merge p10-w1-renderer-swap into dev (Phase 10 Wave 1: WebGL2 fragment-shader Mix renderer at parity) 2026-06-15 13:36:01 -04:00
daniel-c-harvey c1562dde03 Merge track-detail-play-wrong-track into dev (fix wrong-track Play on detail pages) 2026-06-15 13:34:09 -04:00
daniel-c-harvey 8b0bd6d26e docs(client): note detail pages must load in OnParametersSetAsync under InteractiveAuto 2026-06-15 13:15:04 -04:00
daniel-c-harvey 7d23c0654b fix(detail): capture guard fields before await to close re-entrancy window in OnParametersSetAsync 2026-06-15 12:55:15 -04:00
daniel-c-harvey cab181db4b refactor(visualizer): remove dead sampleCount field from Datum — shader uses durationSeconds only 2026-06-15 12:49:47 -04:00
daniel-c-harvey f02f370ed9 fix(detail): reload track on route-param change so Play uses the right track
Detail pages loaded only in OnInitialized, which doesn't re-run when an
InteractiveAuto component instance is reused across same-template navigations,
leaving a stale track that Play streamed. Move load to OnParametersSetAsync
keyed on the route id, and guard the prerender bridge restore against an id mismatch.
2026-06-15 12:47:57 -04:00
daniel-c-harvey b451dda79e feat(visualizer): WebGL2 fragment-shader Mix renderer at parity; datum-as-texture, shader-clock rAF, drop CSS backdrop-filter (P10 W1) 2026-06-15 12:43:56 -04:00
daniel-c-harvey 4f84216ab6 Merge cms-special-action-columns into dev
Promote CMS release special actions (Mix waveform, Session hero) to dedicated grid columns.
2026-06-15 12:01:17 -04:00
daniel-c-harvey bb50d8369b Merge ui-detail-cover-art into dev (medium cover thumbnails on mix and session detail pages) 2026-06-15 11:59:39 -04:00
daniel-c-harvey ebdcc29f2e docs(mix-visualizer): lock MixVisualizerControlState widen decision (§3c) 2026-06-15 11:57:47 -04:00
daniel-c-harvey ea8b97e47b docs: spec WebGL2 Mix visualizer renderer (Phase 10)
Replaces the 1-2 FPS Canvas 2D visualizer with a WebGL2 fragment-shader
renderer. Four-control row, morphing navy/moss field, in-shader glass.
Full spec in product-notes; PLAN.md Phase 10 points at it.
2026-06-15 11:36:46 -04:00
daniel-c-harvey f1600023dc feat(detail): medium release cover thumbnails on mix and session detail pages 2026-06-15 11:36:39 -04:00
daniel-c-harvey 09f6dc88f7 docs: record CmsAlbumBrowser special-action column promotion 2026-06-15 11:30:54 -04:00
daniel-c-harvey 31084b09a4 fix(cms): stabilize _specialColumns allocation and refresh stale comments
Allocate _specialColumns once in OnInitialized; update RowActions references to SpecialColumns in the medium browsers and base class.
2026-06-15 11:26:21 -04:00
daniel-c-harvey 5941f1f23a feat(cms): dedicated grid columns for medium-specific row actions
Replace CmsAlbumBrowser's single RowActions slot with a SpecialColumns
list (header + per-row cell). Mix renders a Waveform column, Session a
Hero column, between Tracks and Actions; Edit/Delete stay rightmost.
Child-row colspan now computed from column count. Cut/ALL unchanged.
2026-06-15 11:16:15 -04:00
daniel-c-harvey 7f1c6bdb66 Merge ui-share-relocate into dev (share button centered below detail metadata) 2026-06-15 11:13:22 -04:00
daniel-c-harvey 37608aee28 Merge ui-mix-visualizer into dev (footer above waveform backdrop; zoom slider to top) 2026-06-15 11:13:05 -04:00
daniel-c-harvey e0ab2f3d00 Merge ui-archive-search into dev (archive search controls: centered flex row + narrow reflow) 2026-06-15 11:12:58 -04:00
daniel-c-harvey 41e3ccc9fa fix(archive): center medium toggle between balanced search/genre side zones 2026-06-15 11:10:06 -04:00
daniel-c-harvey 709103ad71 ui: move SharePopover below metadata on all release detail pages 2026-06-15 10:45:27 -04:00
daniel-c-harvey 9f074f7350 fix(visualizer): lift footer above waveform backdrop; move zoom slider to top-right 2026-06-15 10:45:25 -04:00
daniel-c-harvey 47082591ee refactor(archive): single flex row for search/medium/genre controls with narrow-screen reflow 2026-06-15 10:45:18 -04:00
daniel-c-harvey 4df2b8fb57 Merge 8k-w2-renderer into dev (8.K Wave 2: scrolling Canvas 2D Mix visualizer, read-only) 2026-06-14 19:02:16 -04:00
daniel-c-harvey a9965ad751 docs: record 8.K Mix Visualizer redesign landed; Wave 8 fully complete 2026-06-14 18:39:19 -04:00
daniel-c-harvey c64455f2f2 fix(visualizer): gate rAF loop on is-playing; one-shot redraws while idle (§E) 2026-06-14 18:31:24 -04:00
daniel-c-harvey 2d0a565765 feat(public): scrolling Canvas 2D Mix visualizer — windowed, playback-coupled, zoomable, read-only (8.K W2) 2026-06-14 18:20:32 -04:00
daniel-c-harvey c608fa345a Merge 8k-w1-datum into dev (8.K Wave 1: duration-derived Mix waveform datum density) 2026-06-14 17:13:05 -04:00
daniel-c-harvey 09a980ba2a feat(api): derive Mix waveform datum density from duration (~333 samples/sec, capped/floored) instead of fixed 2048 buckets 2026-06-14 16:21:57 -04:00
daniel-c-harvey da08ac4efb Merge p9-w8-8m-legacy-form-retirement into dev (8.M: retire legacy single-track forms, track-addressed BatchEdit) 2026-06-14 14:51:47 -04:00
daniel-c-harvey 00d7215178 docs: record Wave 8 track 8.M landed (legacy single-track form retirement) 2026-06-14 12:43:42 -04:00
daniel-c-harvey 898fcfaa04 feat(cms): retire legacy single-track forms; route single-track edit into BatchEdit (8.M) 2026-06-14 11:53:06 -04:00
135 changed files with 11742 additions and 2528 deletions
+157 -3
View File
@@ -6,6 +6,140 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 10 — Mix Visualizer Reframe: Waves R1R4 (Lava tuning + eight-knob controls)
**Landed:** 2026-06-17 on dev.
- **What:** A major reframe of the Mix visualizer's effects, controls, and color model, built on the landed WebGL2 Phase 10 renderer infrastructure. Four waves:
- **Wave R1** — removed the static noise/frost texture (Daniel: "makes the screen look dirty"); implemented dynamic footer-height clip so the lava stops cleanly above the player bar; redrawn `DDIcons.LavaLamp` to the classic 1970s silhouette (wide truncated-cone base, bulbous→roundedly-pointed teardrop glass body, small cone cap — navy fluid + moss blobs, body `currentColor`).
- **Wave R2** — CPU-side per-frame physics step (~1632 Lagrangian wax blobs: position/velocity/temperature/radius), uploaded as uniforms each frame; `smin` SDF metaball render producing a **flat, coalescing fluid** (not blobs with radial hotspots); energy-coupled dynamics (high heat → many small turbulent bubbles; low heat → fewer large calm masses); 2D elastic collision on both blob↔waveform and blob↔blob pairs; collision strength knob sweeping from genuine soft mush to a high-elasticity upward-and-outward throw; waveform collision always on regardless of heat. Loudness profile smoothed with a **~15 ms envelope-follower at preprocessing only** (`RmsLoudnessAlgorithm.cs`); there is no decode-time smoothing (`smoothDatum` was removed). Existing vault mixes gain the smoothing only after server-side reprocessing — they do not benefit automatically. Ribbon rendered with smootherstep sinusoid reconstruction.
- **Wave R3** — replaced HSL `mixHsl`/`vivify` color with OKLab interpolation (structural fix for the cyan excursion artifact); three combined gradient motions: (1) A/B anchor rotation among three theme colors at the gradient-rotation-speed rate; (2) per-segment sinusoidal variation keyed to mix-time so colors travel with the segment as it scrolls; (3) per-bar gradient curve shifts with scroll height (mostly A at bottom → mostly B at top). Static noise texture removed. One source of truth (`DeepDrftPalettes`), no hardcoded hexes.
- **Wave R4**`MixVisualizerControlState` widened from four properties to **eight**: `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, **`FluidAmount`** (replaces the former `BlobDensity`), **`FluidViscosity`** (new — cohesion / coalescence control, the second half of the bubbles split), `CollisionStrength`, `WaveformWidth`. `MixVisualizerControls` now renders **eight** `RadialKnob`s; the `Visible` parameter `@if`-gates the knob band while the container holds a reserved min-height so content below never pops when the lamp toggles. Scroll-speed knob range tuned to 60110% band; gravity 075%; heat +20% at top; width default 50% / range 1095%. The scaffold's `TopRowCenter` slot (added in the prior reframe) carries the controls in-flow between the back link and lava-lamp toggle.
- **Why:** Daniel tested the Phase 10 effects end-to-end and rejected the visual result: lava read as "giant disconnected circles," colors drifted to cyan (HSL arc artifact), waveform and lava read as two unrelated layers. The diagnosis was that these were structural failures of the prior model (too few scripted blobs with no physics; HSL hue-arc through cyan), not tuning misses. The reframe replaced the model with CPU-physics wax blobs + OKLab gradients, fixing both root causes.
- **Shape:** `DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs` — ~15 ms envelope-follower smoothing added at preprocessing (server-side only; no decode-time smoothing). `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — smootherstep sinusoid ribbon reconstruction; wax-blob physics loop; OKLab gradient; footer-clip; noise texture removed. `DeepDrftPublic.Client/Services/MixVisualizerControlState.cs` — widened to eight properties (`FluidAmount` + `FluidViscosity` replace `BlobDensity`; `WaveformWidth` range/default updated). `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor` — eight `RadialKnob`s, `Visible` parameter gates knob band via `@if` while container holds reserved height. `DeepDrftShared.Client/Common/DDIcons.cs``LavaLamp` glyph redrawn. Full design, acceptance criteria, and phasing: `product-notes/phase-10-mix-visualizer-lava-reframe.md`.
---
## Phase 11 — Public Site Enhancements (complete — all tracks 11.A11.H landed 2026-06-16)
### 11.H — release `EntryKey` identifiers (terminal public-site wave)
**Landed:** 2026-06-16 on dev.
- **What:** Front the release `long` PK with an app-minted GUID-string `EntryKey` column — the same pattern `TrackEntity.EntryKey` uses. `ReleaseEntity.EntryKey` is `required string`, minted as `Guid.NewGuid().ToString()` at the `FindOrCreateRelease` path; `ReleaseDto.EntryKey` mirrors it; `TrackConverter` round-trips it. The public addressing surface was re-typed from `long` to the `EntryKey` string handle: detail routes (`/cuts`, `/sessions`, `/mixes`, and the `/tracks/{id}` redirect), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path (`IReleaseDataService.GetByEntryKey`), and the public release API (`GET api/release/{entryKey}`, the mix waveform endpoint). The `releaseId` track-page query is resolved client-side from the EntryKey-loaded release and stays `long` (never enters a navigable URL). The internal `long` PK and all internal FKs (`TrackEntity.ReleaseId`, `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId`) are unchanged — DB-only, unused by the app. ApiKey-gated CMS endpoints stay on the int PK. EF migration `20260616210143_AddReleaseEntryKey` authored; **not yet applied** (Daniel-gated; must follow 11.G's `20260616035252_AddReleaseDescription` in apply order). The migration adds the `entry_key` column, backfills a unique GUID string per existing release row at migration time, then sets NOT NULL + unique index.
- **Why:** The release `long` PK was leaking into navigable public URLs (`/cuts/{long}`, `/sessions/{long}`, `/mixes/{long}`), exposing sequential internal IDs and making public addresses dependent on DB identity. An app-minted opaque GUID handle (the pattern already established by `TrackEntity.EntryKey`) decouples the public addressing surface from the storage PK, enables backfilling existing rows without a dev reset, and completes the commitment-9 scope of Phase 11.
- **Shape:** `ReleaseEntity.EntryKey` (`required string`) in `DeepDrftModels/Entities/`; `ReleaseConfiguration` adds the `entry_key` column config + unique index. `ReleaseDto.EntryKey` in `DeepDrftModels/DTOs/`. `TrackConverter` maps EntryKey on both read and write paths. `FindOrCreateRelease` (`DeepDrftData/TrackManager.cs`) mints `Guid.NewGuid().ToString()` on new-release creates. Public API route params re-typed to string EntryKey: `GET api/release/{entryKey}` + mix waveform endpoint. `IReleaseDataService.GetByEntryKey` (public read path). Detail page routes (`/cuts/{entryKey}`, `/sessions/{entryKey}`, `/mixes/{entryKey}`), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, `TrackRedirect.razor`. Migration `20260616210143_AddReleaseEntryKey` authored but not applied.
---
### 11.D — Archive filters in the URL
**Landed:** 2026-06-16 on dev.
- **What:** `ArchiveView` filter state (`q`, `medium`, `genre`) is now URL-bound via `[SupplyParameterFromQuery]`, making every filtered archive view a shareable, bookmarkable address (`/archive?q=&medium=&genre=`). Filter handlers navigate only; the seed-and-fetch reaction moved to `OnParametersSetAsync` (history-driven, §5.3 Option A). A `_loadedFilterKey` idempotency guard composed from the three-axis filter triple makes same-route query changes (debounce/chip-nav races, back/forward history) a no-op when the filter set is unchanged. The `HasActiveFilter` prerender-persistence gate is preserved: a filtered direct-load fetches its own narrowed result; a plain `/archive` visit restores the bridged first page. `medium` is parsed leniently with `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined` so a stray token degrades to All. Folded-in cleanup: `GenresView` genre-tile click was repointed from the deleted `/tracks?genre=` route to `/archive?genre=`, closing the 11.C dead-link residual — no `/tracks?genre=` references remain in the codebase.
- **Why:** Archive filters were held in component fields with no URL representation, so a filtered view had no shareable address and the browser's back button did not restore the previous filter state. URL-binding makes the filter model consistent with the TracksView `[SupplyParameterFromQuery]` pattern already in the codebase and is a prerequisite for 11.H (which re-types the addressing surface 11.D defines).
- **Shape:** `ArchiveView.razor.cs` (`DeepDrftPublic.Client/Pages/`): added three `[SupplyParameterFromQuery]` properties (`QueryParam`, `MediumParam`, `GenreParam`); added `_loadedFilterKey` string field + `ComposeFilterKey()` method; moved the seed-and-fetch reaction from `OnInitializedAsync` to `OnParametersSetAsync` with the idempotency guard; filter handlers (`OnSearchInput`, `OnMediumSelected`, `OnGenreSelected`) rewritten to call `NavigateToFilter` (navigate-only). `SeedFromQuery()` private method maps query params onto the component's filter fields with lenient enum parsing. `GenresView.razor.cs` (`DeepDrftPublic.Client/Pages/`): genre-tile click repointed to `/archive?genre=` from the former `/tracks?genre=`.
---
### 11.E — release-level Share
**Landed:** 2026-06-16 on dev.
- **What:** `SharePopover` gained a release-keyed mode alongside the existing track-keyed mode. Two new parameters: `ReleaseId` (`long?`) and `ReleaseMedium` (`ReleaseMedium`). When `ReleaseId` is set, "Copy link" copies the absolute URL formed from `ReleaseRoutes.DetailHref(id, medium)` composed against `NavigationManager.BaseUri`; the "Embed player" affordance is hidden entirely — release pages are not single-track embeds. The existing track-keyed mode (`EntryKey`, copy link + embed) is unchanged. `IsReleaseMode` is a private derived bool (`ReleaseId is not null`) that drives the branch. `CutDetail.razor`'s header Share button now passes `ReleaseId` and `ReleaseMedium` from the loaded release — unconditional, no longer gated on a track being present. Session and Mix detail headers were not touched.
- **Why:** Cuts had no shareable release-level URL — the Share button in `CutDetail` was wired to a track entry key. With the Cut detail page now the canonical address for an album, sharing should copy the album URL (`/cuts/{id}`), not a per-track URL. A single popover component serving both modes avoids duplicating clipboard/popover-chrome logic.
- **Shape:** `SharePopover.razor.cs` (`DeepDrftPublic.Client/Controls/`): added `[Parameter] public long? ReleaseId { get; set; }`, `[Parameter] public ReleaseMedium ReleaseMedium { get; set; }`, `private bool IsReleaseMode => ReleaseId is not null`, and a `LinkUrl` computed property that branches on `IsReleaseMode`. `SharePopover.razor`: embed section wrapped in `@if (!IsReleaseMode)`. `CutDetail.razor`: Share button updated to `<SharePopover ReleaseId="@release.Id" ReleaseMedium="@release.Medium" />`.
---
### 11.C — retire track-cardinal stack + normalize release cards
**Landed:** 2026-06-16 on dev.
- **What:** Deleted the entire track-cardinal stack: `TracksView.razor` + `.razor.cs` + `.css`, `TrackDetail.razor` + `.razor.cs`, `TrackCard.razor` + `.css`, `TracksGallery.razor` + `.css`, `GalleryViewMode`, and the orphaned `TracksViewModel` + `TrackDetailViewModel`. Their DI registrations were removed from `Startup.cs`. `/tracks` was cleaned from the nav index (`Pages.cs`) and the `DeepDrftHero` + `Home` CTAs were repointed from `/tracks` to `/archive`. Routes `/tracks` and `/track/{EntryKey}` are gone; the `/albums` redirect and the `/tracks/{id}` release-id redirect (`TrackRedirect.razor`) both survive. On the normalize side: `ReleaseGallery` is now the single release-card grid across all browse surfaces, generalized with an optional `HrefResolver` parameter (per-card medium routing via `ReleaseRoutes.DetailHref`) and a `SubtitleResolver` parameter (Cuts show "N tracks", others show artist). `ArchiveView` and `AlbumsView` folded their inline card markup and CSS into `ReleaseGallery` via these new parameters; Sessions and Mixes continue on the back-compat `DetailRoute` path unchanged. Known residual (not fixed): `GenresView.razor.cs` still links to the deleted `/tracks?genre=` route (left intentionally — `/genres` is out of Phase 11 scope); one orphaned `.deepdrft-track-card-link` CSS rule remains in the `DeepDrftPublic` host stylesheet.
- **Why:** 11.B removed every inbound link to the track-cardinal stack (Archive/AlbumsView cards and the player-bar title all route through `ReleaseRoutes` now), so the stack became dead code. Deleting it removes several files and two view-models from the interactive surface and prevents stale routes from being accidentally discoverable. The release-card normalization was the companion half of the commitment: Archive and Cuts had been reimplementing card markup inline rather than using the shared `ReleaseGallery`, so a new medium or a card-design tweak required edits in three places.
- **Shape:** Deleted from `DeepDrftPublic.Client/Pages/`: `TracksView.razor`, `TracksView.razor.cs`. Deleted from `DeepDrftPublic.Client/Controls/`: `TrackCard.razor`, `TrackCard.razor.css`, `TracksGallery.razor`, `TracksGallery.razor.css`, `GalleryViewMode`. Deleted from `DeepDrftPublic.Client/ViewModels/`: `TracksViewModel.cs`, `TrackDetailViewModel.cs`. `Startup.cs`: DI registrations for deleted view-models removed. `Pages.cs` (`DeepDrftPublic.Client/Layout/`): `/tracks` removed from `MenuPages`. `DeepDrftHero.razor` and `Home.razor`: CTAs repointed to `/archive`. `ReleaseGallery.razor` (`DeepDrftPublic.Client/Controls/`): new `[Parameter] public Func<ReleaseDto, string>? HrefResolver { get; set; }` and `[Parameter] public Func<ReleaseDto, string>? SubtitleResolver { get; set; }` parameters; `CardHref` private method branches on `HrefResolver` presence. `ArchiveView.razor` and `AlbumsView.razor` (or `.razor.cs`): inline card markup removed, delegated to `ReleaseGallery` with `HrefResolver` and (for Cuts) `SubtitleResolver`.
---
### 11.B — `ReleaseRoutes` resolver + repoint
**Landed:** 2026-06-16 on dev.
- **What:** New shared `DeepDrftPublic.Client/Common/ReleaseRoutes.cs` — the single source of truth for resolving a release to its dedicated detail route. `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` returns `/cuts/{id}`, `/sessions/{id}`, or `/mixes/{id}`; a convenience overload `DetailHref(ReleaseDto)` delegates to the primary. `ArchiveView`'s former private `DetailHref` switch was removed and replaced by this shared resolver. The player-bar title (`TrackMetaLabel`), Archive cards, and `AlbumsView` Cut cards all route through the shared resolver. A thin `/tracks/{id}` redirect page (`Pages/TrackRedirect.razor`) handles bare-release-id deep links: it fetches the release to discover its medium, resolves through `ReleaseRoutes.DetailHref`, and performs a history-replacing redirect — one medium→route table, no second source. The track-cardinal stack (`TrackDetail`/`TracksView`/etc.) was deliberately not touched — that is 11.C.
- **Why:** Multiple call sites (Archive, AlbumsView, player bar) each maintained their own medium→route mapping. A fourth medium or a route rename would require hunting all of them. Centralising into one static helper makes the medium→detail-page contract explicit in one place and removes the risk of call sites drifting.
- **Shape:** `ReleaseRoutes.cs` (new, `DeepDrftPublic.Client/Common/`): static class, two `DetailHref` overloads. `ArchiveView.razor`: private `DetailHref` switch removed; calls delegate to `ReleaseRoutes.DetailHref`. `TrackMetaLabel.razor` and `AlbumsView.razor.cs`: updated to call `ReleaseRoutes.DetailHref`. `TrackRedirect.razor` (new, `DeepDrftPublic.Client/Pages/`, route `/tracks/{Id:long}`): fetches release via `IReleaseDataService.GetById`, resolves through `ReleaseRoutes.DetailHref`, navigates with `replace: true`; falls back to `/cuts` on unknown id.
---
### §3.4 PlayAlbum queue seam — wired (follow-up to 11.A + 11.F)
**Landed:** 2026-06-16 on dev.
- **What:** The §3.4 integration seam between 11.A (`/cuts/{id}`) and 11.F (`IQueueService`) is now closed. `CutDetail.razor` consumes the cascaded `IQueueService`: header Play calls `Queue.PlayRelease(ViewModel.Tracks, 0)` (loads the full album as an ordered queue starting at track 0); per-row play calls `Queue.PlayRelease(ViewModel.Tracks, index)` (album continues from the chosen track). The currently-playing row still toggles play/pause via `IPlayerService.TogglePlayPause`. Null-safe fallback to `PlayerService.SelectTrackStreaming` is retained for prerender/non-interactive contexts where the queue cascade is absent. Consumption-only — no changes to `IQueueService`, `QueueService`, the player, or `AudioPlayerProvider`.
- **Why:** 11.A shipped with a documented one-line seam in `PlayAlbum()` noting the future swap to `IQueueService.PlayRelease`. 11.F landed the queue. This follow-up closes the seam so the Cut detail page actually plays the full album as an ordered queue rather than single-track only.
- **Shape:** `CutDetail.razor` (`DeepDrftPublic.Client/Pages/`) adds `[CascadingParameter] public IQueueService? Queue { get; set; }` and rewrites `PlayAlbum()` and `PlayTrack()` to branch on `Queue is not null` before falling back to direct `SelectTrackStreaming`.
---
### 11.A — `/cuts/{id}` album-detail page
**Landed:** 2026-06-16 on dev.
- **What:** New public Cut album detail page at `/cuts/{id}`. Composes `ReleaseDetailScaffold` via a generalized `Header` slot (left meta: name, artist, genre, year, Play + Share) and a `BodyContent` slot (right theme-bordered cover image; `TrackNumber`-ordered track list with per-row play). `CutDetailBase` carries the multi-track prerender bridge across the prerender→WASM seam (following the `ReleaseDetailBase` pattern); `CutDetailViewModel` holds the loaded state. Header Play and per-row play wire into the existing single-slot `IPlayerService` (`SelectTrackStreaming` / toggle). A `PlayAlbum` method contains a documented one-line seam for a future swap to `IQueueService.PlayRelease` — queue integration is a deferred follow-up, not live in this wave. Reuses the existing `GetById` release endpoint and the `releaseId`-filtered track page; no new API surface. Track ordinal (`TrackNumber`) was verified already built and consumed correctly — no new schema.
- **Why:** Cuts (Studio releases) had no single-release detail page — `/cuts` cards navigated to `/tracks?album={title}` (a track-cardinal view). This makes the album the primary navigable unit on the public site for Cut releases, completing the per-medium detail page set alongside `/sessions/{id}` and `/mixes/{id}`.
- **Shape:** New `CutDetail.razor` + `CutDetailBase.cs` + `CutDetailViewModel.cs` in `DeepDrftPublic.Client`. Composes `ReleaseDetailScaffold` with `Header` and `BodyContent` render fragments. Track list ordered by `TrackNumber`; per-row play binds to `IPlayerService` (`SelectTrackStreaming` / toggle). `PersistentComponentState` bridge is owned by `CutDetailBase` (keyed `"cut-tracks"`).
---
### 11.F — play-queue `IQueueService`
**Landed:** 2026-06-16 on dev.
- **What:** A separate `IQueueService` orchestrating album (ordered multi-track) playback above the single-slot player. Holds an ordered track list, a current index, and `Next()`/`Previous()` skip navigation wired into the player-bar controls (skip-forward gated on `HasNext`, skip-back gated on `HasPrevious`). Auto-advance via a new `IPlayerService.TrackEnded` event (raised only on organic end-of-stream): `OnTrackEnded` advances the queue only when `player.CurrentTrack.Id == queue.Current.Id` — an `Id`-equality cross-advance guard that prevents a superseding direct-play call from accidentally advancing the queue. `Attach(IStreamingPlayerService)` binds the queue to the player (called once by `AudioPlayerProvider`); loading a track list into the queue is a separate concern via `PlayRelease`. No detach-on-direct-Play mechanism. Provider-owned and cascaded — not DI-registered, by design. Surface members: `Items`, `CurrentIndex`, `Current`, `HasNext`, `HasPrevious`, `QueueChanged` event; methods `Attach(IStreamingPlayerService)`, `PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)`, `Next()`, `Previous()`, `Enqueue`, `EnqueueRange`, `Clear`.
- **Why:** The player was single-slot only. The Cut album detail page (11.A) needs "play album" — an ordered queue that advances through tracks end-to-end. Absorbs the queue half of Phase 1 §1.3 (the preload half remains deferred). Prerequisite for a future `PlayAlbum` integration in 11.A; also exposes skip controls in the player bar.
- **Shape:** New `IQueueService` interface + `QueueService` implementation in `DeepDrftPublic.Client`. `IPlayerService` gains `TrackEnded` event. Player bar gains skip-forward and skip-back controls bound to `IQueueService.Next()`/`Previous()`, gated on `HasNext`/`HasPrevious`. `Attach(IStreamingPlayerService)` wires the queue to the player without constructor growth; `PlayRelease(IEnumerable<TrackDto>, int)` loads an ordered track list and starts playback.
---
### 11.G — release Description schema slice
**Landed:** 2026-06-16 on dev.
- **What:** New nullable `ReleaseEntity.Description` column (plain text, max 4000 characters) on the base release table, mirrored in `ReleaseDto.Description`. `TrackConverter` round-trip updated. Write-path plumbing threaded wherever `Genre` is: `UpdateTrackMetadataRequest` + upload form fields + `UnifiedTrackService` + `TrackManager` update path. CMS `AlbumHeaderFields` gains a multiline `MudTextField` for Description input. Detail-page rendering deliberately deferred — Description degrades cleanly (null renders nothing) so schema and render can land in either order. EF migration `20260616035252_AddReleaseDescription` authored; **not yet applied** (Daniel-gated).
- **Why:** Commitment 8 from the Phase 11 spec. No `Description` member existed on `ReleaseEntity` or `ReleaseDto` prior to this wave. A base-release free-text field (uniform across all media) lets admins describe a release context, inspiration, or credits. Lives on the base release, not a per-medium satellite (consistent with Phase 9's open/closed spine).
- **Shape:** `ReleaseEntity.Description` nullable string in `DeepDrftData`. EF `ReleaseConfiguration` adds max-length annotation (4000). `ReleaseDto.Description` nullable string. `TrackConverter` updated to map the field on both read and write paths. `UpdateTrackMetadataRequest` gains `Description` field. Upload form (multipart) gains `description` form field. `AlbumHeaderFields.razor` gains a multiline `MudTextField`. Migration `20260616035252_AddReleaseDescription` authored but not applied.
---
## Phase 10 — Mix detail Hero + MetaContent overlay (presentation only)
**Landed:** 2026-06-16 on dev.
- **What:** Extracted a shared **`ReleaseHeroOverlay`** presentational component (`DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` + `.razor.css`) that both Session detail and Mix detail now consume — one source of truth for the background-image hero with all metadata overlaid (genre/date + share top row; cover-thumb/title/artist + play bottom row). Mix detail's hero is now an overlaid ~600px square cover, replacing the stacked masthead + 220px cover + meta-divider block, freeing more canvas for the lava-lamp visualizer. The Phase 10 reframe top row (`TopRowCenter` controls + lava-lamp `TopRightAction`) is preserved unchanged. `ReleaseDetailScaffold` gained a `bool ShowHeader = true` gate (slot-consistent with `ShowMeta`/`ShowShareRow`) to suppress the duplicate masthead for Mix. The background-image surface is a plain `<div class="release-hero-img">` (no `MudPaper`).
- **Why:** The Mix detail page carried a stacked masthead + 220px cover + meta-divider block that kept the overlay aesthetic of Sessions from applying and wasted vertical canvas the lava-lamp visualizer could use. Extracting `ReleaseHeroOverlay` delivered the DRY win (one overlay, no duplication) and brought Mix into the same design family as Sessions, while the `ShowHeader` gate gave the scaffold a clean suppression mechanism rather than an empty-fragment hack.
- **Shape:** New `DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` (+ `.razor.css`) — the shared overlay, parameterized for `HeroImageKey`, `PlaceholderIcon`, `CoverThumbKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `ShareContent` slot, `PlayContent` slot, `Class`. `SessionDetail.razor` — inline hero-overlay replaced by `<ReleaseHeroOverlay ... />`; behavior-preserving lift. `SessionDetail.razor.css` — overlay cascade moved to the shared component; page-specific rules remain. `MixDetail.razor` — old `.mix-detail-cover` `Hero` slot replaced with `<ReleaseHeroOverlay Class="mix-hero" ... />` in the scaffold's `Hero` slot; `MetaContent` dropped (metadata now in the overlay); share row moved into the overlay's `ShareContent` slot; scaffold used with `ShowHeader="false"`. `MixDetail.razor.css``mix-hero` square/medium sizing override added; `.mix-detail-cover` removed. `ReleaseDetailScaffold.razor``bool ShowHeader = true` gate added around the default header region.
Full design, DRY trade-offs, acceptance criteria, and the open questions resolved during implementation: `product-notes/mix-detail-hero-overlay.md`.
---
## CMS Grid Refinements
### `CmsAlbumBrowser` special-action column promotion
**Landed:** 2026-06-15 on branch `cms-special-action-columns`.
Follow-on refinement of 8.C: the `RenderFragment<ReleaseDto>? RowActions` slot that 8.C introduced into `CmsAlbumBrowser` was replaced by a dedicated, header-labelled column model so that medium-specific actions (Mix waveform, Session hero) each appear in their own named grid column rather than being merged into the shared Actions cell.
- **What:** `CmsAlbumBrowser.razor` removed the `[Parameter] public RenderFragment<ReleaseDto>? RowActions { get; set; }` slot. In its place: `[Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; }` (defaulting to `Array.Empty<SpecialActionColumn>()`). `SpecialActionColumn` is a new `sealed record` (`string Header`, `RenderFragment<ReleaseDto> Cell`) in `DeepDrftManager/Components/Pages/Tracks/SpecialActionColumn.cs`. The grid renders one dedicated `<MudTh>` per declared column (between the Tracks header and the Actions header) and one `<MudTd>` per row per column. Child-row colspan moves from the hardcoded `9` to a computed `ColumnCount` property (`private const int BaseColumnCount = 9; private int ColumnCount => BaseColumnCount + SpecialColumns.Count`).
- **Why:** Merging a per-medium affordance into the generic Actions cell forced the admin to parse mixed content in a single column. Promoting each to its own labelled column gives the grid a discoverable header for every action kind and makes it obvious at a glance which column is the Waveform column vs. the Actions column.
- **Shape:** `CmsMixBrowser` declares one column: `new SpecialActionColumn("Waveform", WaveformCell)` — the Mix waveform generate/regenerate button with status icon. `CmsSessionBrowser` declares one column: `new SpecialActionColumn("Hero", HeroCell)` — the Session hero thumbnail preview plus set/replace upload button. `CmsCutBrowser` and the ALL-releases grid (`CmsAllReleasesGrid`) declare none; their column count and rendering are unchanged. Both callers allocate `_specialColumns` once in `OnInitialized` (field initializers cannot reference instance members; initialization is deferred to the first lifecycle hook). No change to `CmsMediumBrowserBase.cs`, `TrackList.razor`, or any other file.
**Completion note:** `CmsAlbumBrowser.razor``RowActions` parameter removed; `SpecialColumns` parameter added; `BaseColumnCount = 9` constant + `ColumnCount` computed property added; header and row loops updated to `foreach (var column in SpecialColumns)`. `SpecialActionColumn.cs` (new file, `DeepDrftManager/Components/Pages/Tracks/`): `public sealed record SpecialActionColumn(string Header, RenderFragment<ReleaseDto> Cell)`. `CmsMixBrowser.razor``RowActions` fragment replaced with `_specialColumns` field (allocated in `OnInitialized`) passed via `SpecialColumns="_specialColumns"`. `CmsSessionBrowser.razor` — same pattern. `CmsCutBrowser.razor` and `CmsAllReleasesGrid.razor` — no change (declare no special columns). No automated tests (no bUnit harness in DeepDrftTests; consistent with all prior Wave 8 / post-Phase-9 CMS tracks).
---
## Phase 9 — Release Medium Types
### 9.7 Wave 7 — Domain Invariant Hardening: per-medium track cardinality
@@ -34,11 +168,11 @@ The single-track-per-release rule for Session/Mix is enforced only in the CMS fo
---
### 9.8 Wave 8 — Remediation (Phase-9-completion gate met: 8.A8.J + 8.L landed; 8.K post-Phase-9, 8.M follow-on pending)
### 9.8 Wave 8 — Remediation (fully landed: 8.A8.J + 8.L, 8.M, 8.K)
**Landed:** 2026-06-13 on dev (eleven tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L).
**Landed:** 2026-06-13 on dev (eleven tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L); 8.M on 2026-06-14; 8.K on 2026-06-14.
Daniel tested the landed Phase 9 surface (Waves 17) and produced a punch-list. Wave 8 is remediation — the gap between what the specs *built* and what hands-on use *wants*. Full design, acceptance criteria, and dependencies: `product-notes/phase-9-wave-8-remediation.md`. The wave spans CMS, public site, and label polish. The Phase-9-completion gate (8.A8.J + 8.L) is now fully met; 8.K (Mix Visualizer) is pulled out of Phase-9-completion scope (post-Phase-9 wave, design-complete); 8.M (legacy-form retirement) is a code-surface-reduction follow-on that trails.
Daniel tested the landed Phase 9 surface (Waves 17) and produced a punch-list. Wave 8 is remediation — the gap between what the specs *built* and what hands-on use *wants*. Full design, acceptance criteria, and dependencies: `product-notes/phase-9-wave-8-remediation.md`. The wave spans CMS, public site, and label polish. The Phase-9-completion gate (8.A8.J + 8.L) was met on 2026-06-13; 8.M (legacy-form retirement follow-on) landed 2026-06-14; 8.K (Mix Visualizer redesign, post-Phase-9 wave, designed-complete before Phase 9 closed) landed 2026-06-14. Wave 8 is fully complete.
**8.A — Release Archive as medium tabs, not cards**
@@ -156,6 +290,26 @@ Daniel tested the landed Phase 9 surface (Waves 17) and produced a punch-list
---
**8.M — Retire the legacy single-track CMS forms**
- **What:** Retire `TrackNew` (`/tracks/new`) and `TrackEdit` (`/tracks/{Id:long}`) as standalone authoring forms in `DeepDrftManager`. Their responsibility is absorbed by `BatchUpload` / `BatchEdit`'s single-track branch.
- **Why:** The legacy forms were a duplicate code surface. Folding their function into the batch forms reduces form surface and removes the addressing-model gap that existed between `TrackEdit` (addressed by track id) and `BatchEdit` (addressed by release title). Daniel's decision: "consolidate the forms and reduce the code surface" (2026-06-13).
- **Shape:** **Option 2 (Daniel's decision):** `BatchEdit` gained a track-addressed route `/tracks/{TrackId:long}/edit` that resolves the track to its parent release via `GetByIdAsync`, loads the release through the existing release-load path, and pre-selects the addressed track's row (`ResolveInitialSelection` matches by `Id`, falls back to row 0). The existing release-title route (`/tracks/album/{AlbumName}/edit`) is untouched. The two legacy components were reduced to thin redirect shims (not hard-deleted, to guard bookmarks): `/tracks/new``/tracks/upload`; `/tracks/{Id}``/tracks/{Id}/edit`. `CmsTrackGrid`'s per-row Edit now targets `/tracks/{id}/edit`. Files changed (6): `BatchEdit.razor`, `BatchUpload.razor`, `CmsTrackGrid.razor`, `SessionFields.razor`, `TrackEdit.razor`, `TrackNew.razor`. No new component, no public-site change, no constructor growth, no `IServiceProvider`.
**Completion note:** `BatchEdit.razor` gained a second `@page` route `/tracks/{TrackId:long}/edit`; `OnInitializedAsync` uses `GetByIdAsync` when `TrackId` is set, resolves the parent release, loads through the existing release-load path, and calls `ResolveInitialSelection` which matches by `Id` (falls back to row 0) to pre-select the addressed track's row. The existing `/tracks/album/{AlbumName}/edit` route and its load path are untouched. `TrackEdit.razor` and `TrackNew.razor` were each reduced to thin redirect shims — `TrackNew` redirects `/tracks/new``/tracks/upload`; `TrackEdit` redirects `/tracks/{Id}``/tracks/{Id}/edit` — preserving inbound bookmarks without keeping dead form logic. `CmsTrackGrid.razor` per-row Edit link updated from `/tracks/{id}` to `/tracks/{id}/edit`. `SessionFields.razor` and `BatchUpload.razor` received minor coordinating edits. Build clean. No automated tests (DeepDrftTests has no bUnit harness / no DeepDrftManager reference — consistent with prior Wave 8 tracks). All acceptance criteria met; legacy `TrackNew`/`TrackEdit` authoring forms retired; track-addressed edit route live on `BatchEdit`.
---
**8.K — Mix Visualizer redesign (post-Phase-9 wave)**
- **What:** Replace the static SVG waveform silhouette on the Mix detail page with a windowed, playback-coupled, bottom-to-top scrolling Canvas 2D animation; simultaneously switch Mix loudness datum capture from a fixed 2048-bucket count to a duration-derived constant-time-resolution scheme. Strictly read-only (no seek seam); theme-aware glassy gradient aesthetic (lava-lamp idiom, MudBlazor palette, live dark-mode responsive).
- **Why:** The static SVG silhouette did not communicate playback progress or the shape of the material at any useful zoom level. Long mixes were under-sampled at fixed 2048 buckets — the visualizer design called for ~333 samples/sec so max-zoom detail is legible. The redesign gives Mix detail pages their signature dynamic visual and makes the waveform datum meaningfully dense.
- **Shape:** Two waves. Wave 1 (datum §F): bucket count becomes `ceil(durationSeconds × 333)`, clamped `[2048, 2_000_000]`; pure helper `MixWaveformResolution.cs` (`BucketCountForDuration`, named constants `SamplesPerSecond`/`MinBucketCount`/`MaxBucketCount`); `UnifiedReleaseService.TriggerMixWaveformAsync` derives the count from `audio.Duration`; fixed `MixWaveformBucketCount = 2048` constant removed. Single high-density datum (not tiered/mipmap — Daniel's decision). Backward-compatible: existing 2048-bucket mixes still render coarsely; re-running the Generate trigger re-captures at new density. Wave 2 (renderer §A/B/C/D/E): `MixWaveformVisualizer` rewritten from static SVG to Canvas 2D scrolling animation driven by a `requestAnimationFrame` loop in new TS interop module `MixVisualizer.ts` (`DeepDrftPublic/Interop/visualizer/`). Guitar-Hero zoom coupling anchored at 0.333 s (1 quarter note @ 180 BPM max-zoom), range 0.333 s → 30 s, default-open 10 s. rAF loop gated on is-playing (idle on pause; one-shot redraws on zoom/theme/datum/resize while idle). Sample↔time mapping uses the DTO's `BucketCount` and the mix duration (sourced from the cascaded player, gated to the mix's `TrackId`) — no fixed-2048 assumption. New `MixZoomMapping.cs` (pure log-scaled zoom↔seconds) and `MixVisualizerZoomState.cs` (scoped, session-persistent, resets on fresh load, registered in `Startup.cs`); `MixDetail.razor` passes `TrackId`. Inert `OnSeek` + two-way `PlaybackPosition` seam dropped; `PlaybackPosition` is one-way input; `ReleaseId` self-fetches the datum. No `@rendermode` override, no constructor growth, no `IServiceProvider`; component CSS scoped.
**Completion note:** Wave 1 landed: `DeepDrftContent/Processors/MixWaveformResolution.cs` (new, pure helper with `BucketCountForDuration`, `SamplesPerSecond = 333`, `MinBucketCount = 2048`, `MaxBucketCount = 2_000_000`); `UnifiedReleaseService.TriggerMixWaveformAsync` derives bucket count from `audio.Duration` via the new helper; fixed `MixWaveformBucketCount = 2048` constant removed. `WaveformProfileDto.BucketCount` now varies per-mix. 8 unit tests in `MixWaveformResolutionTests.cs`. Wave 2 landed: `MixWaveformVisualizer` rewritten as a Canvas 2D scrolling component; `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` (new TS module) owns canvas, datum decode, rAF loop, scroll/zoom/compositing math, and dark-mode responsive theming. `MixZoomMapping.cs` and `MixVisualizerZoomState.cs` (new); zoom state registered as scoped in `Startup.cs`; `MixDetail.razor` passes `TrackId`. Two-way `PlaybackPosition` binding dropped; one-way input only. No automated tests for Wave 2 (DeepDrftTests references DeepDrftContent/DeepDrftData, not DeepDrftPublic.Client — consistent with prior public-site UI waves). Design spec: `product-notes/phase-9-mix-visualizer-redesign.md`. All acceptance criteria met; Wave 8 track 8.K completes the Mix Visualizer redesign and closes Wave 8 in full.
---
### 9.6 Wave 6 — Gap Closure
**Landed:** 2026-06-13 on dev.
+8 -8
View File
@@ -150,7 +150,7 @@ Paged metadata list from SQL with optional filtering. Public browser data, same
- **Query parameters**:
- `page` (int, optional, default 1): 1-based page number.
- `pageSize` (int, optional, default 20): tracks per page.
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`.
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`, `"TrackNumber"`. Defaults to `Id`.
- `sortDescending` (bool, optional, default false): sort direction.
- `q` (string, optional): search text filter (matches track name / artist).
- `album` (string, optional): album title filter.
@@ -220,19 +220,19 @@ Paged release list, optionally filtered to one medium. Public browse data, same
- `sortDescending` (bool, optional, default false): sort direction.
- Returns 200 with `PagedResult<ReleaseDto>` on success. Returns 400 if `medium` is unrecognized. Returns 500 on query error.
### GET api/release/{id:long} (unauthenticated)
### GET api/release/{entryKey} (unauthenticated)
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`.
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`. Addresses releases by their opaque public `EntryKey` (GUID string), never the int PK (Phase 11 §3e).
- **Route parameter `id`** (long): the SQL release ID.
- **Response**: `ReleaseDto` with `Id`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
- **Route parameter `entryKey`** (string): the release's `EntryKey` (the public handle).
- **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/{id:long}/mix/waveform (unauthenticated)
### GET api/release/{entryKey}/mix/waveform (unauthenticated)
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.
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).
- **Route parameter `id`** (long): the SQL release ID.
- **Route parameter `entryKey`** (string): the release's `EntryKey`.
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
- Returns 200 on success. Returns 404 if the release is not a Mix, carries no waveform key, or no datum is stored. Returns 500 on query/vault error.
+18 -15
View File
@@ -66,18 +66,20 @@ public class ReleaseController : ControllerBase
return Ok(result.Value);
}
// GET api/release/{id}/mix/waveform (unauthenticated)
// 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. Declared before the shorter "{id:long}" route for clarity.
[HttpGet("{id:long}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(long id, CancellationToken ct = default)
// 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.
[HttpGet("{entryKey}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
{
var lookup = await _releaseService.GetByIdAsync(id, ct);
var lookup = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetMixWaveform lookup failed for {ReleaseId}: {Error}", id, error);
_logger.LogError("GetMixWaveform lookup failed for {EntryKey}: {Error}", entryKey, error);
return StatusCode(500, "Failed to load release");
}
@@ -85,14 +87,14 @@ public class ReleaseController : ControllerBase
var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey;
if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey))
{
_logger.LogInformation("No mix waveform datum for release: {ReleaseId}", id);
_logger.LogInformation("No mix waveform datum for release: {EntryKey}", entryKey);
return NotFound();
}
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
if (bytes is null)
{
_logger.LogInformation("Mix waveform key set but no datum stored for release: {ReleaseId}", id);
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
return NotFound();
}
@@ -159,17 +161,18 @@ public class ReleaseController : ControllerBase
return StatusCode(500, error);
}
// GET api/release/{id} (unauthenticated)
// Single release with both metadata navs (nulls for non-matching media). Declared after the longer
// "{id:long}/mix/waveform" routes so the segmented routes resolve first.
[HttpGet("{id:long}")]
public async Task<ActionResult> GetReleaseById(long id, CancellationToken ct = default)
// GET api/release/{entryKey} (unauthenticated)
// Single release with both metadata navs (nulls for non-matching media). Public read — addresses by
// the opaque EntryKey, not the int PK (§3e). Declared after the longer "{entryKey}/mix/waveform"
// route so the segmented route resolves first.
[HttpGet("{entryKey}")]
public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
{
var result = await _releaseService.GetByIdAsync(id, ct);
var result = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetReleaseById failed for {ReleaseId}: {Error}", id, error);
_logger.LogError("GetReleaseByEntryKey failed for {EntryKey}: {Error}", entryKey, error);
return StatusCode(500, "Failed to load release");
}
@@ -189,6 +189,7 @@ public class TrackController : ControllerBase
[FromForm] string? artist,
[FromForm] string? album,
[FromForm] string? genre,
[FromForm] string? description,
[FromForm] string? releaseDate,
[FromForm] string? originalFileName,
[FromForm] long createdByUserId,
@@ -283,6 +284,7 @@ public class TrackController : ControllerBase
artist,
string.IsNullOrWhiteSpace(album) ? null : album,
string.IsNullOrWhiteSpace(genre) ? null : genre,
string.IsNullOrWhiteSpace(description) ? null : description,
parsedReleaseDate,
createdByUserId,
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
@@ -412,6 +414,7 @@ public class TrackController : ControllerBase
release.Artist = request.Artist;
release.Title = request.Album ?? string.Empty;
release.Genre = request.Genre;
release.Description = request.Description;
release.ReleaseDate = request.ReleaseDate;
// ImagePath is tri-state: null = no change, "" = clear, value = set.
@@ -16,6 +16,7 @@ public record UpdateTrackMetadataRequest(
string Artist,
string? Album,
string? Genre,
string? Description,
DateOnly? ReleaseDate,
string? ImagePath = null,
ReleaseType? ReleaseType = null,
@@ -17,10 +17,6 @@ namespace DeepDrftAPI.Services;
/// </summary>
public class UnifiedReleaseService
{
// High-res bucket count for Mix waveforms — 4x the player-bar default (512), feeding the
// public-site MixWaveformVisualizer.
private const int MixWaveformBucketCount = 2048;
/// <summary>Error message returned when the Mix release has no linked track.</summary>
public const string MixHasNoTrackMessage = "Mix release has no track.";
@@ -104,9 +100,11 @@ public class UnifiedReleaseService
}
/// <summary>
/// Fetch the Mix's track audio from the vault, compute a high-res (2048-bucket) waveform datum,
/// store it in the MixWaveforms 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.
/// 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
/// 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.
/// </summary>
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
{
@@ -149,8 +147,11 @@ public class UnifiedReleaseService
return Result.CreateFailResult(MixTrackNoAudioMessage);
}
// 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, MixWaveformBucketCount, VaultConstants.MixWaveforms);
audio.Buffer, entryKey, bucketCount, VaultConstants.MixWaveforms);
if (!computed)
{
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
+4 -2
View File
@@ -57,6 +57,7 @@ public class UnifiedTrackService
string artist,
string? album,
string? genre,
string? description,
DateOnly? releaseDate,
long createdByUserId,
string? originalFileName,
@@ -106,8 +107,8 @@ public class UnifiedTrackService
// Resolve the release FK before persisting the track. An upload with an album lands on the
// shared release (created on first sighting); an upload without one stays a loose track with
// a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides
// on the release, not the track.
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
// rides on the release, not the track.
long? releaseId = null;
if (!string.IsNullOrWhiteSpace(album))
{
@@ -116,6 +117,7 @@ public class UnifiedTrackService
Title = album,
Artist = artist,
Genre = genre,
Description = description,
ReleaseDate = releaseDate,
ReleaseType = releaseType,
Medium = medium,
@@ -0,0 +1,52 @@
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.
///
/// 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
/// 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
{
/// <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
/// 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.
/// </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.
/// </summary>
public static int BucketCountForDuration(double durationSeconds)
{
if (double.IsNaN(durationSeconds) || durationSeconds <= 0)
return MinBucketCount;
// Guard against overflow before the cast: anything at/above the cap clamps anyway.
var raw = Math.Ceiling(durationSeconds * SamplesPerSecond);
if (raw >= MaxBucketCount)
return MaxBucketCount;
var buckets = (int)raw;
return buckets < MinBucketCount ? MinBucketCount : buckets;
}
}
@@ -3,11 +3,21 @@ namespace DeepDrftContent.Processors;
/// <summary>
/// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned,
/// 16/24/32-bit signed little-endian), averages channels to mono, partitions the frames into
/// equal time slices, takes the RMS of each slice, then peak-normalizes so the loudest bucket is 1.
/// No external audio dependency — operates directly on the WAV data-chunk bytes.
/// equal time slices, takes the RMS of each slice, applies a ~15 ms envelope-follower smoothing
/// so the contour reads as a smooth curve rather than a spikey polygon, then peak-normalizes so
/// the loudest bucket is 1. No external audio dependency — operates directly on the WAV data-chunk bytes.
/// </summary>
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
{
/// <summary>
/// Envelope-follower time constant, seconds. ~15 ms is the smoothing target (Phase 10
/// tuning, reduced from 50 ms which was over-smoothed): long enough to round off the
/// per-bucket RMS spikes into a smooth ribbon contour, short enough that real loudness
/// transients (kicks, drops) still read. Applied as a symmetric (forward+backward) one-pole
/// filter so the smoothing introduces no time lag.
/// </summary>
public const double SmoothingTimeConstantSeconds = 0.005;
public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount)
{
if (bucketCount <= 0)
@@ -64,16 +74,28 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
counts[bucket]++;
}
var peak = 0.0;
for (var i = 0; i < bucketCount; i++)
{
if (counts[i] > 0)
{
result[i] = Math.Sqrt(sumSquares[i] / counts[i]);
if (result[i] > peak)
{
peak = result[i];
}
}
}
// Envelope smoothing (~15 ms): round the spikey per-bucket RMS into a smooth contour before
// peak-normalization, so the rendered ribbon reads as a continuous curve, not faceted polygons.
// Each bucket spans (totalSeconds / bucketCount) of audio; the filter coefficient is derived
// from that against the time constant so the smoothing is duration-aware, not a fixed window.
var totalSeconds = (double)frameCount / sampleRate;
var bucketSeconds = totalSeconds / bucketCount;
SmoothEnvelope(result, bucketSeconds);
var peak = 0.0;
for (var i = 0; i < bucketCount; i++)
{
if (result[i] > peak)
{
peak = result[i];
}
}
@@ -92,6 +114,42 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
return result;
}
/// <summary>
/// Symmetric one-pole envelope smoothing over the per-bucket loudness, in place. A forward pass
/// then a backward pass cancels the single-pole phase lag, so the smoothed contour stays aligned
/// with the audio (no rightward time shift). The coefficient <c>a = exp(bucketSeconds / τ)</c>
/// gives a ~<paramref name="bucketSeconds"/>-relative response targeting the ~15 ms time constant:
/// each bucket blends <c>(1 a)</c> of itself with <c>a</c> of the running envelope. A near-zero
/// or non-finite bucket duration leaves the data untouched (nothing to smooth meaningfully).
/// </summary>
private static void SmoothEnvelope(double[] data, double bucketSeconds)
{
if (data.Length < 2 || bucketSeconds <= 0 || !double.IsFinite(bucketSeconds))
{
return;
}
var a = Math.Exp(-bucketSeconds / SmoothingTimeConstantSeconds);
// a→1 means buckets are far finer than τ (heavy smoothing); a→0 means each bucket already
// spans ≫ τ, so smoothing is a no-op. Either extreme is handled by the blend below.
// Forward pass.
var env = data[0];
for (var i = 0; i < data.Length; i++)
{
env = a * env + (1 - a) * data[i];
data[i] = env;
}
// Backward pass (zero-phase): smooth the forward result in reverse so the net lag is zero.
env = data[^1];
for (var i = data.Length - 1; i >= 0; i--)
{
env = a * env + (1 - a) * data[i];
data[i] = env;
}
}
/// <summary>
/// Decodes one PCM sample at <paramref name="offset"/> to a normalized amplitude in [-1, 1].
/// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian.
@@ -42,7 +42,10 @@ 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;
/// pass a higher value (e.g., 2048) for the Mix high-res datum. Returns false (and logs) on any
/// 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
/// 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.
/// </summary>
@@ -21,6 +21,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
// App-minted GUID-string public handle, configured exactly like TrackConfiguration's
// entry_key: required, max 100, snake_case column. The unique index guarantees a release
// resolves to one row by its public key.
builder.Property(e => e.EntryKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("entry_key");
builder.HasIndex(e => e.EntryKey)
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
builder.Property(e => e.Title)
.IsRequired()
.HasMaxLength(200)
@@ -35,6 +47,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasMaxLength(100)
.HasColumnName("genre");
// Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration).
builder.Property(e => e.Description)
.HasMaxLength(4000)
.HasColumnName("description");
builder.Property(e => e.ReleaseDate)
.HasColumnName("release_date");
+3
View File
@@ -20,6 +20,9 @@ public interface IReleaseService
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>The public addressing read: single release resolved by its opaque EntryKey (Phase 11 §3e). Both metadata navs included (nulls for non-matching media).</summary>
Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default);
/// <summary>Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut.</summary>
Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default);
@@ -0,0 +1,308 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260616035252_AddReleaseDescription")]
partial class AddReleaseDescription
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "description",
table: "release",
type: "character varying(4000)",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "description",
table: "release");
}
}
}
@@ -0,0 +1,318 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260616210143_AddReleaseEntryKey")]
partial class AddReleaseEntryKey
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseEntryKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 11.H — front the int PK with an app-minted GUID-string EntryKey (Phase 11 §3e). The
// scaffolded single non-null-with-"" add is hand-edited into the three-step backfill the
// spec requires (§3e.5(2)): existing rows must each get a UNIQUE, non-null key, so a shared
// "" default would collide on the unique index. Add nullable → backfill a GUID per row →
// mark non-null. No DB default is set on the final column: new rows are app-populated by
// FindOrCreateRelease (Guid.NewGuid().ToString()), exactly as tracks mint their EntryKey.
// 1) Add the column nullable so the backfill can run before the NOT NULL constraint.
migrationBuilder.AddColumn<string>(
name: "entry_key",
table: "release",
type: "character varying(100)",
maxLength: 100,
nullable: true);
// 2) Backfill a unique GUID string per existing row. gen_random_uuid()::text yields the
// lowercase 36-char hyphenated shape Guid.NewGuid().ToString() produces, so migrated and
// app-minted keys are indistinguishable. Per-row evaluation → each row gets a distinct key.
migrationBuilder.Sql(
"UPDATE \"release\" SET \"entry_key\" = gen_random_uuid()::text WHERE \"entry_key\" IS NULL;");
// 3) Now every row is populated and unique — enforce NOT NULL.
migrationBuilder.AlterColumn<string>(
name: "entry_key",
table: "release",
type: "character varying(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100,
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_release_entry_key",
table: "release",
column: "entry_key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_release_entry_key",
table: "release");
migrationBuilder.DropColumn(
name: "entry_key",
table: "release");
}
}
}
@@ -90,6 +90,17 @@ namespace DeepDrftData.Migrations
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
@@ -138,6 +149,10 @@ namespace DeepDrftData.Migrations
b.HasKey("Id");
b.HasIndex("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
+14
View File
@@ -94,6 +94,20 @@ public class ReleaseManager : IReleaseService
}
}
public async Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default)
{
try
{
var entity = await _repository.GetByEntryKeyWithMetadataAsync(entryKey, cancellationToken);
return ResultContainer<ReleaseDto?>.CreatePassResult(
entity is null ? null : TrackConverter.Convert(entity));
}
catch (Exception e)
{
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default)
{
try
@@ -95,6 +95,16 @@ public class ReleaseRepository
.Include(r => r.MixMetadata)
.FirstOrDefaultAsync(ct);
// The public addressing read: resolve a release by its opaque EntryKey (Phase 11 §3e). Mirrors
// GetByIdWithMetadataAsync but keys on the unique entry_key column — the int PK never reaches the
// public surface. The resolved entity still carries its int Id for internal joins (track page).
public async Task<ReleaseEntity?> GetByEntryKeyWithMetadataAsync(string entryKey, CancellationToken ct)
=> await _context.Releases
.Where(r => r.EntryKey == entryKey && !r.IsDeleted)
.Include(r => r.SessionMetadata)
.Include(r => r.MixMetadata)
.FirstOrDefaultAsync(ct);
// Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on
// list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary.
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
+7
View File
@@ -19,11 +19,13 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
public static ReleaseDto Convert(ReleaseEntity entity) => new()
{
Id = entity.Id,
EntryKey = entity.EntryKey,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
Title = entity.Title,
Artist = entity.Artist,
Genre = entity.Genre,
Description = entity.Description,
ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath,
Medium = entity.Medium,
@@ -50,11 +52,16 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
public static ReleaseEntity Convert(ReleaseDto dto) => new()
{
Id = dto.Id,
// Round-trips the public handle. On the create path (FindOrCreateRelease) the DTO carries no
// EntryKey yet, so that path overrides this with a freshly minted GUID — the same shape as the
// natural-key (Title/Artist) override there.
EntryKey = dto.EntryKey,
CreatedAt = dto.CreatedAt,
UpdatedAt = dto.UpdatedAt,
Title = dto.Title,
Artist = dto.Artist,
Genre = dto.Genre,
Description = dto.Description,
ReleaseDate = dto.ReleaseDate,
ImagePath = dto.ImagePath,
Medium = dto.Medium,
+4
View File
@@ -177,6 +177,9 @@ public class TrackManager
// in releaseData so a typo upstream cannot create a release that won't be found again.
var entity = TrackConverter.Convert(releaseData);
entity.Id = 0;
// Mint the public EntryKey app-side at creation — the identical call tracks make in
// TrackContentService (Phase 11 §3e.4). The incoming DTO carries no key on the create path.
entity.EntryKey = Guid.NewGuid().ToString();
entity.Title = title;
entity.Artist = artist;
@@ -281,6 +284,7 @@ public class TrackManager
releaseEntity.Title = release.Title;
releaseEntity.Artist = release.Artist;
releaseEntity.Genre = release.Genre;
releaseEntity.Description = release.Description;
releaseEntity.ReleaseDate = release.ReleaseDate;
releaseEntity.ImagePath = release.ImagePath;
releaseEntity.Medium = release.Medium;
@@ -22,6 +22,11 @@
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12">
<MudTextField Value="Description" ValueChanged="@((string v) => DescriptionChanged.InvokeAsync(v))"
T="string" Label="Description" Lines="4" MaxLength="4000"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12" sm="6">
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@@ -79,6 +84,8 @@
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
[Parameter] public string Genre { get; set; } = string.Empty;
[Parameter] public EventCallback<string> GenreChanged { get; set; }
[Parameter] public string Description { get; set; } = string.Empty;
[Parameter] public EventCallback<string> DescriptionChanged { get; set; }
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
@@ -1,4 +1,5 @@
@page "/tracks/album/{AlbumName}/edit"
@page "/tracks/{TrackId:long}/edit"
@using System.Security.Claims
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@@ -32,6 +33,7 @@
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
@@ -111,8 +113,15 @@
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
private const long MaxUploadBytes = 1_073_741_824L;
// Release-title addressing (Album-mode batch Edit): loads the whole release by title.
[Parameter] public string AlbumName { get; set; } = string.Empty;
// Track-id addressing (Track-mode per-row Edit, §8.M): loads the addressed track's parent
// release and pre-selects that track's row, so editing a single Cut track lands the admin on
// the track they clicked rather than on the release with no row context. Null for the
// release-title route. The two routes are mutually exclusive — only one segment binds.
[Parameter] public long? TrackId { get; set; }
private List<BatchRowModel> _tracks = new();
private int _selectedIndex = -1;
private bool _loading = true;
@@ -129,6 +138,7 @@
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
@@ -154,12 +164,35 @@
protected override async Task OnInitializedAsync()
{
// Track-addressed entry (§8.M): resolve the addressed track to its parent release title,
// then fall through to the shared release-load path below. The clicked track's id is held
// for row pre-selection once the list is built.
var albumName = AlbumName;
if (TrackId is { } trackId)
{
var trackResult = await CmsTrackService.GetByIdAsync(trackId);
if (!trackResult.Success || trackResult.Value is not { } track)
{
_loadError = trackResult.Messages.FirstOrDefault()?.Message ?? "Track not found.";
_loading = false;
return;
}
albumName = track.Release?.Title;
if (string.IsNullOrEmpty(albumName))
{
_loadError = "This track has no parent release to edit.";
_loading = false;
return;
}
}
// A single page of 100 covers the full release (albums are small — same assumption as
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
var result = await CmsTrackService.GetPagedAsync(
page: 1, pageSize: 100,
sortColumn: "TrackNumber", sortDescending: false,
album: AlbumName);
album: albumName);
if (!result.Success || result.Value is null)
{
@@ -171,15 +204,16 @@
var tracks = result.Value.Items.ToList();
if (tracks.Count == 0)
{
_loadError = $"No tracks found for release '{AlbumName}'.";
_loadError = $"No tracks found for release '{albumName}'.";
_loading = false;
return;
}
var release = tracks[0].Release;
_albumName = AlbumName;
_albumName = albumName;
_artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty;
_description = release?.Description ?? string.Empty;
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
_medium = release?.Medium ?? ReleaseMedium.Cut;
@@ -203,10 +237,24 @@
_tracks.RemoveRange(1, _tracks.Count - 1);
}
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
// Track-addressed entry pre-selects the clicked row (§8.M Option 2). For a multi-track Cut
// the addressed track may be any ordinal; for single-track media it is always row 0 (the
// collapse above leaves one row). Fall back to row 0 if the id is absent or trimmed away.
_selectedIndex = ResolveInitialSelection();
_loading = false;
}
private int ResolveInitialSelection()
{
if (_tracks.Count == 0) return -1;
if (TrackId is { } trackId)
{
var addressed = _tracks.FindIndex(t => t.Id == trackId);
if (addressed >= 0) return addressed;
}
return 0;
}
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
{
_errorMessage = null;
@@ -337,6 +385,7 @@
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
@@ -401,6 +450,7 @@
_artist,
album,
genre,
description,
releaseDate,
imagePathForUpdate,
_releaseType,
@@ -435,6 +485,7 @@
_artist,
album,
genre,
description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
@@ -463,6 +514,7 @@
_artist,
album,
genre,
description,
releaseDate,
linkPath,
_releaseType,
@@ -471,7 +523,7 @@
if (!linkResult.Success)
{
// Non-blocking: track persisted; cover can be linked via TrackEdit.
// Non-blocking: track persisted; cover can be re-linked by re-editing.
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
row.TrackName, uploadResult.Value.Id);
}
@@ -21,6 +21,7 @@
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
@@ -126,6 +127,7 @@
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
@@ -342,6 +344,7 @@
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
@@ -360,7 +363,7 @@
else
{
// The upload endpoint does not accept an imagePath, so link the cover art with
// a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use.
// a follow-up metadata update — same two-step pattern BatchEdit uses.
if (_imagePath is { } imgPath)
{
var linkResult = await CmsTrackService.UpdateAsync(
@@ -369,6 +372,7 @@
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath,
_releaseType,
@@ -377,7 +381,7 @@
if (!linkResult.Success)
{
// Non-blocking: track is persisted; cover art can be linked via TrackEdit.
// Non-blocking: track is persisted; cover art can be re-linked by editing.
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
row.TrackName, result.Value.Id);
}
@@ -33,6 +33,10 @@ else
<MudTh>Release Date</MudTh>
<MudTh>Type</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
@foreach (var column in SpecialColumns)
{
<MudTh Style="width: 1%; white-space: nowrap;">@column.Header</MudTh>
}
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
@@ -64,11 +68,14 @@ else
</MudChip>
</MudTd>
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
@foreach (var column in SpecialColumns)
{
@* One dedicated cell per host-declared special-action column (Mix waveform, Session hero).
The Cell fragment recovers its typed row state via the host's RowFor lookup. Sits between
Tracks and Actions so the universal Edit/Delete stay rightmost. *@
<MudTd DataLabel="@column.Header">@column.Cell(context.Release)</MudTd>
}
<MudTd DataLabel="Actions">
@* Medium-specific row action (Session hero, Mix waveform) when a host supplies one;
the ALL tab supplies none. Rendered before the shared edit/delete so the medium
affordance reads left-to-right ahead of the universal actions. *@
@RowActions?.Invoke(context.Release)
<MudTooltip Text="Batch Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
@@ -88,7 +95,7 @@ else
@if (context.IsExpanded)
{
<MudTr>
<MudTd colspan="9" Style="padding: 0;">
<MudTd colspan="@ColumnCount" Style="padding: 0;">
@if (context.IsLoading)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
@@ -126,12 +133,19 @@ else
[Parameter] public bool IsLoading { get; set; }
[Parameter] public EventCallback OnReleasesChanged { get; set; }
// Optional per-row, medium-specific action slot (Session hero upload, Mix waveform generate),
// rendered in the Actions cell ahead of the shared edit/delete buttons. The ALL tab leaves it
// unset and renders the grid exactly as before. A per-medium host (CmsCut/Session/MixBrowser)
// supplies it so the rich grid filtered to one medium keeps that medium's bespoke affordance
// the rich expand/delete/Type-chip/edit logic stays here, single-sourced, rather than forked.
[Parameter] public RenderFragment<ReleaseDto>? RowActions { get; set; }
// 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
// columns plus Edit/Delete. A per-medium host supplies its bespoke affordances here so the rich
// expand/delete/Type-chip/edit logic stays single-sourced in this grid rather than forked.
[Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; } = Array.Empty<SpecialActionColumn>();
// Base columns: expand, Art, Album, Artist, Genre, Release Date, Type, Tracks, Actions = 9.
private const int BaseColumnCount = 9;
// Total rendered columns, driving the expanded child-row colspan so it always spans the full table
// regardless of how many special-action columns the host declared.
private int ColumnCount => BaseColumnCount + SpecialColumns.Count;
private List<AlbumRow> _rows = new();
@@ -4,8 +4,8 @@
@* CUTS tab content (Phase 9 §8.A/§8.C): the rich CmsAlbumBrowser grid filtered to Cut releases, so the
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 row action, so no RowActions slot is supplied; the grid
renders its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
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"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync" />
@@ -13,7 +13,7 @@ namespace DeepDrftManager.Components.Pages.Tracks;
/// it (§8.C parity — reuse, don't fork). This base owns the loading flag, the medium-filtered load, the
/// per-release row projection, and a cover-thumbnail helper; subclasses supply the <see cref="Medium"/>,
/// an error noun, and their bespoke per-row action (Session hero upload, Mix waveform generate) via the
/// rich grid's <c>RowActions</c> slot, looking their action-state row up with <see cref="RowFor"/>.
/// rich grid's <c>SpecialColumns</c> column model, looking their action-state row up with <see cref="RowFor"/>.
/// </summary>
/// <typeparam name="TRow">The subclass's row model wrapping a <see cref="ReleaseDto"/> plus its
/// medium-specific action state (upload/generate flags). The rich grid renders from the bare
@@ -42,8 +42,8 @@ public abstract class CmsMediumBrowserBase<TRow> : ComponentBase where TRow : cl
// it never sees TRow. Rebuilt on every (re)load so the grid re-projects against a fresh reference.
protected IReadOnlyList<ReleaseDto> Releases { get; private set; } = Array.Empty<ReleaseDto>();
// release.Id → action-state row, so a RowActions fragment (which the grid hands a ReleaseDto) can
// recover its TRow. Rebuilt alongside Rows so a refresh never leaves a stale row behind.
// release.Id → action-state row, so a SpecialColumns cell delegate (which the grid hands a ReleaseDto)
// can recover its TRow. Rebuilt alongside Rows so a refresh never leaves a stale row behind.
private Dictionary<long, TRow> _rowsById = new();
protected override async Task OnInitializedAsync() => await LoadAsync();
@@ -8,9 +8,10 @@
@* Embedded as the MIXES tab content of the Release Archive (Phase 9 §8.A), and still routable at
/tracks/mixes for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Mixes (§8.C
parity: expand-tracks, delete, Type chip, per-row edit), with the Mix waveform generate supplied as
its medium-specific RowActions slot so that affordance survives the move off the thin table. When
embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is
suppressed; the standalone route keeps it. The waveform affordance (9.5.E) is preserved in both. *@
its medium-specific special-action column so that affordance survives the move off the thin table.
When embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive"
button) is suppressed; the standalone route keeps it. The waveform affordance (9.5.E) is preserved
in both. *@
@if (Embedded)
{
@GridContent
@@ -52,46 +53,59 @@ else
protected override ReleaseDto ReleaseOf(MixRow row) => row.Release;
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
// both branches above render the same markup without duplication. The waveform generate is the Mix's
// medium-specific RowActions content; the grid hands it each release, and RowFor recovers the
// matching MixRow's generate state.
// 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"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync">
<RowActions Context="release">
@{ var row = RowFor(release); }
@if (row is not null)
OnReleasesChanged="ReloadAsync"
SpecialColumns="_specialColumns" />;
// Allocated once per component instance in OnInitialized (field initializers cannot reference
// instance members, so initialization is deferred to the first lifecycle hook).
private IReadOnlyList<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
protected override void OnInitialized()
{
_specialColumns = new[] { new SpecialActionColumn("Waveform", WaveformCell) };
base.OnInitialized();
}
// Per-row cell for the dedicated "Waveform" column: status icon plus generate/regenerate button with
// progress. Recovers the typed MixRow via RowFor; skips rendering for a release not on the page.
private RenderFragment<ReleaseDto> WaveformCell => release =>@<text>
@{ var row = RowFor(release); }
@if (row is not null)
{
@if (row.HasWaveform)
{
@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)
{
<MudTooltip Text="Waveform generated">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</MudTooltip>
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating…</span>
}
else
{
<MudTooltip Text="No waveform — incomplete">
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
</MudTooltip>
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
}
<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>
}
</RowActions>
</CmsAlbumBrowser>;
</MudButton>
}
</text>;
private async Task GenerateWaveformAsync(MixRow row)
{
@@ -9,9 +9,10 @@
@* Embedded as the SESSIONS tab content of the Release Archive (Phase 9 §8.A), and still routable at
/tracks/sessions for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Sessions
(§8.C parity: expand-tracks, delete, Type chip, per-row edit), with the Session hero upload supplied
as its medium-specific RowActions slot so that affordance survives the move off the thin table. When
embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is
suppressed; the standalone route keeps it. The hero affordance (9.5.E) is preserved in both contexts. *@
as its medium-specific special-action column so that affordance survives the move off the thin table.
When embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive"
button) is suppressed; the standalone route keeps it. The hero affordance (9.5.E) is preserved in
both contexts. *@
@if (Embedded)
{
@GridContent
@@ -53,48 +54,61 @@ else
protected override ReleaseDto ReleaseOf(SessionRow row) => row.Release;
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
// both branches above render the same markup without duplication. The hero upload is the Session's
// medium-specific RowActions content; the grid hands it each release, and RowFor recovers the
// matching SessionRow's upload state.
// 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"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync">
<RowActions Context="release">
@{ var row = RowFor(release); }
@if (row is not null)
OnReleasesChanged="ReloadAsync"
SpecialColumns="_specialColumns" />;
// Allocated once per component instance in OnInitialized (field initializers cannot reference
// instance members, so initialization is deferred to the first lifecycle hook).
private IReadOnlyList<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
protected override void OnInitialized()
{
_specialColumns = new[] { new SpecialActionColumn("Hero", HeroCell) };
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>
@{ var row = RowFor(release); }
@if (row is not null)
{
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
{
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
{
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
}
else
{
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
}
<MudFileUpload T="IBrowserFile"
Accept="image/*"
FilesChanged="@(file => UploadHeroAsync(row, file))"
Disabled="@row.IsUploading">
<ActivatorContent>
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Image"
Disabled="@row.IsUploading">
@if (row.IsUploading)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Uploading…</span>
}
else
{
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
}
</MudButton>
</ActivatorContent>
</MudFileUpload>
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
}
</RowActions>
</CmsAlbumBrowser>;
else
{
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
}
<MudFileUpload T="IBrowserFile"
Accept="image/*"
FilesChanged="@(file => UploadHeroAsync(row, file))"
Disabled="@row.IsUploading">
<ActivatorContent>
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Image"
Disabled="@row.IsUploading">
@if (row.IsUploading)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Uploading…</span>
}
else
{
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
}
</MudButton>
</ActivatorContent>
</MudFileUpload>
}
</text>;
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
{
@@ -79,7 +79,7 @@
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
Href="@($"/tracks/{context.Id}")" />
Href="@($"/tracks/{context.Id}/edit")" />
</MudTooltip>
<MudTooltip Text="Delete">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
@@ -2,7 +2,7 @@
@* Session-medium fields. When AllowHeroUpload is true (BatchUpload create path), the hero image
file picker is shown — the file is held by the parent and POSTed after the release is created.
When false (edit paths: BatchEdit, TrackEdit, TrackNew), the original guidance alert is rendered
When false (edit path: BatchEdit), the original guidance alert is rendered
instead, directing the admin to the Sessions browser per-row replace action. This gate prevents
a dead/inert control on edit forms that do not wire the hero callbacks. *@
@if (AllowHeroUpload)
@@ -0,0 +1,15 @@
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Components;
namespace DeepDrftManager.Components.Pages.Tracks;
/// <summary>
/// A dedicated, header-labelled grid column for a medium-specific row affordance (e.g. Mix waveform
/// generate, Session hero upload) in <see cref="CmsAlbumBrowser"/>. A per-medium host declares zero or
/// more of these; the grid renders one extra header cell and one extra per-row cell for each, positioned
/// between the Tracks column and the universal Actions (Edit/Delete) column. The <see cref="Cell"/>
/// fragment is handed each release; the host recovers its typed row state via its own RowFor lookup.
/// </summary>
/// <param name="Header">Column header label (e.g. "Waveform", "Hero").</param>
/// <param name="Cell">Per-row cell content for a given release.</param>
public sealed record SpecialActionColumn(string Header, RenderFragment<ReleaseDto> Cell);
@@ -1,322 +1,16 @@
@page "/tracks/{Id:long}"
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject CmsTrackBrowserViewModel VM
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Nav
@inject ILogger<TrackEdit> Logger
@inject NavigationManager Navigation
<PageTitle>Edit Track — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks"
Class="mb-4">
Back to tracks
</MudButton>
@if (_loading)
{
<MudProgressCircular Indeterminate="true" />
}
else if (_track is null)
{
<MudAlert Severity="Severity.Warning">
Track not found.
</MudAlert>
}
else
{
<MudText Typo="Typo.h4" GutterBottom="true">Edit Track</MudText>
<MudPaper Class="pa-6" Elevation="2">
<MudStack Spacing="4">
<MudField Label="Entry Key" Variant="Variant.Outlined" InnerPadding="false">
<MudText Typo="Typo.body1" Style="font-family: monospace;">@_track.EntryKey</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">
Vault reference — not editable.
</MudText>
</MudField>
<MudTextField @bind-Value="_form.TrackName"
Label="Track Name"
Required="true"
RequiredError="Track name is required"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="_form.Artist"
Label="Artist"
Required="true"
RequiredError="Artist is required"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="_form.Album"
Label="Album"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="_form.Genre"
Label="Genre"
Variant="Variant.Outlined" />
<MediumFields @bind-Medium="_form.Medium" @bind-ReleaseType="_form.ReleaseType" Disabled="_busy" />
<MudNumericField @bind-Value="_form.TrackNumber"
Label="Track Number"
Min="1"
Variant="Variant.Outlined" />
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@if (ImagePreviewUrl is { } previewUrl)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudImage Src="@previewUrl"
Alt="Cover art preview"
Elevation="1"
Style="max-width: 120px; height: auto; border-radius: 4px;" />
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="_busy"
OnClick="ClearImage"
aria-label="Clear cover art" />
</MudStack>
}
else if (_selectedImageFile is not null)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">New image selected (not yet saved).</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="_busy"
OnClick="ClearImage"
aria-label="Cancel image selection" />
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">No cover art set.</MudText>
}
<InputFile OnChange="HandleImageFileSelected" accept="image/*" />
@if (_selectedImageFile is { } selected)
{
<MudText Typo="Typo.caption">Selected: @selected.Name (will upload on save)</MudText>
}
</MudStack>
</MudField>
<MudDatePicker @bind-Date="_form.ReleaseDate"
Label="Release Date"
DateFormat="yyyy-MM-dd"
Variant="Variant.Outlined" />
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween">
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Delete"
Disabled="_busy"
OnClick="ConfirmDelete">
Delete
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
Disabled="_busy || !CanSave"
OnClick="SaveAsync">
Save Changes
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
}
</MudContainer>
@* §8.M: the legacy single-track edit form is retired. Its edit responsibility is absorbed by
BatchEdit's track-addressed entry (/tracks/{id}/edit), which loads the parent release and
pre-selects the addressed track's row. This route is kept only as a redirect so any bookmarked
/tracks/{id} lands on the live edit surface; CmsTrackGrid's per-row Edit targets the new route
directly. *@
@code {
[Parameter] public long Id { get; set; }
private TrackDto? _track;
private TrackEditForm _form = new();
private bool _loading = true;
private bool _busy;
private IBrowserFile? _selectedImageFile;
private bool CanSave =>
!string.IsNullOrWhiteSpace(_form.TrackName)
&& !string.IsNullOrWhiteSpace(_form.Artist);
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private string? ImagePreviewUrl =>
string.IsNullOrEmpty(_form.ImagePath)
? null
: $"/api/image/{Uri.EscapeDataString(_form.ImagePath)}";
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
var result = await CmsTrackService.GetByIdAsync(Id);
_track = result.Success ? result.Value : null;
if (_track is not null)
{
_form = TrackEditForm.From(_track);
}
_loading = false;
}
private async Task SaveAsync()
{
if (_track is null || !CanSave) return;
_busy = true;
try
{
// Upload any newly picked cover art first; abort the save if it fails so we never
// persist metadata pointing at an image that was never stored.
if (_selectedImageFile is { } file)
{
await using var imageStream = file.OpenReadStream(maxAllowedSize: 50_000_000);
var uploadResult = await CmsTrackService.UploadImageAsync(
imageStream, file.Name, file.ContentType);
if (!uploadResult.Success)
{
var uploadError = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Image upload failed: {uploadError}", Severity.Error);
return;
}
_form.ImagePath = uploadResult.Value;
_selectedImageFile = null;
}
// Metadata update over HTTP — EntryKey is immutable and not sent. The Content API
// loads the authoritative row and applies these fields. imagePath is tri-state: an
// explicit empty string clears the link, a value sets it.
var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
var updated = await CmsTrackService.UpdateAsync(
Id, _form.TrackName, _form.Artist,
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
releaseDate,
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
_form.ReleaseType,
_form.Medium,
_form.TrackNumber);
if (updated.Success)
{
// Album/genre browse lists derive from this track's metadata; drop their cache so
// the /tracks browser re-fetches fresh data on next mode switch.
VM.Invalidate();
Snackbar.Add("Track updated.", Severity.Success);
await LoadAsync();
}
else
{
var error = updated.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Save failed: {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Save failed for track {TrackId}", Id);
Snackbar.Add("Save failed — please try again.", Severity.Error);
}
finally
{
_busy = false;
}
}
private void HandleImageFileSelected(InputFileChangeEventArgs e)
{
_selectedImageFile = e.File;
}
private void ClearImage()
{
_form.ImagePath = null;
_selectedImageFile = null;
}
private async Task ConfirmDelete()
{
if (_track is null) return;
var confirmed = await DialogService.ShowMessageBox(
"Delete track",
$"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.",
yesText: "Delete",
cancelText: "Cancel");
if (confirmed != true) return;
_busy = true;
try
{
var result = await CmsTrackService.DeleteTrackAsync(Id);
if (result.Success)
{
// Deleting a track can empty or alter a release; drop the browse cache so the
// /tracks album and genre lists re-fetch fresh counts on next mode switch.
VM.Invalidate();
Snackbar.Add("Track deleted.", Severity.Success);
Nav.NavigateTo("/tracks");
}
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}", Id);
Snackbar.Add("Delete failed — please try again.", Severity.Error);
}
finally
{
_busy = false;
}
}
private sealed class TrackEditForm
{
public string TrackName { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public string? Album { get; set; }
public string? Genre { get; set; }
public string? ImagePath { get; set; }
public DateTime? ReleaseDate { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
// Drives ReleaseType visibility via MediumFields and is persisted on save (PUT api/track/meta
// carries Medium). A non-Cut medium resets ReleaseType to its default server-side.
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
public int TrackNumber { get; set; } = 1;
public static TrackEditForm From(TrackDto track) => new()
{
TrackName = track.TrackName,
Artist = track.Release?.Artist ?? string.Empty,
Album = track.Release?.Title,
Genre = track.Release?.Genre,
ImagePath = track.Release?.ImagePath,
ReleaseDate = track.Release?.ReleaseDate is { } d
? d.ToDateTime(TimeOnly.MinValue)
: null,
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
Medium = track.Release?.Medium ?? ReleaseMedium.Cut,
TrackNumber = track.TrackNumber
};
}
protected override void OnInitialized() =>
Navigation.NavigateTo($"/tracks/{Id}/edit", replace: true);
}
@@ -1,287 +1,12 @@
@page "/tracks/new"
@using System.Security.Claims
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILogger<TrackNew> Logger
@inject CmsTrackBrowserViewModel VM
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Add Track</MudText>
<MudPaper Class="pa-6" Elevation="2">
<MudStack Spacing="4">
<MudText Typo="Typo.subtitle1">WAV file</MudText>
<InputFile OnChange="OnFileSelected" accept=".wav,audio/wav,audio/x-wav" />
@if (_selectedFile is not null)
{
<MudText Typo="Typo.body2">
Selected: @_selectedFile.Name (@FormatBytes(_selectedFile.Size))
</MudText>
}
<MudTextField @bind-Value="_trackName" Label="Track Name" Required="true" RequiredError="Track Name is required" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
<MediumFields @bind-Medium="_medium" @bind-ReleaseType="_releaseType" Disabled="_isUploading" />
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@if (_selectedImageFile is { } selectedImage)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="_isUploading"
OnClick="ClearImage"
aria-label="Cancel image selection" />
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
}
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@_isUploading" />
@if (_selectedImageFile is not null)
{
<MudText Typo="Typo.caption">Will upload on save.</MudText>
}
</MudStack>
</MudField>
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error">@_errorMessage</MudAlert>
}
<MudStack Row="true" Spacing="2" Justify="Justify.FlexEnd">
<MudButton Variant="Variant.Text"
OnClick="Cancel"
Disabled="_isUploading">
Cancel
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SubmitAsync"
Disabled="_isUploading">
@if (_isUploading)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
<text>Uploading…</text>
}
else
{
<text>Upload</text>
}
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudContainer>
@* §8.M: the legacy single-track add form is retired. Its add responsibility is absorbed by
BatchUpload's single-track branch. This route is kept only as a redirect so any bookmarked
/tracks/new lands on the live upload surface; there are no in-app callers. *@
@code {
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
// streaming path means the limit caps the request, not in-memory buffering.
private const long MaxUploadBytes = 1_073_741_824L;
private IBrowserFile? _selectedFile;
private IBrowserFile? _selectedImageFile;
private string? _imagePath;
private string _trackName = string.Empty;
private string _artist = string.Empty;
private string _album = string.Empty;
private string _genre = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
private string? _errorMessage;
private bool _isUploading;
private void OnFileSelected(InputFileChangeEventArgs e)
{
_selectedFile = e.File;
_errorMessage = null;
}
private void HandleImageFileSelected(InputFileChangeEventArgs e)
{
_selectedImageFile = e.File;
_imagePath = null;
}
private void ClearImage()
{
_selectedImageFile = null;
_imagePath = null;
}
private async Task SubmitAsync()
{
_errorMessage = null;
if (_selectedFile is null)
{
_errorMessage = "Please select a WAV file.";
return;
}
if (!_selectedFile.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
_errorMessage = "Selected file must be a .wav file.";
return;
}
if (string.IsNullOrWhiteSpace(_trackName))
{
_errorMessage = "Track Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_artist))
{
_errorMessage = "Artist is required.";
return;
}
if (!string.IsNullOrWhiteSpace(_releaseDate)
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
{
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
return;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(userIdValue, out var createdByUserId))
{
// The page is gated by [HierarchicalRoleAuthorize(Admin)], so a missing or
// unparseable id here is a configuration bug, not normal client state.
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
return;
}
_isUploading = true;
try
{
// Upload any selected cover art first; abort the submit if it fails so we never
// create a track expecting an image that was never stored in the vault.
if (_selectedImageFile is { } imgFile)
{
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
if (!imgResult.Success)
{
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Image upload failed: {imgError}";
return;
}
_imagePath = imgResult.Value;
}
// 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 fileStream = _selectedFile.OpenReadStream(MaxUploadBytes);
var result = await CmsTrackService.UploadTrackAsync(
fileStream,
_selectedFile.Name,
_selectedFile.ContentType,
_trackName,
_artist,
string.IsNullOrWhiteSpace(_album) ? null : _album,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
_selectedFile.Name,
createdByUserId,
_releaseType,
trackNumber: 1,
_medium);
if (result.Success)
{
// Mix uploads fire the server-side high-res waveform trigger (§3.4) — the CMS computes
// nothing. Non-blocking: a failed trigger is recoverable from the Mixes browser.
if (_medium == ReleaseMedium.Mix && result.Value?.ReleaseId is { } mixReleaseId)
{
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
if (!waveformResult.Success)
{
Logger.LogWarning("TrackNew: mix waveform trigger failed for release {ReleaseId}", mixReleaseId);
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
}
}
// The upload endpoint does not accept an imagePath, so link the cover art with a
// follow-up metadata update — same two-step pattern TrackEdit uses.
if (_imagePath is { } imgPath && result.Value is { } created)
{
var linkResult = await CmsTrackService.UpdateAsync(
created.Id,
_trackName,
_artist,
string.IsNullOrWhiteSpace(_album) ? null : _album,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath);
if (!linkResult.Success)
{
// Track was created; image is in the vault but unlinked. Non-blocking —
// the user can attach it via Edit.
Snackbar.Add("Track uploaded, but cover art could not be linked. You can add it via Edit.", Severity.Warning);
}
}
Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success);
VM.Invalidate();
Navigation.NavigateTo("/tracks");
return;
}
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Upload failed: {error}";
Logger.LogWarning("CMS upload rejected: {Error}", error);
}
catch (Exception ex)
{
Logger.LogError(ex, "Upload failed in TrackNew");
_errorMessage = "Upload failed. Please try again.";
}
finally
{
_isUploading = false;
}
}
private void Cancel()
{
Navigation.NavigateTo("/tracks");
}
private static string FormatBytes(long bytes)
{
const long KB = 1024;
const long MB = KB * 1024;
const long GB = MB * 1024;
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
return $"{bytes} bytes";
}
protected override void OnInitialized() =>
Navigation.NavigateTo("/tracks/upload", replace: true);
}
+4 -1
View File
@@ -39,6 +39,7 @@ public class CmsTrackService : ICmsTrackService
string artist,
string? album,
string? genre,
string? description,
string? releaseDate,
string? originalFileName,
long createdByUserId,
@@ -58,6 +59,7 @@ public class CmsTrackService : ICmsTrackService
multipart.Add(new StringContent(artist), "artist");
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
if (!string.IsNullOrWhiteSpace(description)) multipart.Add(new StringContent(description), "description");
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
// Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename.
if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
@@ -371,7 +373,7 @@ public class CmsTrackService : ICmsTrackService
public async Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? album, string? genre, string? description, DateOnly? releaseDate,
string? imagePath = null,
ReleaseType? releaseType = null,
ReleaseMedium? medium = null,
@@ -385,6 +387,7 @@ public class CmsTrackService : ICmsTrackService
artist,
album,
genre,
description,
releaseDate,
imagePath,
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
+2 -1
View File
@@ -30,6 +30,7 @@ public interface ICmsTrackService
string artist,
string? album,
string? genre,
string? description,
string? releaseDate,
string? originalFileName,
long createdByUserId,
@@ -85,7 +86,7 @@ public interface ICmsTrackService
/// </summary>
Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? album, string? genre, string? description, DateOnly? releaseDate,
string? imagePath = null,
ReleaseType? releaseType = null,
ReleaseMedium? medium = null,
+5
View File
@@ -9,9 +9,14 @@ namespace DeepDrftModels.DTOs;
// TrackConverter assigns every field on the round-trip, so an empty default is never observable.
public class ReleaseDto : BaseModel
{
// The release's public handle — the GUID string the public site/API address it by (Phase 11 §3e).
// Mirrors TrackDto's EntryKey. No `required` (BaseModel/Manager<> need new()); TrackConverter
// assigns it on every round-trip, so an empty default is never observable.
public string EntryKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public string? Genre { get; set; }
public string? Description { get; set; }
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
+10
View File
@@ -12,9 +12,19 @@ namespace DeepDrftModels.Entities;
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
public class ReleaseEntity : BaseEntity, IEntity
{
// App-minted GUID-string public handle, mirroring TrackEntity.EntryKey exactly: required string,
// entry_key column, unique index, born app-side as Guid.NewGuid().ToString() at release creation
// (the FindOrCreateRelease path). Fronts the int Id (the DB-only PK, unused by the app) so the
// public site/API address releases by an opaque key, never the transparent sequential id
// (Phase 11 §3e). Unlike a track's EntryKey it has no vault job — it is purely an identifier.
public required string EntryKey { get; set; }
public required string Title { get; set; }
public required string Artist { get; set; }
public string? Genre { get; set; }
// Free-text prose blurb describing the release. Uniform across media (Cut/Session/Mix), so it
// lives on the base table alongside Genre rather than in a per-medium satellite. Plain text,
// max 4000 (configured in ReleaseConfiguration); nullable so existing rows migrate as NULL.
public string? Description { get; set; }
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
+5 -1
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). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `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).
- `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.
@@ -23,6 +23,8 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. 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).
- `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.
@@ -31,6 +33,7 @@ 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.
- `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.
@@ -140,5 +143,6 @@ dotnet test DeepDrftTests/
- **Result types**: Clients return `ApiResult<T>` from NetBlocks. UI checks `Success` before using `Value`.
- **Async/await**: All operations are async.
- **Stream consumption**: `TrackMediaClient.GetAudioStreamAsync` returns a `Stream` (not fully buffered). `StreamingAudioPlayerService` reads it in chunks to avoid memory pressure on large files.
- **Detail pages under InteractiveAuto must load in `OnParametersSetAsync`, not `OnInitializedAsync`**: Blazor reuses a scoped component instance across same-template navigations (e.g. `/mixes/5``/mixes/8`), firing `OnParametersSet`/`OnParametersSetAsync` rather than re-running `OnInitialized`. If load logic is in `OnInitialized` only, the ViewModel retains the prior track and Play will stream the wrong item. Capture the route parameter (id/key) synchronously at the top of `OnParametersSetAsync` before any await — after an await the route state may have advanced. Guard `PersistentComponentState` restores on id/key equality to prevent cross-item bleed when the prerender and WASM-boot passes disagree on which item is current.
When working with this project, maintain the separation between presentation (Razor components) and logic (ViewModels/Clients), follow the established audio player architecture, and respect the dark-mode round-trip (cookie → DarkModeSettings → PersistentComponentState → client).
@@ -69,9 +69,9 @@ public class ReleaseClient
: ApiResult<PagedResult<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<ReleaseDto>> GetById(long id)
public async Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
{
var response = await _http.GetAsync($"api/release/{id}");
var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}");
if (!response.IsSuccessStatusCode)
return ApiResult<ReleaseDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
@@ -85,13 +85,13 @@ public class ReleaseClient
}
/// <summary>
/// Fetches the high-res waveform datum for a Mix release. 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.
/// 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(long id)
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
{
var response = await _http.GetAsync($"api/release/{id}/mix/waveform");
var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
@@ -0,0 +1,32 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// The single source of truth for a release's dedicated detail route (Phase 11 §2). A release
/// resolves to its per-medium detail page from its <see cref="ReleaseDto.EntryKey"/> and
/// <see cref="ReleaseMedium"/> — no round-trip needed at call sites that already hold the medium
/// (Archive cards, AlbumsView cards, the player-bar title). The thin <c>/tracks/{entryKey}</c>
/// redirect page fetches by EntryKey to discover the medium, then resolves through this same
/// helper, so the medium→route table lives in exactly one place.
/// </summary>
public static class ReleaseRoutes
{
/// <summary>
/// The dedicated detail route for a release: <c>/cuts/{entryKey}</c>, <c>/sessions/{entryKey}</c>,
/// or <c>/mixes/{entryKey}</c>. The route carries the release's opaque public EntryKey (Phase 11
/// §3e) — never the transparent int PK. Cut is the default arm so a new medium without an entry
/// here surfaces a build-visible gap rather than a silent fallthrough — extend the switch when a
/// fourth medium lands.
/// </summary>
public static string DetailHref(string entryKey, ReleaseMedium medium) => medium switch
{
ReleaseMedium.Session => $"/sessions/{entryKey}",
ReleaseMedium.Mix => $"/mixes/{entryKey}",
_ => $"/cuts/{entryKey}",
};
/// <summary>Convenience overload for call sites holding a <see cref="ReleaseDto"/>.</summary>
public static string DetailHref(ReleaseDto release) => DetailHref(release.EntryKey, release.Medium);
}
@@ -1,6 +1,6 @@
@if (_isMinimized)
{
<div class="minimized-dock">
<div class="minimized-dock" @ref="_miniDock">
<LevelMeterFab OnClick="@ToggleMinimized" />
</div>
}
@@ -21,6 +21,10 @@ else
Fixed="Fixed"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"
HasNext="HasNext"
HasPrevious="HasPrevious"
SkipNext="@SkipNext"
SkipPrevious="@SkipPrevious"
Class="transport-zone"/>
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
@@ -9,6 +9,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? QueueService { get; set; }
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public EventCallback<bool> OnMinimized { get; set; }
@@ -19,13 +20,22 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private bool _isSeeking = false;
private double _seekPosition = 0;
private IStreamingPlayerService? _subscribedService;
private IQueueService? _subscribedQueue;
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
// spacer reserves its space. We mirror this element's live height into a CSS
// var via a ResizeObserver (see Interop/layout/spacer.ts) rather than a static
// value, because the player reflows across breakpoints and grows with the
// 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
// 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.
private ElementReference _playerRoot;
private ElementReference _miniDock;
private ElementReference _lastObservedElement;
private IJSObjectReference? _spacerModule;
private bool _spacerObserved;
@@ -48,6 +58,11 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private double LoadProgress => PlayerService?.LoadProgress ?? 0;
private string? ErrorMessage => PlayerService?.ErrorMessage;
// Skip affordances reflect live queue state. With no queue (null) or an empty queue both are
// false, so the buttons sit disabled and the bar behaves exactly as it did before the queue.
private bool HasNext => QueueService?.HasNext ?? false;
private bool HasPrevious => QueueService?.HasPrevious ?? false;
/// <summary>
/// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary>
@@ -76,28 +91,58 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
// The queue cascade is also IsFixed, so re-render the skip affordances off its own
// change signal — same posture as the player StateChanged subscription above.
if (QueueService != null && !ReferenceEquals(QueueService, _subscribedQueue))
{
if (_subscribedQueue != null)
_subscribedQueue.QueueChanged -= OnQueueChanged;
QueueService.QueueChanged += OnQueueChanged;
_subscribedQueue = QueueService;
}
}
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private void OnQueueChanged() => InvokeAsync(StateHasChanged);
private async Task SkipNext()
{
if (QueueService == null) return;
await QueueService.Next();
}
private async Task SkipPrevious()
{
if (QueueService == null) return;
await QueueService.Previous();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Only the docked, expanded shape needs a spacer: the Fixed embed is
// already in normal flow, and the minimized FAB floats clear of content.
// Toggle the observer on the minimized/expanded transition only — the
// ResizeObserver itself handles every size change in between.
var shouldObserve = !_isMinimized && !Fixed;
if (shouldObserve == _spacerObserved) return;
// 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
// the footer in both states (fix §1).
// expanded → observe _playerRoot (full player bar, reflows across breakpoints)
// minimized → observe _miniDock (floating FAB container, ~5660px)
// The player-spacer's .minimized class uses a hardcoded height and ignores
// the var, so publishing the FAB height here does not regress the spacer.
if (Fixed) return;
var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id;
if (alreadyOnThisElement) return;
var module = await GetSpacerModuleAsync();
if (module is null) return;
if (shouldObserve)
await module.InvokeVoidAsync("observe", _playerRoot);
else
await module.InvokeVoidAsync("unobserve");
_spacerObserved = shouldObserve;
await module.InvokeVoidAsync("observe", elementToObserve);
_spacerObserved = true;
_lastObservedElement = elementToObserve;
}
private async Task<IJSObjectReference?> GetSpacerModuleAsync()
@@ -2,12 +2,25 @@
@using DeepDrftPublic.Client.Controls
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@if (!Fixed)
{
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
Color="Color.Primary"
Size="Size.Large"
OnClick="@SkipPrevious"
Disabled="!HasPrevious"/>
}
<PlayStateIcon Size="Size.Large"
Color="Color.Primary"
Disabled="!CanPlay"
OnToggle="@TogglePlayPause"/>
@if (!Fixed)
{
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
Color="Color.Primary"
Size="Size.Large"
OnClick="@SkipNext"
Disabled="!HasNext"/>
<MudIconButton Icon="@Icons.Material.Filled.Stop"
Color="Color.Primary"
Size="Size.Large"
@@ -16,4 +16,13 @@ public partial class PlayerControls : ComponentBase
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public required EventCallback TogglePlayPause { get; set; }
[Parameter] public required EventCallback Stop { get; set; }
/// <summary>Whether the queue has a track to skip forward to. Drives the skip-next affordance.</summary>
[Parameter] public bool HasNext { get; set; }
/// <summary>Whether the queue has a track to step back to. Drives the skip-previous affordance.</summary>
[Parameter] public bool HasPrevious { get; set; }
[Parameter] public EventCallback SkipNext { get; set; }
[Parameter] public EventCallback SkipPrevious { get; set; }
}
@@ -6,7 +6,11 @@
CanPlay="CanPlay"
Fixed="Fixed"
TogglePlayPause="TogglePlayPause"
Stop="Stop"/>
Stop="Stop"
HasNext="HasNext"
HasPrevious="HasPrevious"
SkipNext="SkipNext"
SkipPrevious="SkipPrevious"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
@@ -14,5 +14,9 @@ public partial class PlayerTransportZone : ComponentBase
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public EventCallback TogglePlayPause { get; set; }
[Parameter] public EventCallback Stop { get; set; }
[Parameter] public bool HasNext { get; set; }
[Parameter] public bool HasPrevious { get; set; }
[Parameter] public EventCallback SkipNext { get; set; }
[Parameter] public EventCallback SkipPrevious { get; set; }
[Parameter] public string? Class { get; set; }
}
@@ -6,11 +6,23 @@
{
<div class="track-meta-row">
<div class="track-meta-identity">
<a href="@($"/track/{Track.EntryKey}")" style="text-decoration: none;">
@* Title links to the release's dedicated detail page via the shared resolver (§2): the
TrackDto already carries Release { Id, Medium }, so no round-trip is needed. When no
release is attached there is no medium to resolve, so the title renders unlinked. *@
@if (Track.Release is not null)
{
<a href="@ReleaseRoutes.DetailHref(Track.Release)" style="text-decoration: none;">
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
@Track.TrackName
</MudText>
</a>
}
else
{
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
@Track.TrackName
</MudText>
</a>
}
<MudText Typo="Typo.subtitle2" Class="track-meta-sep"> - </MudText>
<MudText Typo="Typo.caption" Class="track-meta-artist text-truncate">
@Track.Release?.Artist
@@ -1,6 +1,6 @@
/* Single space-between row under the waveform: identity on the left, accents on the right.
Colours come from the MudBlazor theme (the dock surface is theme-aware), so unlike the
always-dark TrackCard glass we do not hard-code green-accent overrides here. */
Colours come from the MudBlazor theme (the dock surface is theme-aware), so we do not
hard-code green-accent overrides here. */
.track-meta-row {
display: flex;
align-items: center;
@@ -3,5 +3,7 @@
If instance swapping at runtime is ever needed, change IsFixed to false (adds subscription
overhead on every parent re-render, but allows children to see the new reference). *@
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
@ChildContent
<CascadingValue Value="@(_queueService)" IsFixed="true">
@ChildContent
</CascadingValue>
</CascadingValue>
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
private IStreamingPlayerService? _audioPlayerService;
private QueueService? _queueService;
[Parameter] public RenderFragment? ChildContent { get; set; }
@@ -29,6 +30,13 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
// Children must not wrap or replace this callback.
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
// OnTrackSelected will be set by individual child components that need it
// The queue orchestrates above the single-slot player. The player is not DI-registered
// (constructed here), so the queue binds to it via Attach rather than constructor injection —
// no construction cycle, no IServiceProvider. Cascaded alongside the player so the bar and a
// future up-next panel both read it.
_queueService = new QueueService();
_queueService.Attach(_audioPlayerService);
}
/// <summary>
@@ -38,6 +46,11 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
/// </summary>
public async ValueTask DisposeAsync()
{
// Dispose the queue first so it unsubscribes from the player's TrackEnded before the
// player tears down.
_queueService?.Dispose();
_queueService = null;
if (_audioPlayerService is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
@@ -6,7 +6,7 @@
</p>
<div class="hero-actions @AnimClass">
<StreamNowButton ButtonClass="btn-primary" ButtonLabel="Start Streaming" />
<a class="btn-ghost" href="/tracks">Browse Tracks</a>
<a class="btn-ghost" href="/archive">Browse Tracks</a>
</div>
@code {
@@ -1,7 +0,0 @@
namespace DeepDrftPublic.Client.Controls;
public enum GalleryViewMode
{
Grid,
List
}
@@ -0,0 +1,166 @@
@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();
}
}
@@ -0,0 +1,36 @@
/* 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,35 +1,16 @@
@namespace DeepDrftPublic.Client.Controls
@* Full-page background waveform for a Mix release. Deliberately NOT the player-bar peak-bar idiom
(SpectrumVisualizer / LevelMeterFab own that): this renders a single continuous mirrored
silhouette filling the viewport behind the detail content. Fetches its own datum from
api/release/{id}/mix/waveform. The played portion is washed with the progress overlay driven by
PlaybackPosition; the click-to-seek seam (OnSeek + bindable PlaybackPosition) is wired here for a
future wave even though click handling does not ship yet. *@
@* Full-page scrolling Mix waveform background (Phase 9, 8.K). A windowed slice of the mix'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
bridge that feeds it datum + playback + zoom + theme. Deliberately NOT the player-bar peak-bar idiom. *@
<div class="mix-waveform-bg @(_profile is null ? "mix-waveform-bg--empty" : null)">
@if (_profile is not null)
{
<svg class="mix-waveform-svg"
viewBox="0 0 @ViewBoxWidth 100"
preserveAspectRatio="none"
role="img"
aria-label="Waveform">
<defs>
<clipPath id="@_clipId">
<path d="@_silhouettePath" />
</clipPath>
</defs>
@* Base silhouette. *@
<path d="@_silhouettePath" class="mix-waveform-fill" />
@* Played-portion wash: a full-height rect clipped to the silhouette, width tracking
PlaybackPosition. Clamped to [0, 1]. *@
<rect x="0" y="0"
width="@(ClampedPosition * ViewBoxWidth)" height="100"
class="mix-waveform-played"
clip-path="url(#@_clipId)" />
</svg>
}
<div class="mix-waveform-bg">
<canvas @ref="_canvas" class="mix-waveform-canvas"></canvas>
</div>
@* The viewing controls (resolution + the three Wave 2 controls) live in MixVisualizerControls,
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. *@
@@ -1,132 +1,355 @@
using System.Globalization;
using System.Text;
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>
/// Renders a Mix release's stored loudness profile as a full-page background silhouette. Standalone
/// and reusable: give it a <see cref="ReleaseId"/> and it fetches its own datum. Visually distinct
/// from the player-bar spectrum/level idiom by design — this is a single continuous mirrored wave,
/// not discrete peak bars.
/// 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
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; }
/// <summary>The Mix release whose waveform datum to fetch and render.</summary>
[Parameter] public required long ReleaseId { 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>
/// Normalized playback head in [0, 1]. Two-way bindable so a future click-to-seek can write back
/// through it; today it is read-only input that drives the played-portion wash. The seam exists
/// now so wiring click-to-seek later is a pure addition, not a signature change.
/// 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; }
[Parameter] public EventCallback<double> PlaybackPositionChanged { 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]";
/// <summary>
/// Fired when the user seeks by interacting with the waveform. Unused until click-to-seek ships;
/// present now to lock the seek seam into the public contract.
/// </summary>
[Parameter] public EventCallback<double> OnSeek { get; set; }
private static void DebugLog(string message)
{
if (Debug) Console.WriteLine($"{Tag} {message}");
}
// Fixed SVG coordinate width. The path is computed in this space, then stretched to the
// viewport via preserveAspectRatio="none".
private const int ViewBoxWidth = 1000;
private readonly string _clipId = $"mix-wf-clip-{Guid.NewGuid():N}";
private ElementReference _canvas;
private IJSObjectReference? _module;
private IJSObjectReference? _handle;
private IStreamingPlayerService? _subscribedService;
private WaveformProfileDto? _profile;
private string _silhouettePath = string.Empty;
private long? _loadedReleaseId;
private string? _loadedReleaseKey;
private double ClampedPosition => Math.Clamp(PlaybackPosition, 0d, 1d);
// 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()
{
// ReleaseId is the only fetch input; fetch once per id. A PlaybackPosition update re-renders
// but must not refetch — and a release with no datum must not refetch either, so the guard
// keys on the fetched id, not on whether a profile came back.
if (_loadedReleaseId == ReleaseId)
return;
// 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"}.");
}
_loadedReleaseId = ReleaseId;
// 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;
var result = await ReleaseData.GetMixWaveform(ReleaseId);
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0)
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;
try
{
_silhouettePath = BuildSilhouettePath(profile);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "MixWaveformVisualizer: failed to decode waveform profile for release {ReleaseId}; rendering empty backdrop.", ReleaseId);
_profile = null;
_silhouettePath = string.Empty;
}
DebugLog($"datum fetch OK — {profile.BucketCount} buckets, base64 length {profile.Data.Length}.");
}
else
{
// No datum (not generated yet, or not a Mix) — leave the background empty; the detail
// page still renders its content over a plain backdrop.
// No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still
// renders its content over a plain background.
_profile = null;
_silhouettePath = string.Empty;
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;
}
}
// Builds a closed, vertically mirrored silhouette path across the buckets. Loudness bytes are
// [0, 255]; mapped to a half-height amplitude around the vertical midline (y=50). The top edge
// runs left-to-right, the bottom edge mirrors right-to-left, and the path closes — yielding a
// filled continuous wave shape rather than separate bars.
private static string BuildSilhouettePath(WaveformProfileDto profile)
public async ValueTask DisposeAsync()
{
var data = Convert.FromBase64String(profile.Data);
int n = data.Length;
if (n == 0) return string.Empty;
const double midline = 50d;
const double maxAmplitude = 48d; // leave a 2-unit margin top and bottom
double step = n > 1 ? (double)ViewBoxWidth / (n - 1) : ViewBoxWidth;
var sb = new StringBuilder();
// Top edge, left to right.
for (int i = 0; i < n; i++)
if (_subscribedService is not null)
{
double x = i * step;
double amp = data[i] / 255d * maxAmplitude;
double y = midline - amp;
sb.Append(i == 0 ? 'M' : 'L');
AppendPoint(sb, x, y);
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
// Bottom edge, right to left (mirror).
for (int i = n - 1; i >= 0; i--)
if (_subscribedToControls)
{
double x = i * step;
double amp = data[i] / 255d * maxAmplitude;
double y = midline + amp;
sb.Append('L');
AppendPoint(sb, x, y);
ControlState.Changed -= OnControlStateChanged;
_subscribedToControls = false;
}
sb.Append('Z');
return sb.ToString();
}
if (_handle is not null)
{
try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { }
try { await _handle.DisposeAsync(); } catch (JSDisconnectedException) { }
_handle = null;
}
private static void AppendPoint(StringBuilder sb, double x, double y)
{
sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture));
sb.Append(' ');
sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture));
sb.Append(' ');
if (_module is not null)
{
try { await _module.DisposeAsync(); } catch (JSDisconnectedException) { }
_module = null;
}
}
}
@@ -1,31 +1,32 @@
/* Full-viewport fixed backdrop. Sits behind page content (negative-ish z-index within the
detail layout) and never intercepts pointer events until click-to-seek ships. */
/* Full-viewport fixed backdrop. Sits behind the detail content (.mix-detail-foreground is z-index:1)
and never intercepts pointer events except the zoom slider, which re-enables them on itself.
Footer clip (Phase 10 W1, spec §2c): the backdrop must stop cleanly ABOVE the audio player bar so
no lava/waveform pixel paints over or under it. `overflow: hidden` clips the canvas to this box, and
`bottom` is inset by `--player-height`, which AudioPlayerBar publishes on :root via its ResizeObserver
(Interop/layout/spacer.ts). The observer now points at whichever element is live:
expanded the full player dock (tracks breakpoint reflow + error-banner growth)
minimized the minimized-dock FAB container (~5660 px)
so --player-height is always non-zero while the player is mounted and the clip line follows the bar in
BOTH states (fix §1 / p10-reframe-w1-fix). The 0px fallback keeps the backdrop full-height on any
page that does not host the player. */
.mix-waveform-bg {
position: fixed;
inset: 0;
bottom: var(--player-height, 0px);
z-index: 0;
pointer-events: none;
overflow: hidden;
display: flex;
align-items: center;
}
.mix-waveform-bg--empty {
/* No datum: nothing to draw. Kept as a hook for a future flat-line fallback. */
}
.mix-waveform-svg {
/* 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);
the glass treatment returns in-shader in Wave 3. */
.mix-waveform-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 60vh;
margin: auto 0;
opacity: 0.18;
}
/* Native SVG elements — scoped CSS stamps these directly, no ::deep needed. */
.mix-waveform-fill {
fill: var(--mud-palette-text-secondary);
}
.mix-waveform-played {
fill: var(--mud-palette-primary);
height: 100%;
display: block;
}
@@ -0,0 +1,57 @@
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Pure mapping between the Mix 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
{
/// <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;
/// <summary>Longest span (min zoom). Tunable.</summary>
public const double MaxVisibleSeconds = 30.0;
/// <summary>
/// Lower edge of the useful zoom band on the underlying fraction axis. Phase 10 retune: the bottom
/// 60% of the old knob travel (fraction 0…0.6) was a useless slow/wide window, so the scroll knob's
/// full [0,1] travel now maps onto the upper 0.6…1.0 band where every position reads as a useful
/// zoom (Daniel: "range below 60% is useless; optimize for the current 60%110% zoom values" — 110%
/// caps at the hard 0.333 s max-zoom anchor, fraction 1.0).
/// </summary>
public const double ScrollKnobZoomFloor = 0.60;
/// <summary>
/// Maps the scroll-speed knob [0,1] onto the useful zoom band [<see cref="ScrollKnobZoomFloor"/>, 1.0]
/// of the underlying fraction axis, then to visible seconds. So knob 0 sits at the slow edge of the
/// *useful* range (not the dead slow end), and knob 1 reaches max zoom. Phase 10 scroll retune.
/// </summary>
public static double ScrollKnobToSeconds(double knob)
{
knob = Math.Clamp(knob, 0, 1);
var fraction = ScrollKnobZoomFloor + (1.0 - ScrollKnobZoomFloor) * knob;
return FractionToSeconds(fraction);
}
/// <summary>Slider position [0, 1] -> visible seconds. 0 = zoomed out, 1 = zoomed in.</summary>
public static double FractionToSeconds(double fraction)
{
fraction = Math.Clamp(fraction, 0, 1);
var logMax = Math.Log(MaxVisibleSeconds);
var logMin = Math.Log(MinVisibleSeconds);
// Interpolate in log space from out (frac 0 -> logMax) to in (frac 1 -> logMin).
return Math.Exp(logMax + (logMin - logMax) * fraction);
}
/// <summary>Visible seconds -> slider position [0, 1]. Inverse of <see cref="FractionToSeconds"/>.</summary>
public static double SecondsToFraction(double seconds)
{
seconds = Math.Clamp(seconds, MinVisibleSeconds, MaxVisibleSeconds);
var logMax = Math.Log(MaxVisibleSeconds);
var logMin = Math.Log(MinVisibleSeconds);
return (logMax - Math.Log(seconds)) / (logMax - logMin);
}
}
@@ -38,7 +38,7 @@ public partial class PlayStateIcon : ComponentBase, IDisposable
{
// The cascade is IsFixed, so the provider's re-render never reaches us; subscribe to the
// multicast side-channel to re-render on every player state change. Reference-guarded so
// re-parametering is idempotent. Mirrors AudioPlayerBar / TracksView.
// re-parametering is idempotent. Mirrors AudioPlayerBar.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
@@ -2,30 +2,56 @@
@* Invariant trio shared by every medium's detail page: a back link, a masthead (title + artist),
a play/share affordance row wired to the streaming player, and slots for the medium-specific
hero visual and metadata block. TrackDetail and the Session/Mix detail pages all compose this;
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">
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; @BackLabel
</MudLink>
@* 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
the back link alone at the left edge. The Mix detail page fills the action with its lava-lamp
toggle and renders its knob band below via TopContent. *@
<div class="deepdrft-track-detail-top-row">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; @BackLabel
</MudLink>
@TopContent
@TopRightAction
</MudStack>
</div>
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h3">@Title</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
</div>
@* Play + share only make sense once a playable track is resolved. *@
@if (Track is not null)
@* The header region. A composer that wants the default masthead+play row supplies nothing; one
that needs a different arrangement (e.g. the Cut album's left-meta / right-cover split) supplies
its own Header fragment. Layout variance rides this slot, never a boolean flag (Phase 9 §5.3).
ShowHeader (a gate, not a layout flag) suppresses the region entirely for composers that carry
title/artist/play elsewhere — Mix overlays them on its hero. *@
@if (ShowHeader)
{
@if (Header is not null)
{
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<SharePopover EntryKey="@Track.EntryKey" />
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
@Header
}
else
{
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h3">@Title</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
</div>
@* Play only makes sense once a playable track is resolved. *@
@if (Track is not null)
{
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
</MudStack>
}
</MudStack>
}
</MudStack>
}
@Hero
@@ -37,4 +63,16 @@
</div>
}
@* Multi-track body region (the Cut album's track list). Single-track media leave it null. *@
@BodyContent
@* The default share row is bound to the single resolved track. A composer that owns its own share
affordance (the Cut header carries Play + Share inline) suppresses it via ShowShareRow. *@
@if (Track is not null && ShowShareRow)
{
<div class="deepdrft-share-row">
<SharePopover EntryKey="@Track.EntryKey" />
</div>
}
</div>
@@ -7,8 +7,8 @@ namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Shared detail-page chrome for any release medium: back link, masthead, play/share affordance,
/// and hero/meta slots. Owns the play-toggle wiring against the cascaded streaming player so each
/// detail page supplies only its data and medium-specific visuals. Extracted from the original
/// TrackDetail page, which is now a thin consumer of this scaffold.
/// detail page supplies only its data and medium-specific visuals. Each medium's detail page is a
/// thin consumer of this scaffold.
/// </summary>
public partial class ReleaseDetailScaffold : ComponentBase
{
@@ -24,12 +24,50 @@ public partial class ReleaseDetailScaffold : ComponentBase
[Parameter] public string BackHref { get; set; } = "/archive";
[Parameter] public string BackLabel { get; set; } = "Archive";
/// <summary>
/// Optional medium-specific content rendered as its own full-width band between the back/action top
/// row and the masthead — the "below the back button, above the details" band. The Mix detail page
/// uses it for the visualizer knob row (gated by Blazor on the lava-lamp toggle); other media leave
/// it null.
/// </summary>
[Parameter] public RenderFragment? TopContent { get; set; }
/// <summary>
/// Optional action rendered at the top-right of the container, on the same SpaceBetween row as the
/// back link (back link left, action right). The Mix detail page uses it for the lava-lamp
/// visualizer-settings button; other media leave it null and render the back link alone.
/// </summary>
[Parameter] public RenderFragment? TopRightAction { get; set; }
/// <summary>
/// Optional replacement for the header region (masthead + play affordance). When null, the
/// scaffold renders its default masthead+play row wired to <see cref="PlayTrack"/>. A composer
/// that needs a different header arrangement (e.g. the Cut album's left-meta / right-cover split
/// with its own Play/Share buttons) supplies this — layout variance rides the slot, never a
/// boolean flag (Phase 9 §5.3).
/// </summary>
[Parameter] public RenderFragment? Header { get; set; }
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
[Parameter] public RenderFragment? Hero { get; set; }
/// <summary>
/// Optional body region rendered below the meta block — the Cut album's multi-track listing.
/// Single-track media leave it null.
/// </summary>
[Parameter] public RenderFragment? BodyContent { get; set; }
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
[Parameter] public RenderFragment? MetaContent { get; set; }
/// <summary>
/// Gate for the header region (masthead + play, or a custom <see cref="Header"/>). A composer that
/// carries title/artist/play elsewhere — e.g. Mix overlays them on its hero — sets this false to
/// suppress the duplicate. A gate, not a layout flag, so it is slot-consistent with
/// <see cref="ShowMeta"/> / <see cref="ShowShareRow"/> (Phase 9 §5.3). Defaults to shown.
/// </summary>
[Parameter] public bool ShowHeader { get; set; } = true;
/// <summary>
/// Gate for the metadata block. Lets a consumer supply a <see cref="MetaContent"/> fragment but
/// suppress the divider + block when its data is empty (slot fragments cannot be conditionally
@@ -37,6 +75,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// </summary>
[Parameter] public bool ShowMeta { get; set; } = true;
/// <summary>
/// Gate for the default track-keyed share row at the foot of the scaffold. A composer that owns
/// its own share affordance (the Cut header carries Play + Share inline) sets this false to
/// suppress the duplicate. Defaults to shown.
/// </summary>
[Parameter] public bool ShowShareRow { get; set; } = true;
private async Task PlayTrack()
{
if (Track is null || PlayerService is null) return;
@@ -0,0 +1,5 @@
.deepdrft-share-row {
display: flex;
justify-content: center;
margin-top: 1.5rem;
}
@@ -1,9 +1,14 @@
@namespace DeepDrftPublic.Client.Controls
@* Card grid of releases that open their own detail page (/{DetailRoute}/{id}). Shared by the
Sessions and Mixes browse pages. Cuts intentionally do not use this they open the track
gallery filtered by album, a different navigation target. Fully controlled by the parent:
loading and item state are passed in. *@
@* The single release-card grid for every browse surface (Sessions, Mixes, Cuts, Archive). Cards
open a detail page; how a card computes its href is the only real divergence across surfaces, so
the parent supplies it one of two ways:
- DetailRoute (the simple default): every card links /{DetailRoute}/{entryKey} (Sessions, Mixes).
- HrefResolver (per-card): each card links HrefResolver(release), so Archive routes each card by
its own medium through the one ReleaseRoutes table, and Cuts routes to /cuts/{entryKey}.
HrefResolver wins when both are supplied. The card subtitle defaults to the artist; SubtitleResolver
overrides it (Cuts shows a track count instead). Fully controlled by the parent: loading and item
state are passed in. *@
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="release-gallery-container">
@@ -33,7 +38,7 @@
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="release-card-center">
<a href="@($"/{DetailRoute}/{release.Id}")" class="release-card-link">
<a href="@CardHref(release)" class="release-card-link">
<div class="release-card">
@if (!string.IsNullOrEmpty(release.ImagePath))
{
@@ -51,7 +56,7 @@
@release.Title
</MudText>
<MudText Typo="Typo.caption" Class="release-card-artist text-truncate">
@release.Artist
@(SubtitleResolver?.Invoke(release) ?? release.Artist)
</MudText>
</div>
</div>
@@ -68,8 +73,27 @@
[Parameter] public required IReadOnlyList<DeepDrftModels.DTOs.ReleaseDto> Releases { get; set; }
[Parameter] public bool Loading { get; set; }
/// <summary>Route segment for a card's detail page; a card links to /{DetailRoute}/{id}.</summary>
[Parameter] public required string DetailRoute { get; set; }
/// <summary>
/// Route segment for a card's detail page; a card links to /{DetailRoute}/{entryKey}. The simple
/// fixed-route default used by Sessions/Mixes. Ignored when <see cref="HrefResolver"/> is supplied.
/// </summary>
[Parameter] public string? DetailRoute { get; set; }
/// <summary>
/// Per-card href resolver. When supplied, a card links to its result instead of the
/// <see cref="DetailRoute"/>-based href, letting Archive route each card by its own medium and
/// Cuts route to /cuts/{entryKey} (both via <c>ReleaseRoutes.DetailHref</c>).
/// </summary>
[Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? HrefResolver { get; set; }
/// <summary>
/// Optional override for a card's subtitle line (defaults to the release artist). Cuts pass a
/// track-count label here.
/// </summary>
[Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? SubtitleResolver { get; set; }
[Parameter] public string EmptyMessage { get; set; } = "Nothing here yet";
private string CardHref(DeepDrftModels.DTOs.ReleaseDto release)
=> HrefResolver?.Invoke(release) ?? $"/{DetailRoute}/{release.EntryKey}";
}
@@ -1,4 +1,4 @@
.release-gallery-container {
::deep .release-gallery-container {
padding-top: 16px;
}
@@ -18,7 +18,6 @@
flex-direction: column;
width: 200px;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 120ms ease;
}
@@ -30,6 +29,7 @@
.release-card-cover {
width: 200px;
height: 200px;
border-radius: 8px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
@@ -0,0 +1,110 @@
@namespace DeepDrftPublic.Client.Controls
@* Shared background-image hero with all release metadata overlaid: top row (genre/date + share),
bottom row (optional cover thumb + title/artist + play). Single source of truth for the overlay
composition consumed by both Session and Mix detail. Purely presentational — owns no data fetch
and no player wiring; play/share ride in as slots so each page keeps its own toggle. Per-page
aspect/sizing variance rides the Class parameter (e.g. Mix's square `mix-hero`), never a fork. *@
@{
var hasGenre = !string.IsNullOrEmpty(Genre);
var hasDate = ReleaseDate is not null;
// Show the cover thumbnail only when it differs from the hero background — otherwise it would
// duplicate the same image. Mix passes CoverThumbKey=null, so this is false there for free.
var showCover = !string.IsNullOrEmpty(CoverThumbKey) && CoverThumbKey != HeroImageKey;
}
@* 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. *@
<div class="release-hero @Class">
@if (!string.IsNullOrEmpty(HeroImageKey))
{
<div class="release-hero-img"
style="@($"background-image: url('api/image/{Uri.EscapeDataString(HeroImageKey)}');")"></div>
}
else
{
<div class="release-hero-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@PlaceholderIcon" Color="Color.Primary" />
</div>
}
@* Darkening shim so overlaid text/controls stay legible over any image. *@
<div class="release-hero-shim"></div>
@* Top overlay: secondary details (genre, release date) and the share affordance. *@
<div class="release-hero-top">
<MudStack Row AlignItems="AlignItems.Center" Spacing="3" Class="release-hero-meta">
@if (hasGenre)
{
<MudChip T="string" Variant="Variant.Outlined" Class="release-overlay-chip">
@Genre
</MudChip>
}
@if (hasDate)
{
<div class="release-overlay-date">
<span class="release-overlay-label">Released</span>
<span class="release-overlay-value">@ReleaseDate!.Value.ToString("MMMM yyyy")</span>
</div>
}
</MudStack>
@if (ShareContent is not null)
{
<div class="release-hero-share">
@ShareContent
</div>
}
</div>
@* Bottom overlay: cover thumbnail, title/artist, and the play affordance in one row. *@
<div class="release-hero-bottom">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4" Class="release-hero-bottom-row">
@if (showCover)
{
<div class="release-cover-thumb">
<div class="deepdrft-track-detail-cover-art"
style="@($"background-image: url('api/image/{Uri.EscapeDataString(CoverThumbKey!)}');")"></div>
</div>
}
<div class="release-hero-titles">
<div class="release-overlay-title">@Title</div>
<div class="release-overlay-artist">@Artist</div>
</div>
@if (PlayContent is not null)
{
<div class="release-hero-play">
@PlayContent
</div>
}
</MudStack>
</div>
</div>
@code {
/// <summary>Background image entry key. Null renders the placeholder treatment.</summary>
[Parameter] public string? HeroImageKey { get; set; }
/// <summary>Material icon for the no-image placeholder (Session: Piano; Mix: Album).</summary>
[Parameter] public required string PlaceholderIcon { get; set; }
/// <summary>
/// Optional small cover thumbnail in the bottom row. Shown only when it differs from
/// <see cref="HeroImageKey"/> (otherwise it would duplicate the background). Mix passes null.
/// </summary>
[Parameter] public string? CoverThumbKey { get; set; }
[Parameter] public required string Title { get; set; }
[Parameter] public string? Artist { get; set; }
[Parameter] public string? Genre { get; set; }
[Parameter] public DateOnly? ReleaseDate { get; set; }
/// <summary>Share affordance slot — each page passes its own SharePopover with the right params.</summary>
[Parameter] public RenderFragment? ShareContent { get; set; }
/// <summary>Play affordance slot — each page passes its PlayStateIcon wired to its own toggle.</summary>
[Parameter] public RenderFragment? PlayContent { get; set; }
/// <summary>Extra class for per-page aspect/sizing variance (e.g. Mix's square `mix-hero`).</summary>
[Parameter] public string? Class { get; set; }
}
@@ -0,0 +1,178 @@
/* Background-image hero with the release detail overlaid directly on top, themed to match the
NowPlaying glassmorphic family. The overlay shell (.release-hero, shim, top/bottom rows) is
plain <div>s; per-page aspect/sizing variance rides an extra class (e.g. .mix-hero) layered on
.release-hero. The default aspect here is Sessions' wide hero. */
/* Positioning context for every overlay. A tall, dominant frame rather than the 16:9 strip. */
.release-hero {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
max-height: 70vh;
min-height: 420px;
margin-top: 1rem;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 12px 40px color-mix(in srgb, var(--mud-palette-text-secondary) 22%, transparent);
}
/* The background-image surface and placeholder are plain <div>s, so no ::deep is needed here. */
.release-hero-img {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.release-hero-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--mud-palette-surface);
}
::deep .release-hero-placeholder .mud-icon-root {
font-size: 120px;
}
/* Darkening gradient shim: stronger at the bottom (under the title/play row) and lighter toward
the middle, with a top darken so the genre/share overlay stays legible too. */
.release-hero-shim {
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(to bottom,
rgba(0, 0, 0, 0.55) 0%,
rgba(0, 0, 0, 0.15) 28%,
rgba(0, 0, 0, 0.15) 55%,
rgba(0, 0, 0, 0.75) 100%);
}
/* --- Top overlay: genre / date / share --- */
.release-hero-top {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem;
}
.release-overlay-date {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.release-overlay-label {
font-family: var(--deepdrft-font-mono);
font-size: 0.6rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--deepdrft-green-accent);
}
.release-overlay-value {
font-family: var(--deepdrft-font-body);
font-size: 0.8rem;
color: var(--deepdrft-white);
}
/* Genre chip themed to the glassmorphic NowPlaying surface. The class lands on MudChip's native
.mud-chip output, so ::deep is required to reach it. */
::deep .release-overlay-chip.mud-chip {
background: rgba(250, 250, 248, 0.06);
border: 1px solid rgba(250, 250, 248, 0.12);
backdrop-filter: blur(8px);
color: var(--deepdrft-white);
font-family: var(--deepdrft-font-mono);
letter-spacing: 0.12em;
}
/* --- Bottom overlay: cover thumb / title / play --- */
.release-hero-bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 2;
padding: 1.5rem;
}
.release-cover-thumb {
flex: 0 0 auto;
width: 96px;
height: 96px;
overflow: hidden;
border: 1px solid rgba(250, 250, 248, 0.12);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
}
/* The cover-thumb art surface is a plain <div>, so no ::deep is needed. */
.release-cover-thumb .deepdrft-track-detail-cover-art {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.release-hero-titles {
flex: 1 1 auto;
min-width: 0;
}
.release-overlay-title {
font-family: var(--deepdrft-font-display);
font-size: clamp(1.75rem, 4vw, 2.75rem);
font-weight: 400;
line-height: 1.1;
color: var(--deepdrft-white);
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
}
.release-overlay-artist {
font-family: var(--deepdrft-font-body);
font-size: 0.85rem;
letter-spacing: 0.08em;
color: rgba(250, 250, 248, 0.7);
margin-top: 0.35rem;
}
.release-hero-play {
flex: 0 0 auto;
}
/* The play affordance and share button sit over a dark image force their icon glyphs to the
light theme color regardless of MudBlazor's Secondary palette. Both PlayStateIcon and
SharePopover render MudIconButton / MudProgressCircular internals, so ::deep is required. */
::deep .release-hero-play .mud-icon-button,
::deep .release-hero-play .mud-progress-circular,
::deep .release-hero-share .mud-icon-button {
color: var(--deepdrft-white);
}
@media (max-width: 599.98px) {
.release-hero {
aspect-ratio: 3 / 4;
min-height: 380px;
}
/* release-hero-bottom-row rides on MudStack's native output div, so ::deep is required. */
::deep .release-hero-bottom-row {
flex-wrap: wrap;
}
.release-cover-thumb {
width: 72px;
height: 72px;
}
}
@@ -1,62 +1,78 @@
@namespace DeepDrftPublic.Client.Controls
<MudTooltip Text="Share">
<MudIconButton Icon="@Icons.Material.Filled.Share"
Size="Size.Large"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Share this track" />
</MudTooltip>
@* Overlay is viewport-level/fixed regardless of DOM nesting; placing it outside
the wrapper is harmless/cleaner but doesn't change its behavior.
AutoClose is deliberately OFF: its modeless pointer-down tracking closes the
popover on any click outside the overlay's DOM subtree — including the portaled
embed checkbox/copy-button — which kills the embed affordance. Backdrop
dismissal is handled by OnClick alone. *@
<MudOverlay Visible="@_open" OnClick="@Close" />
<MudOverlay Visible="@_open" OnClick="@Close" AutoClose="true" />
@* Activator and popover share this wrapper so MudPopover's anchoring reads the
button's bounding rect. No shrink-wrap sizing — width/min-width come from the
documented RelativeWidth="Adaptive" on the popover, not from container CSS. *@
<div>
<MudTooltip Text="Share">
<MudIconButton Icon="@Icons.Material.Filled.Share"
Size="Size.Large"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="@(IsReleaseMode ? "Share this release" : "Share this track")" />
</MudTooltip>
<MudPopover Open="@_open"
Fixed="false"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
Class="deepdrft-share-popover">
<MudStack Spacing="2" Class="deepdrft-share-popover-body">
<MudPopover Open="@_open"
Fixed="true"
RelativeWidth="DropdownWidth.Adaptive"
AnchorOrigin="Origin.BottomLeft"
TransformOrigin="Origin.TopLeft"
Class="deepdrft-share-popover">
<MudStack Spacing="2" Class="deepdrft-share-popover-body">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudButton StartIcon="@Icons.Material.Filled.Link"
Variant="Variant.Text"
Color="Color.Primary"
OnClick="@CopyLink">
Copy link
</MudButton>
@if (_linkCopied)
{
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
}
</MudStack>
<MudDivider />
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" />
@if (_embed)
{
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudTextField Value="@EmbedSnippet"
T="string"
ReadOnly="true"
Variant="Variant.Outlined"
Lines="3"
Margin="Margin.Dense"
Class="deepdrft-share-embed-field" />
<MudStack AlignItems="AlignItems.Center" Spacing="0">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Color="Color.Primary"
OnClick="@CopyEmbed"
aria-label="Copy embed snippet" />
@if (_embedCopied)
{
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
}
</MudStack>
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudButton StartIcon="@Icons.Material.Filled.Link"
Variant="Variant.Text"
Color="Color.Primary"
OnClick="@CopyLink">
Copy link
</MudButton>
@if (_linkCopied)
{
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
}
</MudStack>
}
</MudStack>
</MudPopover>
@* Embed is a single-track affordance only; a release page is not a single-track embed (§3b.3). *@
@if (!IsReleaseMode)
{
<MudDivider />
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" />
@if (_embed)
{
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudTextField Value="@EmbedSnippet"
T="string"
ReadOnly="true"
Variant="Variant.Outlined"
Lines="3"
Margin="Margin.Dense"
Class="deepdrft-share-embed-field" />
<MudStack AlignItems="AlignItems.Center" Spacing="0">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Color="Color.Primary"
OnClick="@CopyEmbed"
aria-label="Copy embed snippet" />
@if (_embedCopied)
{
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
}
</MudStack>
</MudStack>
}
}
</MudStack>
</MudPopover>
</div>
@@ -1,20 +1,35 @@
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Share affordance for the track detail page: a popover offering a canonical-link copy
/// and an optional iframe embed snippet. Clipboard writes go through navigator.clipboard;
/// each copy shows a transient "Copied!" confirmation that resets after a short delay.
/// Share affordance with two modes from one source of clipboard/popover-chrome logic
/// (Phase 11 §3b). Track mode (<see cref="EntryKey"/> set) offers a canonical-link copy plus an
/// optional iframe embed snippet. Release mode (<see cref="ReleaseEntryKey"/> set) is copy-link-only —
/// it copies the absolute form of the release's canonical detail URL and hides the embed
/// affordance, since a release page is not a single-track embed. Clipboard writes go through
/// navigator.clipboard; each copy shows a transient "Copied!" confirmation that resets after a
/// short delay.
/// </summary>
public partial class SharePopover : ComponentBase, IDisposable
{
/// <summary>Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.</summary>
[Parameter] public string? EntryKey { get; set; }
/// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and omits the embed option.</summary>
[Parameter] public string? ReleaseEntryKey { get; set; }
/// <summary>Release mode: the medium of the release, used to resolve its canonical detail route.</summary>
[Parameter] public ReleaseMedium ReleaseMedium { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
private bool IsReleaseMode => ReleaseEntryKey is not null;
private bool _open;
private bool _embed;
private bool _linkCopied;
@@ -32,7 +47,14 @@ public partial class SharePopover : ComponentBase, IDisposable
}
}
private string TrackUrl => $"{Navigation.BaseUri}track/{EntryKey}";
// The URL "Copy link" places on the clipboard. Release mode resolves the canonical detail
// route (which carries a leading slash) and composes it against BaseUri (which carries a
// trailing slash) — trim one to avoid a doubled separator.
private string LinkUrl => IsReleaseMode
? $"{Navigation.BaseUri.TrimEnd('/')}{ReleaseRoutes.DetailHref(ReleaseEntryKey!, ReleaseMedium)}"
: TrackUrl;
private string TrackUrl => $"{Navigation.BaseUri}tracks/{EntryKey}";
private string EmbedSnippet =>
$"""<iframe src="{Navigation.BaseUri}FramePlayer?TrackEntryKey={EntryKey}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
@@ -43,7 +65,7 @@ public partial class SharePopover : ComponentBase, IDisposable
private async Task CopyLink()
{
if (await CopyToClipboard(TrackUrl))
if (await CopyToClipboard(LinkUrl))
{
_linkCopied = true;
await ResetAfterDelay(() => _linkCopied = false);
@@ -1,213 +0,0 @@
@{
var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey);
var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null;
var hasArt = !string.IsNullOrEmpty(TrackModel?.Release?.ImagePath);
}
@if (ViewMode == GalleryViewMode.Grid)
{
<div class="deepdrft-track-card-container @(hasArt ? "deepdrft-track-card-container--art" : "")">
@* Cover and title/artist link to the detail page; the play button (below, outside any
anchor) stays the sole playback entry point. display:contents keeps the grid intact. *@
@if (hasLink)
{
<a href="@trackHref" class="deepdrft-track-card-link">
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
{
<div class="deepdrft-track-card-fallback"></div>
}
</a>
}
else if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
{
<div class="deepdrft-track-card-fallback"></div>
}
<div class="deepdrft-track-card-content">
@if (hasLink)
{
<a href="@trackHref" class="deepdrft-track-card-link">
<div class="deepdrft-track-info-top">
<MudText Typo="Typo.subtitle1"
Class="deepdrft-track-title text-truncate mb-1">
@TrackModel?.TrackName
</MudText>
<MudText Typo="Typo.caption"
Class="deepdrft-track-artist text-truncate mb-2">
@TrackModel?.Release?.Artist
</MudText>
</div>
</a>
}
else
{
<div class="deepdrft-track-info-top">
<MudText Typo="Typo.subtitle1"
Class="deepdrft-track-title text-truncate mb-1">
@TrackModel?.TrackName
</MudText>
<MudText Typo="Typo.caption"
Class="deepdrft-track-artist text-truncate mb-2">
@TrackModel?.Release?.Artist
</MudText>
</div>
}
<div class="deepdrft-track-info-middle">
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Title))
{
<MudText Typo="Typo.caption"
Class="deepdrft-track-meta text-truncate">
@TrackModel.Release!.Title
</MudText>
}
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Release!.Genre
</MudChip>
}
</div>
<div class="deepdrft-track-info-bottom">
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption"
Class="deepdrft-track-meta">
@TrackModel.Release!.ReleaseDate!.Value.Year
</MudText>
}
else
{
<div></div>
}
<MudFab Color="Color.Tertiary"
Size="Size.Medium"
StartIcon="@PlayPauseIcon"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@PlayClick"/>
</div>
</div>
</div>
}
else
{
<div class="deepdrft-track-row @(IsPlaying ? "deepdrft-track-row--playing" : "")">
<MudFab Color="Color.Tertiary"
Size="Size.Medium"
StartIcon="@PlayPauseIcon"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@PlayClick"
Class="deepdrft-track-row-fab"/>
@if (hasLink)
{
<a href="@trackHref" class="deepdrft-track-row-link">
@* art thumb *@
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-row-thumb"
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
{
<div class="deepdrft-track-row-thumb deepdrft-track-row-thumb--fallback"></div>
}
@* text block *@
<div class="deepdrft-track-row-text">
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
@TrackModel?.Release?.Artist
</MudText>
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
@TrackModel?.TrackName
</MudText>
</div>
@* right metadata *@
<div class="deepdrft-track-row-meta">
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Release!.Genre
</MudChip>
}
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
@TrackModel.Release!.ReleaseDate!.Value.Year
</MudText>
}
</div>
</a>
}
else
{
@* same structure without anchor *@
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
{
<div class="deepdrft-track-row-thumb"
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
</div>
}
else
{
<div class="deepdrft-track-row-thumb deepdrft-track-row-thumb--fallback"></div>
}
<div class="deepdrft-track-row-text">
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
@TrackModel?.Release?.Artist
</MudText>
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
@TrackModel?.TrackName
</MudText>
</div>
<div class="deepdrft-track-row-meta">
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Release!.Genre
</MudChip>
}
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
@TrackModel.Release!.ReleaseDate!.Value.Year
</MudText>
}
</div>
}
</div>
}
@@ -1,34 +0,0 @@
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace DeepDrftPublic.Client.Controls;
public partial class TrackCard : ComponentBase
{
[Parameter] public required TrackDto TrackModel { get; set; }
[Parameter] public EventCallback<TrackDto> OnPlay { get; set; }
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
[Parameter] public bool IsPlaying { get; set; } = false;
[Parameter] public bool IsPaused { get; set; } = false;
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
// Pause only when actively playing; every other state (idle, paused) reads as "press to play".
private bool IsActivelyPlaying => IsPlaying && !IsPaused;
private string PlayPauseIcon =>
IsActivelyPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
private async Task PlayClick()
{
if (IsActivelyPlaying)
{
if (OnPause.HasDelegate)
await OnPause.InvokeAsync(TrackModel);
}
else if (OnPlay.HasDelegate)
{
await OnPlay.InvokeAsync(TrackModel);
}
}
}
@@ -1,197 +0,0 @@
/* Container transparent so the absolute-positioned fallback panel or album art
controls the card's background. Glass edge matches NowPlayingCard vocabulary. */
.deepdrft-track-card-container {
width: 250px;
height: 250px;
min-width: 250px;
position: relative;
overflow: hidden;
background: transparent;
border: 2px solid var(--mud-palette-secondary);
}
.deepdrft-track-card-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
filter: brightness(0.7);
}
.deepdrft-track-card-content {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px;
background: linear-gradient(to top,
rgba(13, 27, 42, 0.75) 0%,
rgba(13, 27, 42, 0.35) 45%,
rgba(13, 27, 42, 0.00) 100%);
}
/* Fallback panel solid navy, opaque so the card reads correctly on both
light and dark page backgrounds. Semi-transparent + blur washes out on white. */
.deepdrft-track-card-fallback {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: var(--deepdrft-navy-mid, #162437);
border: 1px solid rgba(250, 250, 248, 0.12);
}
/* Title: off-white matches .np-title.
::deep required: MudText renders its own element, so Blazor isolation
won't stamp b-{hash} on it; ::deep pierces into child component output. */
::deep .deepdrft-track-title { color: var(--deepdrft-white, #FAFAF8); }
/* Artist: muted off-white — green reserved for interactive elements (FAB, chip). ::deep for same reason. */
::deep .deepdrft-track-artist { color: rgba(250, 250, 248, 0.55); }
/* Meta: muted off-white — matches .np-sub. ::deep for same reason. */
::deep .deepdrft-track-meta { color: rgba(250, 250, 248, 0.45); }
/* FAB always green-interactive card is always dark glass regardless of page theme.
.mud-button-filled-tertiary specificity (0,1,0) in MudBlazor; our (0,1,1) wins. */
::deep .mud-button-filled-tertiary {
background-color: var(--deepdrft-green-interactive, #3aa163);
color: var(--deepdrft-white, #FAFAF8);
}
/* Genre chip always green-accent outline/text on the dark glass card. */
::deep .deepdrft-genre-chip.mud-chip-outlined {
border-color: var(--deepdrft-green-accent, #3D7A68);
color: var(--deepdrft-green-accent, #3D7A68);
}
::deep .deepdrft-genre-chip.mud-chip-color-tertiary {
color: var(--deepdrft-green-accent, #3D7A68);
}
.deepdrft-track-info-middle { margin: 8px 0; }
.deepdrft-track-info-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
@media (max-width: 480px) {
.deepdrft-track-card-container {
min-width: 200px;
width: 200px;
height: 200px;
}
}
/* ── Mode A: hover-reveal overlay (art cards only) ──────────────────────── */
/* Gate the hidden-at-rest rule on (a) art present and (b) a hover-capable pointer.
Fallback cards (no --art modifier) and touch devices always show the overlay. */
@media (hover: hover) and (pointer: fine) {
.deepdrft-track-card-container--art .deepdrft-track-card-content {
opacity: 0;
background: transparent;
transition: opacity 180ms ease, background-color 180ms ease;
}
.deepdrft-track-card-container--art:hover .deepdrft-track-card-content {
opacity: 1;
background: rgba(22, 36, 55, 0.82);
transition: opacity 180ms ease, background-color 180ms ease;
}
}
/* ── Mode B: list row ───────────────────────────────────────────────────── */
.deepdrft-track-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
height: 80px;
padding: 8px 16px;
background: var(--mud-palette-surface);
border: 1px solid var(--mud-palette-divider);
border-radius: 4px;
box-sizing: border-box;
width: 100%;
}
.deepdrft-track-row-link {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
flex: 1 1 auto;
min-width: 0;
text-decoration: none;
color: inherit;
}
::deep .deepdrft-track-row-fab {
flex: 0 0 auto;
}
.deepdrft-track-row-thumb {
flex: 0 0 64px;
width: 64px;
height: 64px;
background-size: cover;
background-position: center;
border-radius: 2px;
}
.deepdrft-track-row-thumb--fallback {
background: var(--deepdrft-navy-mid);
border: 1px solid var(--mud-palette-divider);
}
.deepdrft-track-row-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.deepdrft-track-row-meta {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 4px;
}
@media (max-width: 480px) {
.deepdrft-track-row {
height: auto;
min-height: 72px;
padding: 8px 12px;
gap: 10px;
}
.deepdrft-track-row-thumb {
flex: 0 0 48px;
width: 48px;
height: 48px;
}
}
.deepdrft-track-row--playing {
border-left: 3px solid var(--deepdrft-green-interactive, #3aa163);
}
/* ── Mode B text: theme-aware overrides (navy on light / off-white on dark) ─ */
/* The global ::deep rules above hard-code off-white for the dark glass grid cards.
List rows use --mud-palette-surface as their background, so text must follow
the theme. These selectors have higher specificity (.deepdrft-track-row[b-hash]
vs plain [b-hash]) and win in the cascade. */
.deepdrft-track-row ::deep .deepdrft-track-title,
.deepdrft-track-row ::deep .deepdrft-track-artist,
.deepdrft-track-row ::deep .deepdrft-track-meta {
color: var(--mud-palette-text-primary);
}
@@ -1,36 +0,0 @@
@if (ViewMode == GalleryViewMode.Grid)
{
<MudContainer MaxWidth="MaxWidth.Large" Class="tracks-gallery-container">
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var track in Tracks)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="deepdrft-track-gallery-item-center">
<TrackCard TrackModel="@track"
ViewMode="@ViewMode"
IsPlaying="@(IsPlaying && ActiveTrack?.Id == track.Id)"
IsPaused="@(IsPaused && ActiveTrack?.Id == track.Id)"
OnPlay="@HandlePlayClick"
OnPause="@HandlePauseClick"/>
</div>
</MudItem>
}
</MudGrid>
</MudContainer>
}
else
{
<MudContainer MaxWidth="MaxWidth.Large">
<div class="deepdrft-track-list">
@foreach (var track in Tracks)
{
<TrackCard TrackModel="@track"
ViewMode="@ViewMode"
IsPlaying="@(IsPlaying && ActiveTrack?.Id == track.Id)"
IsPaused="@(IsPaused && ActiveTrack?.Id == track.Id)"
OnPlay="@HandlePlayClick"
OnPause="@HandlePauseClick"/>
}
</div>
</MudContainer>
}
@@ -1,26 +0,0 @@
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls;
public partial class TracksGallery : ComponentBase
{
[Parameter] public IEnumerable<TrackDto> Tracks { get; set; } = [];
// Controlled play-state inputs: the parent owns playback truth (the player service)
// and drives these. The gallery is presentational — it only matches by id to decide
// which card reflects the active state.
[Parameter] public TrackDto? ActiveTrack { get; set; }
[Parameter] public bool IsPlaying { get; set; }
[Parameter] public bool IsPaused { get; set; }
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
[Parameter] public EventCallback<TrackDto> OnPlay { get; set; }
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
private Task HandlePlayClick(TrackDto track) =>
OnPlay.HasDelegate ? OnPlay.InvokeAsync(track) : Task.CompletedTask;
private Task HandlePauseClick(TrackDto track) =>
OnPause.HasDelegate ? OnPause.InvokeAsync(track) : Task.CompletedTask;
}
@@ -1,15 +0,0 @@
.tracks-gallery-container {
box-sizing: border-box;
}
.deepdrft-track-gallery-item-center {
display: flex;
justify-content: center;
}
.deepdrft-track-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
@@ -1,4 +1,8 @@
.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. */
position: relative;
z-index: 1;
background: var(--deepdrft-white);
border-top: 1px solid var(--deepdrft-border);
padding: 3rem;
+2 -2
View File
@@ -22,8 +22,8 @@ public static class Pages
// ARCHIVE (→ the release-cardinal /archive browser) carries the three medium modes as Children.
// Above the medium breakpoint the desktop nav flattens them into inline appbar links beside
// ARCHIVE (no popover); below the breakpoint the mobile hamburger renders them as an indented
// sub-list under ARCHIVE. /tracks and /genres are intentionally absent from the nav (8.I) —
// their routes (TracksView, GenresView) remain reachable by direct URL.
// sub-list under ARCHIVE. /genres is intentionally absent from the nav (8.I) — its route
// (GenresView) remains reachable by direct URL.
public static readonly List<PageRoute> MenuPages =
[
new()
+8 -59
View File
@@ -1,63 +1,12 @@
@page "/cuts"
@using DeepDrftPublic.Client.Controls
<PageTitle>DeepDrft Cuts</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container">
@if (_loading)
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="album-card-center">
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
</div>
</MudItem>
}
</MudGrid>
}
else if (_albums.Count == 0)
{
<div class="albums-empty">
<MudText Typo="Typo.h6">No albums yet</MudText>
</div>
}
else
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var album in _albums)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="album-card-center">
<div class="album-card"
role="button"
tabindex="0"
@onclick="@(() => OpenAlbum(album.Title))">
@if (!string.IsNullOrEmpty(album.ImagePath))
{
<div class="album-card-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(album.ImagePath)');">
</div>
}
else
{
<div class="album-card-cover album-card-cover--fallback"></div>
}
<div class="album-card-body">
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
@album.Title
</MudText>
<MudText Typo="Typo.caption" Class="album-card-count">
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
</MudText>
</div>
</div>
</div>
</MudItem>
}
</MudGrid>
}
</MudContainer>
</div>
@* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table.
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
<ReleaseGallery Releases="@_albums"
Loading="@_loading"
HrefResolver="@ReleaseRoutes.DetailHref"
SubtitleResolver="@TrackCountLabel"
EmptyMessage="No albums yet" />
@@ -1,5 +1,6 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Models.Common;
@@ -9,8 +10,8 @@ namespace DeepDrftPublic.Client.Pages;
/// <summary>
/// Medium-filtered release gallery. Routed at <c>/cuts</c> (Cut releases) and parameterized by
/// <see cref="Medium"/> so the same component can back any medium's card grid without a fork.
/// Cards open the track gallery filtered to that release's album title, preserving the original
/// /albums ergonomics.
/// Cards open the release's dedicated detail page via <see cref="ReleaseRoutes.DetailHref(ReleaseDto)"/>
/// (a Cut routes to <c>/cuts/{entryKey}</c>), the single source for medium→route resolution (Phase 11 §2).
/// </summary>
public partial class AlbumsView : ComponentBase, IDisposable
{
@@ -18,7 +19,6 @@ public partial class AlbumsView : ComponentBase, IDisposable
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
// The medium whose releases this grid shows. Defaults to Cut for the /cuts route; other media
// can reuse this component by passing a different value. Drives both the fetch filter and the
@@ -33,8 +33,8 @@ public partial class AlbumsView : ComponentBase, IDisposable
protected override async Task OnInitializedAsync()
{
// Bridge the prerendered fetch across the prerender -> WASM seam (see TracksView). Without
// this, the WASM pass re-fetches and replays the card entrance animations.
// Bridge the prerendered fetch across the prerender -> WASM seam (see MediumBrowseBase).
// Without this, the WASM pass re-fetches and replays the card entrance animations.
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistAlbums);
if (PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored) && restored is not null)
@@ -58,8 +58,9 @@ public partial class AlbumsView : ComponentBase, IDisposable
return Task.CompletedTask;
}
private void OpenAlbum(string album)
=> Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}");
// Cut cards show track count where the shared card otherwise shows the artist.
private static string TrackCountLabel(ReleaseDto album)
=> $"{album.TrackCount} {(album.TrackCount == 1 ? "track" : "tracks")}";
public void Dispose() => _persistingSubscription.Dispose();
}
@@ -1,58 +0,0 @@
.albums-view-container {
padding-top: 16px;
}
.album-card-center {
display: flex;
justify-content: center;
width: 100%;
}
.album-card {
display: flex;
flex-direction: column;
width: 200px;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 120ms ease;
}
.album-card:hover {
transform: translateY(-4px);
}
.album-card-cover {
width: 200px;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.album-card-cover--fallback {
background-color: var(--mud-palette-dark, #1a2238);
}
.album-card-body {
padding: 8px 4px 0 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* album-card-title / album-card-count ride on MudText, a child Razor component whose
root Blazor isolation does not scope-stamp; ::deep pierces into its output. */
::deep .album-card-title {
font-weight: 600;
}
::deep .album-card-count {
opacity: 0.7;
}
.albums-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
+52 -97
View File
@@ -1,116 +1,71 @@
@page "/archive"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
<PageTitle>DeepDrft Archive</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
@* Search + filter affordances are interactive-only: the debounce timer and chip selection
need WASM. During prerender/non-interactive they are hidden, matching TracksView's gate.
need WASM. During prerender/non-interactive they are hidden behind the interactive gate.
The release grid still prerenders so the archive is meaningful before hydration. *@
@if (RendererInfo.IsInteractive)
{
<div class="archive-search-row">
<MudTextField T="string"
Value="@SearchText"
ValueChanged="@OnSearchInput"
Immediate="true"
DebounceInterval="400"
Placeholder="Search releases or artists"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
Class="archive-search-field"/>
</div>
<div class="archive-filter-row">
<MudToggleGroup T="ReleaseMedium?"
Value="@_selectedMedium"
ValueChanged="@OnMediumSelected"
SelectionMode="SelectionMode.SingleSelection"
Color="Color.Primary"
Size="Size.Small"
Class="archive-medium-toggle">
<MudToggleItem T="ReleaseMedium?" Value="@(null)">All</MudToggleItem>
@foreach (var medium in _media)
{
<MudToggleItem T="ReleaseMedium?" Value="@medium">@MediumLabel(medium)</MudToggleItem>
}
</MudToggleGroup>
</div>
@if (_genres.Count > 0)
{
<div class="archive-filter-row">
<MudChipSet T="string"
SelectedValue="@_selectedGenre"
SelectedValueChanged="@OnGenreSelected"
SelectionMode="SelectionMode.ToggleSelection"
Class="archive-genre-chips">
@foreach (var genre in _genres)
{
<MudChip T="string" Value="@genre.Genre">@genre.Genre</MudChip>
}
</MudChipSet>
<div class="archive-controls-row">
<div class="archive-controls-search">
<MudTextField T="string"
Value="@SearchText"
ValueChanged="@OnSearchInput"
Immediate="true"
DebounceInterval="400"
Placeholder="Search releases or artists"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
Class="archive-search-field"/>
</div>
}
}
@if (_loading)
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var _ in Enumerable.Range(0, 8))
<div class="archive-controls-medium">
<MudToggleGroup T="ReleaseMedium?"
Value="@_selectedMedium"
ValueChanged="@OnMediumSelected"
SelectionMode="SelectionMode.SingleSelection"
Color="Color.Primary"
Size="Size.Small"
Class="archive-medium-toggle">
<MudToggleItem T="ReleaseMedium?" Value="@(null)">All</MudToggleItem>
@foreach (var medium in _media)
{
<MudToggleItem T="ReleaseMedium?" Value="@medium">@MediumLabel(medium)</MudToggleItem>
}
</MudToggleGroup>
</div>
@if (_genres.Count > 0)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="archive-card-center">
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
</div>
</MudItem>
<div class="archive-controls-genre">
<MudChipSet T="string"
SelectedValue="@_selectedGenre"
SelectedValueChanged="@OnGenreSelected"
SelectionMode="SelectionMode.ToggleSelection"
Class="archive-genre-chips">
@foreach (var genre in _genres)
{
<MudChip T="string" Value="@genre.Genre">@genre.Genre</MudChip>
}
</MudChipSet>
</div>
}
</MudGrid>
}
else if (_releases.Count == 0)
{
<div class="archive-empty">
<MudText Typo="Typo.h6">No releases found</MudText>
</div>
}
else
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var release in _releases)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="archive-card-center">
<a href="@DetailHref(release)" class="archive-card-link">
<div class="archive-release-card">
@if (!string.IsNullOrEmpty(release.ImagePath))
{
<div class="archive-release-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(release.ImagePath)');">
</div>
}
else
{
<div class="archive-release-cover archive-release-cover--fallback"></div>
}
<div class="archive-release-body">
<MudText Typo="Typo.subtitle1" Class="archive-release-title text-truncate">
@release.Title
</MudText>
<MudText Typo="Typo.caption" Class="archive-release-artist text-truncate">
@release.Artist
</MudText>
</div>
</div>
</a>
</div>
</MudItem>
}
</MudGrid>
}
</MudContainer>
@* The grid itself is the shared release-card component; each card routes by its own medium through
the one ReleaseRoutes table. The Archive-specific search/filter chrome above stays here. *@
<ReleaseGallery Releases="@_releases"
Loading="@_loading"
HrefResolver="@ReleaseRoutes.DetailHref"
EmptyMessage="No releases found" />
</div>
@@ -9,9 +9,14 @@ namespace DeepDrftPublic.Client.Pages;
/// The public archive: a release-cardinal searchable browser over every release across all media
/// (Phase 9 §8.H, decision H2). Replaces the former three-card medium overview. Search (Title /
/// Artist), an enum-driven medium filter, and a genre filter narrow the release list; each card
/// routes to its per-medium detail. Mirrors the <see cref="TracksView"/> seam: the unfiltered first
/// page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor replays
/// the card entrance animations.
/// routes to its per-medium detail. Mirrors the <see cref="MediumBrowseBase"/> seam: the unfiltered
/// first page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor
/// replays the card entrance animations.
///
/// Filter state is URL-bound (Phase 11 §5): <c>q</c> / <c>medium</c> / <c>genre</c> enter via
/// <see cref="SupplyParameterFromQueryAttribute"/> and leave via <see cref="NavigationManager.NavigateTo(string)"/>.
/// The URL is the single source of truth — filter handlers only navigate; the seed-and-fetch reaction
/// lives in <see cref="OnParametersSetAsync"/> so back/forward history "just works" (§5.3 Option A).
/// </summary>
public partial class ArchiveView : ComponentBase, IDisposable
{
@@ -24,6 +29,14 @@ public partial class ArchiveView : ComponentBase, IDisposable
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
// Query-string-bound filter inputs (§5.1). All optional; an absent param means "no filter on that
// axis". `medium` is the lowercase enum token the data service already speaks, parsed back with
// Enum.TryParse(ignoreCase) + Enum.IsDefined (the BatchUpload / API posture).
[SupplyParameterFromQuery(Name = "q")] public string? QueryParam { get; set; }
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
[SupplyParameterFromQuery(Name = "genre")] public string? GenreParam { get; set; }
// Medium filter chips are enum-driven so a fourth medium surfaces a chip from one lookup entry,
// with no markup fork (Phase 9 extension discipline).
@@ -41,24 +54,50 @@ public partial class ArchiveView : ComponentBase, IDisposable
private PersistingComponentStateSubscription _persistingSubscription;
// Idempotency guard for the OnParametersSet reaction (§5.3): a same-route query change re-fires
// OnParametersSet, and the search debounce can race a rapid medium-chip nav. We only re-seed and
// re-fetch when the composed filter triple actually changed. Sentinel != any real key so the first
// reaction always runs.
private string? _loadedFilterKey;
private bool HasActiveFilter =>
_selectedMedium is not null
|| !string.IsNullOrWhiteSpace(_selectedGenre)
|| !string.IsNullOrWhiteSpace(SearchText);
protected override async Task OnInitializedAsync()
protected override Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
return Task.CompletedTask;
}
// The seed-and-fetch reaction lives here, not in OnInitialized: a same-route query change reuses
// the component and fires only OnParametersSet (§5.3). Keyed off the composed filter triple so an
// identical param set (debounce vs. chip-nav race) is a no-op.
protected override async Task OnParametersSetAsync()
{
// Seed filter fields from the URL before the restore/fetch decision so HasActiveFilter reflects
// the requested URL (a direct /archive?medium=mix load must fetch, not restore the bridge).
SeedFromQuery();
var filterKey = ComposeFilterKey();
if (filterKey == _loadedFilterKey)
return;
var firstReaction = _loadedFilterKey is null;
_loadedFilterKey = filterKey;
// The genre chip source is the release-cardinal distinct-genre list (already sourced from the
// release join — see GetDistinctGenresAsync). It only renders interactively, so it is fetched
// lazily on the interactive pass rather than persisted.
if (RendererInfo.IsInteractive)
if (RendererInfo.IsInteractive && _genres.Count == 0)
await LoadGenres();
// The prerendered page is always the unfiltered first page. Restore it only when no filter is
// active; a filtered interactive pass must fetch its own narrowed result instead.
if (!HasActiveFilter
// The prerendered page is always the unfiltered first page. Restore it only on the first
// reaction and only when no filter is active; a filtered load (or a later filter change) must
// fetch its own narrowed result instead.
if (firstReaction
&& !HasActiveFilter
&& PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored)
&& restored is not null)
{
@@ -70,6 +109,22 @@ public partial class ArchiveView : ComponentBase, IDisposable
await LoadReleases();
}
// Maps the query-string params onto the filter fields. `medium` parsed leniently (ignoreCase) and
// validated with Enum.IsDefined so a stray token degrades to "All" rather than throwing.
private void SeedFromQuery()
{
SearchText = string.IsNullOrWhiteSpace(QueryParam) ? null : QueryParam;
_selectedGenre = string.IsNullOrWhiteSpace(GenreParam) ? null : GenreParam;
_selectedMedium =
!string.IsNullOrWhiteSpace(MediumParam)
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
&& Enum.IsDefined(medium)
? medium
: null;
}
private string ComposeFilterKey() => $"{SearchText}|{_selectedMedium}|{_selectedGenre}";
private async Task LoadGenres()
{
var result = await TrackData.GetGenres();
@@ -95,35 +150,42 @@ public partial class ArchiveView : ComponentBase, IDisposable
_loading = false;
}
// Filter handlers only navigate (§5.3 Option A): the query-param change re-runs the seed-and-fetch
// reaction in OnParametersSet, keeping the URL the single source of truth and making back/forward
// history correct for free.
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
// burst reaches here. Re-fetches with the composed filter (search + medium + genre).
private async Task OnSearchInput(string? value)
{
SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
await LoadReleases();
}
// burst reaches here.
private void OnSearchInput(string? value) => NavigateToFilter(
search: string.IsNullOrWhiteSpace(value) ? null : value,
medium: _selectedMedium,
genre: _selectedGenre);
private async Task OnMediumSelected(ReleaseMedium? medium)
{
_selectedMedium = medium;
await LoadReleases();
}
private void OnMediumSelected(ReleaseMedium? medium) => NavigateToFilter(
search: SearchText,
medium: medium,
genre: _selectedGenre);
private async Task OnGenreSelected(string? genre)
{
_selectedGenre = string.IsNullOrWhiteSpace(genre) ? null : genre;
await LoadReleases();
}
private void OnGenreSelected(string? genre) => NavigateToFilter(
search: SearchText,
medium: _selectedMedium,
genre: string.IsNullOrWhiteSpace(genre) ? null : genre);
// Per-medium detail target. Session/Mix open their own detail page; a Cut has no single-release
// detail page, so it opens the track gallery filtered to its release title — the same destination
// AlbumsView's Cut cards use, preserving the established navigation.
private static string DetailHref(ReleaseDto release) => release.Medium switch
// Composes the /archive?... URL from the requested filter triple. Each axis is omitted when null
// (matches the null-means-all scheme); values are escaped. Plain /archive when all three are clear.
private void NavigateToFilter(string? search, ReleaseMedium? medium, string? genre)
{
ReleaseMedium.Session => $"/sessions/{release.Id}",
ReleaseMedium.Mix => $"/mixes/{release.Id}",
_ => $"/tracks?album={Uri.EscapeDataString(release.Title)}",
};
var query = new List<string>(3);
if (!string.IsNullOrWhiteSpace(search))
query.Add($"q={Uri.EscapeDataString(search)}");
if (medium is not null)
query.Add($"medium={medium.ToString()!.ToLowerInvariant()}");
if (!string.IsNullOrWhiteSpace(genre))
query.Add($"genre={Uri.EscapeDataString(genre)}");
var url = query.Count > 0 ? $"/archive?{string.Join('&', query)}" : "/archive";
Navigation.NavigateTo(url);
}
// Display label for a medium filter chip. Centralised so a new medium's label is one entry, not a
// markup change. "DJ Mix" matches the CMS Type-chip wording (§8.D).
@@ -2,10 +2,19 @@
padding-top: 16px;
}
.archive-search-row {
/* Single flex row: search left | toggle center | chips right.
On narrow screens the medium toggle reflows to its own centered row below. */
.archive-controls-row {
display: flex;
justify-content: flex-start;
padding: 0 0 12px 0;
flex-wrap: wrap;
align-items: center;
gap: 24px;
padding: 36px 0 20px 0;
}
.archive-controls-search {
flex: 1 1 0;
min-width: 180px;
}
/* archive-search-field rides on MudTextField, whose root is a child Razor component element.
@@ -15,69 +24,29 @@
width: 100%;
}
.archive-filter-row {
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
padding: 0 0 12px 0;
}
.archive-card-center {
.archive-controls-medium {
flex: 0 0 auto;
display: flex;
justify-content: center;
width: 100%;
}
.archive-card-link {
text-decoration: none;
color: inherit;
}
.archive-release-card {
.archive-controls-genre {
flex: 1 1 0;
display: flex;
flex-direction: column;
width: 200px;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 120ms ease;
justify-content: flex-end;
}
.archive-release-card:hover {
transform: translateY(-4px);
}
/* Narrow screens: push the medium toggle to a full-width centered second row.
Search and genre chips stay together on the first row. */
@media (max-width: 600px) {
.archive-controls-medium {
order: 1;
flex: 1 0 100%;
justify-content: center;
}
.archive-release-cover {
width: 200px;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.archive-release-cover--fallback {
background-color: var(--mud-palette-dark, #1a2238);
}
.archive-release-body {
padding: 8px 4px 0 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* archive-release-title / archive-release-artist ride on MudText (child Razor component); ::deep
pierces into its output since Blazor isolation does not scope-stamp child component roots. */
::deep .archive-release-title {
font-weight: 600;
}
::deep .archive-release-artist {
opacity: 0.7;
}
.archive-empty {
display: flex;
justify-content: center;
padding: 48px 0;
.archive-controls-genre {
order: 0;
flex: 0 1 auto;
}
}
+171
View File
@@ -0,0 +1,171 @@
@page "/cuts/{EntryKey}"
@using DeepDrftModels.DTOs
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits CutDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
@if (ViewModel.IsLoading)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
</div>
</div>
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
<div class="d-flex justify-center mt-4">
<MudButton Href="/cuts"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack">
All cuts
</MudButton>
</div>
</div>
</div>
}
else
{
var release = ViewModel.Release;
var hasGenre = release.Genre is not null;
var hasYear = release.ReleaseDate is not null;
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@firstTrack"
BackHref="/cuts"
BackLabel="All cuts"
ShowShareRow="false">
<Header>
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
<div class="cut-detail-header">
<div class="cut-detail-meta">
<MudText Typo="Typo.h3">@release.Title</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@release.Artist</MudText>
@if (hasGenre || hasYear)
{
<div class="cut-detail-subline">
@if (hasGenre)
{
<span class="cut-detail-genre">@release.Genre</span>
}
@if (hasGenre && hasYear)
{
<span class="cut-detail-sep">&middot;</span>
}
@if (hasYear)
{
<span class="cut-detail-year">@release.ReleaseDate!.Value.Year</span>
}
</div>
}
<div class="cut-detail-actions">
@* Header Play loads the full album into the queue at index 0 (§3.4 seam,
closed P11 W1). Disabled until at least one streamable track is resolved. *@
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.PlayArrow"
Disabled="@(firstTrack is null || !RendererInfo.IsInteractive)"
OnClick="@PlayAlbum">
Play
</MudButton>
@* Release-mode share: copies the canonical /cuts/{entryKey} URL, not a single track (§3b). *@
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
</div>
</div>
<div class="cut-detail-cover">
@if (!string.IsNullOrEmpty(release.ImagePath))
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
}
else
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</MudPaper>
}
</div>
</div>
</Header>
<BodyContent>
<MudDivider Class="cut-detail-divider" />
@if (ViewModel.Tracks.Count == 0)
{
<MudText Typo="Typo.body2" Class="cut-detail-empty">No tracks in this cut yet.</MudText>
}
else
{
<div class="cut-detail-tracklist">
@for (var i = 0; i < ViewModel.Tracks.Count; i++)
{
var track = ViewModel.Tracks[i];
var index = i;
<div class="cut-detail-track-row">
<span class="cut-detail-track-number">@track.TrackNumber</span>
<div class="cut-detail-track-play">
<PlayStateIcon Track="@track"
Size="Size.Medium"
Color="Color.Secondary"
OnToggle="@(() => PlayTrack(track, index))" />
</div>
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
</div>
}
</div>
}
</BodyContent>
</ReleaseDetailScaffold>
}
@code {
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
// Header Play: load the full album into the queue starting at track 0.
private Task PlayAlbum()
{
if (ViewModel.Tracks.Count == 0) return Task.CompletedTask;
if (Queue is not null) return Queue.PlayRelease(ViewModel.Tracks, 0);
// Queue cascade absent (prerender or non-interactive): fall back to direct single-track play.
return PlayerService is not null
? PlayerService.SelectTrackStreaming(ViewModel.Tracks[0])
: Task.CompletedTask;
}
// Row play: toggle if this track is already playing/paused, otherwise load the album at this
// row's index so playback continues to the end from the chosen track.
private async Task PlayTrack(TrackDto track, int index)
{
if (PlayerService is null) return;
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
return;
}
if (Queue is not null)
{
await Queue.PlayRelease(ViewModel.Tracks, index);
}
else
{
// Queue cascade absent: fall back to direct single-track play.
await PlayerService.SelectTrackStreaming(track);
}
}
}
@@ -0,0 +1,68 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
/// <summary>
/// Load + prerender-bridge logic for the Cut album-detail page (/cuts/{entryKey}). Mirrors
/// <see cref="ReleaseDetailBase"/>'s discipline (id-addressed load in OnParametersSetAsync,
/// PersistentComponentState bridge guarded on id) but carries the multi-track payload (release +
/// ordered track list) the Cut page needs. Kept separate from the single-track base so neither
/// grows a medium conditional — the two release shapes are genuinely different (one track vs many).
/// </summary>
public abstract class CutDetailBase : ComponentBase, IDisposable
{
private const string PersistKey = "cut-detail";
[Parameter] public string EntryKey { get; set; } = string.Empty;
[Inject] public required CutDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
// The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g.
// /cuts/{a} -> /cuts/{b}) which reuse this component instance and fire OnParametersSet without
// re-running OnInitialized. Without it the page would keep the prior album's tracks.
private string? _loadedKey;
private bool _loaded;
protected override void OnInitialized()
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
protected override async Task OnParametersSetAsync()
{
if (_loaded && _loadedKey == EntryKey) return;
// Capture the key synchronously before any await so a re-entrant call (rapid navigation or a
// re-render that changes EntryKey while Load is in flight) sees the correct guard state.
_loadedKey = EntryKey;
_loaded = true;
// The bridged payload carries the release and its ordered tracks so the interactive pass
// renders identically without a second round-trip. Guard on the key: a payload for a different
// release must not seed this page (stale-bridge bleed across navigation).
if (PersistentState.TryTakeFromJson<BridgedCut>(PersistKey, out var restored)
&& restored?.Release is not null
&& restored.Release.EntryKey == EntryKey)
{
ViewModel.Restore(restored.Release, restored.Tracks);
}
else
{
await ViewModel.Load(EntryKey);
}
}
private Task Persist()
{
if (ViewModel.Release is not null)
PersistentState.PersistAsJson(PersistKey, new BridgedCut(ViewModel.Release, ViewModel.Tracks));
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
}
@@ -22,5 +22,5 @@ public partial class GenresView : ComponentBase
}
private void OpenGenre(string genre)
=> Navigation.NavigateTo($"/tracks?genre={Uri.EscapeDataString(genre)}");
=> Navigation.NavigateTo($"/archive?genre={Uri.EscapeDataString(genre)}");
}
+1 -1
View File
@@ -166,7 +166,7 @@
<p class="cta-sub">Immerse yourself. The current is always running.</p>
</div>
<div class="cta-actions">
<a class="btn-white" href="/tracks">Explore the Archive</a>
<a class="btn-white" href="/archive">Explore the Archive</a>
@* TODO: route to /schedule when live-session schedule page exists *@
<button class="btn-outline-white" type="button">View Live Schedule</button>
</div>
@@ -9,7 +9,7 @@ namespace DeepDrftPublic.Client.Pages;
/// Shared fetch + prerender-bridge logic for the medium browse pages (Sessions, Mixes). Subclasses
/// supply only the <see cref="Medium"/> and <see cref="DetailRoute"/>; this base fetches the paged
/// releases and bridges the prerendered result across the prerender -> WASM seam so the WASM pass
/// does not re-fetch and replay the card animations (see the TracksView seam).
/// does not re-fetch and replay the card animations.
/// </summary>
public abstract class MediumBrowseBase : ComponentBase, IDisposable
{
+87 -23
View File
@@ -1,5 +1,6 @@
@page "/mixes/{Id:long}"
@page "/mixes/{EntryKey}"
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
@@ -31,41 +32,104 @@ else if (ViewModel.NotFound || ViewModel.Release is null)
else
{
var release = ViewModel.Release;
var hasGenre = release.Genre is not null;
var hasDate = release.ReleaseDate is not null;
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. *@
<MixWaveformVisualizer ReleaseId="@release.Id" />
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" />
<div class="mix-detail-foreground">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
@* Mix keeps the scaffold solely for the Phase 10 top row (back link | controls | lava-lamp).
Title/artist/genre/date/share/play all move into the overlaid hero, so the scaffold's
default header and meta regions are suppressed (ShowHeader/ShowMeta=false) and the share
row stays off (ShowShareRow=false). *@
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@ViewModel.Track"
BackHref="/mixes"
BackLabel="All mixes"
ShowMeta="@(hasGenre || hasDate)">
<MetaContent>
@if (hasGenre)
{
<div>
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Tertiary" Class="deepdrft-genre-chip">
@release.Genre
</MudChip>
</div>
}
@if (hasDate)
{
<div>
<MudText Typo="Typo.overline">Released</MudText>
<MudText Typo="Typo.body1">@release.ReleaseDate!.Value.ToString("MMMM yyyy")</MudText>
</div>
}
</MetaContent>
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>
</TopRightAction>
<Hero>
@* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The
cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
to null). Share and play ride in as slots, matching Sessions. *@
<ReleaseHeroOverlay Class="mix-hero"
HeroImageKey="@release.ImagePath"
PlaceholderIcon="@Icons.Material.Filled.Album"
Title="@release.Title"
Artist="@release.Artist"
Genre="@release.Genre"
ReleaseDate="@release.ReleaseDate">
<ShareContent>
@* Release-mode share: copies the canonical /mixes/{entryKey} URL, not a single track (§3b). *@
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
</ShareContent>
<PlayContent>
@if (ViewModel.Track is not null)
{
<PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
}
</PlayContent>
</ReleaseHeroOverlay>
</Hero>
</ReleaseDetailScaffold>
</MudContainer>
</div>
}
@code {
protected override string PersistKey => "mix-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// The hero now carries the play affordance (the scaffold's header is suppressed), so the
// play-toggle is wired here directly — mirroring SessionDetail. Toggle if this track is already
// active, otherwise start a fresh stream.
private async Task PlayTrack()
{
var track = ViewModel.Track;
if (track is null || PlayerService is null) return;
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
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;
}
@@ -3,3 +3,15 @@
position: relative;
z-index: 1;
}
/* Mix's per-page hero variance: a centered medium square, overriding ReleaseHeroOverlay's default
wide (16/10) aspect. The cover art is square album art, and a smaller square frees the surrounding
canvas for the lava-lamp visualizer. The mix-hero class lands on the overlay component's root
.release-hero <div> (a child Razor component's native output), so ::deep is required to reach it. */
::deep .release-hero.mix-hero {
aspect-ratio: 1 / 1;
max-width: 480px;
min-height: 0;
max-height: none;
margin-inline: auto;
}
@@ -8,33 +8,57 @@ namespace DeepDrftPublic.Client.Pages;
/// Shared load + prerender-bridge logic for the single-release detail pages (Session, Mix).
/// Subclasses supply only their markup; this base loads the release through
/// <see cref="ReleaseDetailViewModel"/> and bridges the prerendered release across the prerender ->
/// WASM seam so the WASM pass does not re-fetch (see the TracksView seam). The playable track is
/// WASM seam so the WASM pass does not re-fetch (see the MediumBrowseBase seam). The playable track is
/// re-resolved on a restore miss only.
/// </summary>
public abstract class ReleaseDetailBase : ComponentBase, IDisposable
{
[Parameter] public long Id { get; set; }
[Parameter] public string EntryKey { get; set; } = string.Empty;
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
// The release EntryKey the ViewModel currently holds. Tracks param-only navigations (e.g.
// /mixes/{a} -> /mixes/{b}) which reuse this component instance and fire OnParametersSet
// without re-running OnInitialized — without this, the page would keep the prior
// release's track and Play would stream the wrong audio.
private string? _loadedKey;
private bool _loaded;
// Distinct keys per medium so a Session restore never lands on a Mix page.
protected abstract string PersistKey { get; }
protected override async Task OnInitializedAsync()
protected override void OnInitialized()
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
protected override async Task OnParametersSetAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
// Re-run whenever the route key changes. Component instances are reused across
// same-template navigations, so the load decision must live here, not in
// OnInitialized (which fires once per instance).
if (_loaded && _loadedKey == EntryKey) return;
// Capture the key synchronously before any await so that a re-entrant call
// (rapid navigation or a re-render that changes EntryKey while Load is in flight)
// sees the correct guard state. Without this, a second OnParametersSetAsync
// for the same key would bypass the guard above and start a second Load,
// causing two ViewModel.Load calls to race on the single scoped instance.
_loadedKey = EntryKey;
_loaded = true;
// The bridged payload carries both the release and its resolved track so the interactive
// pass renders identically without a second round-trip.
if (PersistentState.TryTakeFromJson<BridgedDetail>(PersistKey, out var restored) && restored?.Release is not null)
// pass renders identically without a second round-trip. Guard on the key: a payload for a
// different release must not seed this page (stale-bridge bleed across navigation).
if (PersistentState.TryTakeFromJson<BridgedDetail>(PersistKey, out var restored)
&& restored?.Release is not null
&& restored.Release.EntryKey == EntryKey)
{
ViewModel.Restore(restored.Release, restored.Track);
}
else
{
await ViewModel.Load(Id);
await ViewModel.Load(EntryKey);
}
}
+51 -40
View File
@@ -1,5 +1,7 @@
@page "/sessions/{Id:long}"
@page "/sessions/{EntryKey}"
@using DeepDrftModels.DTOs
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
@@ -37,50 +39,59 @@ else
var heroKey = release.SessionMetadata?.HeroImageEntryKey;
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
var hasGenre = release.Genre is not null;
var hasDate = release.ReleaseDate is not null;
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@ViewModel.Track"
BackHref="/sessions"
BackLabel="All sessions"
ShowMeta="@(hasGenre || hasDate)">
<Hero>
<div class="session-detail-hero">
@if (!string.IsNullOrEmpty(heroImage))
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page">
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; All sessions
</MudLink>
@* 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
thumb would duplicate the background. That logic lives in ReleaseHeroOverlay. *@
<ReleaseHeroOverlay HeroImageKey="@heroImage"
CoverThumbKey="@release.ImagePath"
PlaceholderIcon="@Icons.Material.Filled.Piano"
Title="@release.Title"
Artist="@release.Artist"
Genre="@release.Genre"
ReleaseDate="@release.ReleaseDate">
<ShareContent>
@* Release-mode share: copies the canonical /sessions/{entryKey} URL, not a single track (§3b). *@
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
</ShareContent>
<PlayContent>
@if (ViewModel.Track is not null)
{
<MudPaper Elevation="2" Class="session-detail-hero-img"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(heroImage)}');")" />
<PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
}
else
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@Icons.Material.Filled.Piano" Color="Color.Primary" />
</MudPaper>
}
</div>
</Hero>
<MetaContent>
@if (hasGenre)
{
<div>
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Tertiary" Class="deepdrft-genre-chip">
@release.Genre
</MudChip>
</div>
}
@if (hasDate)
{
<div>
<MudText Typo="Typo.overline">Released</MudText>
<MudText Typo="Typo.body1">@release.ReleaseDate!.Value.ToString("MMMM yyyy")</MudText>
</div>
}
</MetaContent>
</ReleaseDetailScaffold>
</PlayContent>
</ReleaseHeroOverlay>
</MudContainer>
}
@code {
protected override string PersistKey => "session-detail";
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// Mirrors the play-toggle wiring the shared scaffold owns. Session detail composes the player
// affordance directly (it diverges from ReleaseDetailScaffold for the overlay layout), so the
// toggle logic lives here: toggle if this track is already active, otherwise start a fresh stream.
private async Task PlayTrack()
{
var track = ViewModel.Track;
if (track is null || PlayerService is null) return;
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else
{
await PlayerService.SelectTrackStreaming(track);
}
}
}
@@ -1,14 +1,8 @@
/* Hero-dominant: a wide 16:9 image rather than the square cover used on track detail. */
.session-detail-hero {
margin: 0 auto 2rem;
overflow: hidden;
}
/* Session detail is hero-dominant: a large background image with the detail components overlaid
directly on top (the overlay composition lives in ReleaseHeroOverlay). The page widens to the
Large container (set in markup) rather than the shared 760px detail column. */
/* session-detail-hero-img rides on MudPaper (child Razor component); ::deep pierces its output. */
::deep .session-detail-hero-img {
width: 100%;
aspect-ratio: 16 / 9;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
::deep .session-detail-page {
padding-top: 2rem;
padding-bottom: 4rem;
}
@@ -1,101 +0,0 @@
@page "/track/{EntryKey}"
@using DeepDrftPublic.Client.Controls
<PageTitle>@(ViewModel.Track?.TrackName ?? "Track") - DeepDrft</PageTitle>
@if (ViewModel.IsLoading)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-cover">
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Width="100%" Height="320px" />
</div>
<div class="deepdrft-track-detail-masthead">
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
</div>
<div class="deepdrft-track-detail-meta">
<MudSkeleton SkeletonType="SkeletonType.Text" Width="30%" Height="24px" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="25%" Height="24px" />
</div>
</div>
}
else if (ViewModel.NotFound)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Track not found.</MudText>
<MudText Typo="Typo.body2" Align="Align.Center" Color="Color.Secondary">
This track may have been moved or removed.
</MudText>
<div class="d-flex justify-center mt-4">
<MudButton Href="/tracks"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack">
All tracks
</MudButton>
</div>
</div>
</div>
}
else if (ViewModel.Track is not null)
{
var track = ViewModel.Track;
var release = track.Release;
var hasMeta = release is not null
&& (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null);
<ReleaseDetailScaffold Title="@track.TrackName"
Artist="@release?.Artist"
Track="@track"
BackHref="/tracks"
BackLabel="All tracks"
ShowMeta="@hasMeta">
<Hero>
<div class="deepdrft-track-detail-cover">
@if (!string.IsNullOrEmpty(release?.ImagePath))
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
}
else
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</MudPaper>
}
</div>
</Hero>
<MetaContent>
@if (hasMeta)
{
@if (release?.Title is not null)
{
<div>
<MudText Typo="Typo.overline">Album</MudText>
<MudText Typo="Typo.body1">@release.Title</MudText>
</div>
}
@if (release?.Genre is not null)
{
<div>
<MudChip T="string"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@release.Genre
</MudChip>
</div>
}
@if (release?.ReleaseDate is not null)
{
<div>
<MudText Typo="Typo.overline">Released</MudText>
<MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
</div>
}
}
</MetaContent>
</ReleaseDetailScaffold>
}
@@ -1,46 +0,0 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
public partial class TrackDetail : ComponentBase, IDisposable
{
private const string PersistKey = "track-detail";
[Parameter] public required string EntryKey { get; set; }
[Inject] public required TrackDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
protected override async Task OnInitializedAsync()
{
// Carry the prerendered track across the prerender -> interactive (WASM) seam.
// Without this, the WASM pass gets a fresh scoped ViewModel, re-renders the
// skeleton, and re-fetches. Mirror the TracksView bridge: persist on the way
// out of prerender, restore on the interactive pass, and only fetch on a miss.
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistTrack);
if (PersistentState.TryTakeFromJson<TrackDto>(PersistKey, out var restored) && restored is not null)
{
ViewModel.Track = restored;
ViewModel.IsLoading = false;
}
else
{
await ViewModel.Load(EntryKey);
}
}
private Task PersistTrack()
{
if (ViewModel.Track is not null)
{
PersistentState.PersistAsJson(PersistKey, ViewModel.Track);
}
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
}
@@ -0,0 +1,27 @@
@page "/tracks/{EntryKey}"
@using DeepDrftPublic.Client.Services
@inject IReleaseDataService ReleaseData
@inject NavigationManager Navigation
@* Addressable deep-link fallback for a bare release EntryKey (Phase 11 §2, shape iii). Unlike the
player bar / Archive / AlbumsView call sites, an external /tracks/{entryKey} link carries only the
key, so this page fetches the release to discover its medium, then forwards through the same
ReleaseRoutes resolver — one medium→route table, no second source. replace:true keeps the router
out of history so Back skips this hop. Capture EntryKey before the await per the InteractiveAuto
route-param convention. *@
@code {
[Parameter] public string EntryKey { get; set; } = string.Empty;
protected override async Task OnParametersSetAsync()
{
var entryKey = EntryKey;
var result = await ReleaseData.GetByEntryKey(entryKey);
var target = result is { Success: true, Value: { } release }
? ReleaseRoutes.DetailHref(release)
: "/cuts"; // Unknown key: fall back to the Cuts gallery rather than 404.
Navigation.NavigateTo(target, forceLoad: false, replace: true);
}
}
@@ -1,92 +0,0 @@
@page "/tracks"
@using DeepDrftPublic.Client.Controls
<PageTitle>DeepDrft Track Gallery</PageTitle>
<div>
<div class="tracks-view-container">
@* Search + filter affordances are interactive-only: the debounce timer and pill clear
need WASM. During prerender/non-interactive they are hidden, matching the view-mode
toggle's interactivity gate. *@
@if (RendererInfo.IsInteractive)
{
<div class="tracks-search-row">
<MudTextField T="string"
Value="@ViewModel.SearchText"
ValueChanged="@OnSearchInput"
Immediate="true"
DebounceInterval="400"
Placeholder="Search tracks, artists, albums"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
Class="tracks-search-field"/>
</div>
@if (ViewModel.FilterAlbum is not null || ViewModel.FilterGenre is not null)
{
<div class="tracks-filter-pills">
<MudChip T="string"
Color="Color.Tertiary"
Variant="Variant.Filled"
OnClose="@(_ => ClearFilter())">
@(ViewModel.FilterAlbum is not null
? $"Album: {ViewModel.FilterAlbum}"
: $"Genre: {ViewModel.FilterGenre}")
</MudChip>
</div>
}
}
@if (ViewModel.Page != null)
{
<div class="tracks-view-header">
<MudToggleGroup T="GalleryViewMode" @bind-Value="_viewMode" Disabled="@(!RendererInfo.IsInteractive)" Class="tracks-view-toggle">
<MudToggleItem Value="GalleryViewMode.Grid">
<MudIcon Icon="@Icons.Material.Filled.ViewModule"/>
</MudToggleItem>
<MudToggleItem Value="GalleryViewMode.List">
<MudIcon Icon="@Icons.Material.Filled.ViewList"/>
</MudToggleItem>
</MudToggleGroup>
</div>
<div class="tracks-content">
<TracksGallery Tracks="@ViewModel.Page.Items"
ViewMode="@_viewMode"
ActiveTrack="@PlayerService.CurrentTrack"
IsPlaying="@PlayerService.IsPlaying"
IsPaused="@PlayerService.IsPaused"
OnPlay="@PlayTrack"
OnPause="@PauseTrack"/>
</div>
<div class="tracks-footer py-4">
<MudPagination Count="@ViewModel.Page.TotalPages"
Selected="@ViewModel.Page.Page"
SelectedChanged="@SetPage"
Disabled="@(!RendererInfo.IsInteractive)"
BoundaryCount="2"
MiddleCount="3"/>
</div>
}
else
{
<div class="tracks-content">
<MudGrid Spacing="3">
@foreach (var i in Enumerable.Range(0, 12))
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="deepdrft-track-gallery-item-center">
<MudSkeleton Width="240px" Height="240px" SkeletonType="SkeletonType.Rectangle"/>
</div>
</MudItem>
}
</MudGrid>
</div>
<div class="tracks-footer">
<MudSkeleton Height="60px" Width="240px" Class="justify-center"/>
</div>
}
</div>
</div>
@@ -1,163 +0,0 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Controls;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
using Models.Common;
namespace DeepDrftPublic.Client.Pages;
public partial class TracksView : ComponentBase, IDisposable
{
private const string PersistKey = "tracks-page";
[Inject] public required TracksViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
// Filter params arrive on the URL: /tracks?album=X, /tracks?genre=Y, /tracks?q=Z. Copied into
// the ViewModel on init before the first fetch so the gallery renders filtered on direct nav.
[SupplyParameterFromQuery(Name = "album")] public string? AlbumQuery { get; set; }
[SupplyParameterFromQuery(Name = "genre")] public string? GenreQuery { get; set; }
[SupplyParameterFromQuery(Name = "q")] public string? SearchQuery { get; set; }
private IStreamingPlayerService? _subscribedService;
private PersistingComponentStateSubscription _persistingSubscription;
// Ephemeral view-mode selection — presentation-only, not persisted across navigation.
private GalleryViewMode _viewMode = GalleryViewMode.Grid;
protected override async Task OnInitializedAsync()
{
// Seed filter state from the URL before any fetch or restore decision.
ViewModel.FilterAlbum = string.IsNullOrWhiteSpace(AlbumQuery) ? null : AlbumQuery;
ViewModel.FilterGenre = string.IsNullOrWhiteSpace(GenreQuery) ? null : GenreQuery;
ViewModel.SearchText = string.IsNullOrWhiteSpace(SearchQuery) ? null : SearchQuery;
// Carry the prerendered page across the prerender -> interactive (WASM) seam.
// Without this, the WASM pass gets a fresh scoped ViewModel (Page == null),
// re-renders the skeleton, re-fetches, and replaces the gallery DOM a few
// seconds in — replaying TrackCard entrance animations. Mirror the dark-mode
// PersistentComponentState bridge: persist on the way out of prerender,
// restore on the interactive pass, and only fetch on a miss.
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistTracks);
// The prerendered page is always unfiltered. When the URL carries filter params, that
// restored page is wrong for this view — skip the restore and fetch with the filter.
if (!ViewModel.HasActiveFilter
&& PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored)
&& restored is not null)
{
ViewModel.Page = restored;
ViewModel.PageNumber = restored.Page;
}
else
{
await SetPage(ViewModel.PageNumber);
}
}
protected override void OnParametersSet()
{
// The gallery's per-card icons read off the player's live state (CurrentTrack /
// IsPlaying / IsPaused), which mutates outside this component's render path:
// the player bar's play/pause/stop/close all change it directly. The cascade is
// IsFixed, so the provider's re-render never reaches us — subscribe to the
// multicast side-channel and re-render on every state change.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
}
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private Task PersistTracks()
{
// Only persist the unfiltered page. A filtered page restored onto a later plain /tracks
// visit would show the wrong results, so a filtered render leaves the cache untouched.
if (ViewModel.Page is not null && !ViewModel.HasActiveFilter)
{
PersistentState.PersistAsJson(PersistKey, ViewModel.Page);
}
return Task.CompletedTask;
}
private async Task SetPage(int newPage)
{
var result = await ViewModel.TrackData.GetPage(
newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending,
ViewModel.SearchText, ViewModel.FilterAlbum, ViewModel.FilterGenre);
if (result is { Success: true, Value: PagedResult<TrackDto> pageResult })
{
ViewModel.Page = pageResult;
ViewModel.PageSize = pageResult.PageSize;
}
}
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
// burst reaches here. Resets to page 1 since the result set changes, then re-fetches with the
// active filter (search + any album/genre pill compose).
private async Task OnSearchInput(string? value)
{
ViewModel.SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
ViewModel.PageNumber = 1;
await SetPage(1);
StateHasChanged();
}
// Clears the album/genre pill and returns to the unfiltered gallery. Updates the URL (drops the
// query param) and re-fetches in place. SearchText is intentionally left intact — the pill only
// represents FilterAlbum/FilterGenre, not free-text search, so clearing it must not discard an
// active search term. Blazor reuses the component on a same-route query change and does not
// re-run OnInitializedAsync, so the state reset + refetch happen here explicitly rather than
// relying on re-init.
private async Task ClearFilter()
{
ViewModel.FilterAlbum = null;
ViewModel.FilterGenre = null;
ViewModel.PageNumber = 1;
Navigation.NavigateTo("/tracks");
await SetPage(1);
StateHasChanged();
}
private async Task PlayTrack(TrackDto track)
{
// Resume the current track if it's merely paused; otherwise stream the new selection.
if (PlayerService.CurrentTrack?.Id == track.Id && PlayerService.IsPaused)
{
await PlayerService.TogglePlayPause();
}
else
{
await PlayerService.SelectTrack(track);
}
}
private async Task PauseTrack(TrackDto track)
{
if (PlayerService.CurrentTrack?.Id == track.Id && PlayerService.IsPlaying)
{
await PlayerService.TogglePlayPause();
}
}
public void Dispose()
{
_persistingSubscription.Dispose();
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
}
}
@@ -1,45 +0,0 @@
/* Layout for the tracks page.
Dead flex/height rules removed the sticky-footer intent they encoded required
a height target that was never set; normal block flow is sufficient for a
paginated gallery. Horizontal inset is owned by MudContainer in TracksGallery. */
.tracks-view-container {
padding: 0;
}
.tracks-content {
padding-top: 16px;
}
.tracks-footer {
padding: 8px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.tracks-view-header {
display: flex;
justify-content: flex-end;
padding: 0 0 12px 0;
}
.tracks-search-row {
display: flex;
justify-content: flex-start;
padding: 0 0 12px 0;
}
/* tracks-search-field rides on MudTextField, whose root is a child Razor component element.
Blazor isolation does not stamp the scope attribute there, so ::deep is required. */
::deep .tracks-search-field {
max-width: 420px;
width: 100%;
}
.tracks-filter-pills {
display: flex;
justify-content: flex-start;
padding: 0 0 12px 0;
}
@@ -43,6 +43,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
/// <inheritdoc />
public event Action? StateChanged;
/// <inheritdoc />
public event Action? TrackEnded;
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
{
_audioInterop = audioInterop;
@@ -268,6 +271,12 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
CurrentTime = 0;
Duration = null;
await NotifyStateChanged();
// Fire AFTER the state notification so any queue orchestrator that advances on this
// signal selects the next track against a fully-settled idle state. Raised only on
// organic end-of-stream — stop/unload/track-switch go through ResetToIdle, which does
// not raise this — so a subscriber can treat it unambiguously as "advance the queue."
TrackEnded?.Invoke();
}

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