200 Commits

Author SHA1 Message Date
daniel-c-harvey 5b3bbc7b47 Merge branch 'lmf-icon-56' into dev
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m2s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m8s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m36s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m34s
Deploy DeepDrftManager / Deploy (push) Successful in 1m30s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-08 16:33:28 -04:00
daniel-c-harvey f40786171d fix: shrink .lmf-icon to 56px to match MudFab Size.Large 2026-06-08 16:33:20 -04:00
daniel-c-harvey cef1e6bc69 Merge branch 'lmf-big-note' into dev 2026-06-08 16:27:14 -04:00
daniel-c-harvey 5258729c86 feat: enlarge LevelMeterFab note to 68px so it fills the 72px FAB 2026-06-08 16:27:08 -04:00
daniel-c-harvey 8679a9f619 fix: scale LevelMeterFab music note to fill the FAB — bump .lmf-icon from 24px to 56px 2026-06-08 16:17:23 -04:00
daniel-c-harvey ae22153edb style: LevelMeterFab FAB to 72px, icon to 36px 2026-06-08 16:16:22 -04:00
daniel-c-harvey e3df6dd93e fix: scale LevelMeterFab music note to fill the FAB — bump .lmf-icon from 24px to 56px 2026-06-08 16:15:01 -04:00
daniel-c-harvey 6151e6024c Merge branch 'gradient-tune' into dev 2026-06-08 14:55:07 -04:00
daniel-c-harvey 505ac0c47b style: retune spectrum gradient — dark green floor 0-30%, expand yellow/orange zones 2026-06-08 14:54:56 -04:00
daniel-c-harvey 6cacf51318 Merge branch 'gallery-card-border' into dev 2026-06-08 14:53:33 -04:00
daniel-c-harvey 87971dbd6f style: revert fallback thumb background to deepdrft-navy-mid 2026-06-08 14:53:09 -04:00
daniel-c-harvey 881d3d49cd style: thicken track card border to 2px solid secondary palette color 2026-06-08 14:52:01 -04:00
daniel-c-harvey 561cd45237 Merge branch 'spectrum-gradient' into dev 2026-06-08 14:49:30 -04:00
daniel-c-harvey 4e6e3c9eab feat: apply amplitude-tracking gradient to spectrum bars matching LevelMeterFab color scheme 2026-06-08 14:49:23 -04:00
daniel-c-harvey 4ab48ce527 Merge branch 'level-rms' into dev 2026-06-08 14:41:29 -04:00
daniel-c-harvey 58725c4646 feat: true RMS dBFS level measurement for LevelMeterFab via getFloatTimeDomainData 2026-06-08 14:40:11 -04:00
daniel-c-harvey 9cbc09edf7 Merge branch 'level-meter-tune' into dev 2026-06-08 14:20:57 -04:00
daniel-c-harvey 149127c920 fix: recalibrate level meter dB window to [-70, -10] for FFT peak data 2026-06-08 14:20:50 -04:00
daniel-c-harvey ad1c85f3ee Merge branch 'p2-w1-interactivity-guards' into dev 2026-06-08 14:14:02 -04:00
daniel-c-harvey 095b49701f docs: move PLAN 2.4 to COMPLETED — interactivity-gap loading guards landed 2026-06-08 14:11:42 -04:00
daniel-c-harvey 0392ef6954 Merge branch 'level-meter-fill' into dev 2026-06-08 13:31:58 -04:00
daniel-c-harvey c086d03776 feat: guard interactivity-gap controls until WASM hydrates (PLAN 2.4) 2026-06-08 13:31:54 -04:00
daniel-c-harvey b9969640e5 feat: continuous vertical VU fill for LevelMeterFab, replacing 3-band tint 2026-06-08 08:55:45 -04:00
daniel-c-harvey a2814fc939 docs(plan): add 2.4 interactivity-gap loading guard for dead-during-prerender controls 2026-06-08 08:44:41 -04:00
daniel-c-harvey 5b50879476 docs: spec level-meter fill animation (continuous VU-style note fill) 2026-06-08 08:40:03 -04:00
daniel-c-harvey 16f4f894f9 Merge branch 'gallery-text-fix' into dev 2026-06-08 08:38:01 -04:00
daniel-c-harvey 2bac1520db fix: readable text in list mode light theme — override hard-coded off-white with mud-palette-text-primary inside .deepdrft-track-row 2026-06-08 08:36:45 -04:00
daniel-c-harvey 6ce7c580a0 Merge branch 'level-meter-css-fix' into dev 2026-06-08 08:31:41 -04:00
daniel-c-harvey 1c942ffb2b fix: LevelMeterFab icon tint via inline style, bypass Blazor CSS isolation scoping of :root 2026-06-08 08:25:56 -04:00
daniel-c-harvey b88af29731 Merge branch 'gallery-polish' into dev 2026-06-08 08:12:28 -04:00
daniel-c-harvey 21e1a33ccf style: semi-transparent hover overlay and theme-aware list row background in TrackCard 2026-06-08 08:12:04 -04:00
daniel-c-harvey 2db9a6251a docs: record Track Gallery View Toggle landing in COMPLETED.md 2026-06-08 08:05:03 -04:00
daniel-c-harvey 00a3cc8034 Merge branch 'embed-transparent-bg' into dev 2026-06-08 08:02:37 -04:00
daniel-c-harvey 6705c52b69 Merge branch 'gallery-view-toggle' into dev 2026-06-08 08:02:13 -04:00
daniel-c-harvey 4e6cda939d fix(embed): transparent background via dedicated Embed theme instead of inline CSS variable override 2026-06-08 08:00:48 -04:00
daniel-c-harvey 1bd27f2160 fix: add ::deep to track-row-fab rule and define deepdrft-track-row--playing style 2026-06-08 07:59:28 -04:00
daniel-c-harvey 8fbabcdbc5 feat: add grid/list view toggle to track gallery with hover-reveal art cards 2026-06-08 07:56:14 -04:00
daniel-c-harvey 1fdffb1e50 Merge branch 'level-meter-fab-fix' into dev 2026-06-08 07:52:46 -04:00
daniel-c-harvey 2eebc04733 docs: spec Track Gallery View Toggle (grid hover-reveal + list mode) in PLAN.md 2026-06-08 07:49:42 -04:00
daniel-c-harvey 7eae599490 fix(LevelMeterFab): replace MudFab with hand-rolled button+SVG so band color tinting is no longer overridden by MudBlazor internals 2026-06-08 07:46:49 -04:00
daniel-c-harvey 9169493d41 Merge branch 'level-meter-fab' into dev 2026-06-08 07:22:51 -04:00
daniel-c-harvey f1da2382d2 docs: record LevelMeterFab landing in COMPLETED.md and update CLAUDE.md 2026-06-08 07:21:12 -04:00
daniel-c-harvey 165d935ae7 feat: LevelMeterFab tints the minimized-dock FAB icon by live audio level 2026-06-08 07:15:57 -04:00
daniel-c-harvey cef4d243f3 docs: record album art cover wiring in COMPLETED.md 2026-06-08 07:15:27 -04:00
daniel-c-harvey d07ebc9e66 Merge branch 'album-art-detail' into dev 2026-06-08 07:13:03 -04:00
daniel-c-harvey 317e9f84b8 Merge branch 'stream-now-loading-fix' into dev 2026-06-08 07:11:13 -04:00
daniel-c-harvey c57e61f7f9 fix: decouple Stream Now label flag from re-entrancy guard 2026-06-08 07:09:54 -04:00
daniel-c-harvey 2e165d0aef feat: render album art in track detail cover slot, falling back to gradient placeholder 2026-06-08 07:09:39 -04:00
daniel-c-harvey b7b539743b docs: add LevelMeterFab product spec for minimized-dock level meter 2026-06-08 06:59:03 -04:00
daniel-c-harvey 0e5cf7e79d fix: clear stream-loading state before SelectTrackStreaming 2026-06-08 06:54:48 -04:00
daniel-c-harvey 3f02686012 docs: move Phase 2.5 Stream Now to COMPLETED.md 2026-06-07 18:39:49 -04:00
daniel-c-harvey 9015411f12 Merge branch 'p2-w5-stream-now' into dev 2026-06-07 18:35:37 -04:00
daniel-c-harvey 0d4ef369b9 feat: Stream Now instant-play of a random track from the nav button 2026-06-07 18:33:08 -04:00
daniel-c-harvey 4b1a68aa29 docs: close §2.5 open question — add GET api/track/random endpoint 2026-06-07 17:21:50 -04:00
daniel-c-harvey ea535e0c7e Merge branch 'frame-player-cors' into dev 2026-06-07 17:19:38 -04:00
daniel-c-harvey ceb0984262 fix: force FramePlayer to WASM-only render mode; document CORS policy intent 2026-06-07 17:16:49 -04:00
daniel-c-harvey 94a2789127 Merge branch 'seek-state-fix' into dev 2026-06-07 17:15:45 -04:00
daniel-c-harvey 2b4cdeaf72 docs: spec Stream Now random-track instant-play feature (PLAN 2.5) 2026-06-07 16:56:56 -04:00
daniel-c-harvey 7cd85f0bb1 fix: convert absolute pause position to buffer-relative on resume after seek-beyond-buffer 2026-06-07 16:55:31 -04:00
daniel-c-harvey 465cb1ff6c feat: allow /FramePlayer to be embedded in external iframes via CORS + CSP frame-ancestors 2026-06-07 16:53:49 -04:00
daniel-c-harvey 40e001cc7a docs: move Phase 2.1 cover art to COMPLETED.md 2026-06-07 16:46:17 -04:00
daniel-c-harvey a6eba5d8c3 Merge branch 'p2-w2-t2-cms-image' into dev 2026-06-07 16:41:41 -04:00
daniel-c-harvey c766cdf5b8 Merge branch 'p2-w2-t1-public-image' into dev 2026-06-07 16:41:39 -04:00
daniel-c-harvey 905d7fa409 Merge branch 'share-button' into dev 2026-06-07 16:41:35 -04:00
daniel-c-harvey c4dc382bd7 fix: client-side image type guard and deselect affordance on TrackEdit 2026-06-07 16:41:02 -04:00
daniel-c-harvey fa28bfb5cc feat: add Share popover to track detail page 2026-06-07 16:38:37 -04:00
daniel-c-harvey 5703ac2752 feat: CMS cover-art upload on track edit page 2026-06-07 16:33:53 -04:00
daniel-c-harvey 10cb96ef7c feat: add public image proxy and wire TrackCard cover art to api/image/{entryKey} 2026-06-07 16:33:24 -04:00
daniel-c-harvey f6616ed109 Merge branch 'p2-w1-cover-art-api' into dev 2026-06-07 16:27:42 -04:00
daniel-c-harvey 6ef88bef38 docs: document SetMinimized as single mutation point in AudioPlayerBar 2026-06-07 16:20:58 -04:00
daniel-c-harvey 7bd9a434ca Merge branch 'player-minimize-sync' into dev 2026-06-07 16:16:44 -04:00
daniel-c-harvey 627d5623f0 feat: image vault + cover-art API (upload/serve endpoints, ImagePath metadata link) 2026-06-07 16:16:38 -04:00
daniel-c-harvey 1e9313a5d7 docs: move iframe player and backward seek to COMPLETED.md 2026-06-07 16:15:30 -04:00
daniel-c-harvey 5bc1b63b61 fix: route all _isMinimized mutations through SetMinimized so spacer stays in sync
Expand, ToggleMinimized, and Close now share one guarded mutator that fires
OnMinimized and renders. Fixed prerender branch left as a direct assignment.
2026-06-07 16:14:55 -04:00
daniel-c-harvey 9ead3bf2a7 docs: add player minimize/spacer sync design brief 2026-06-07 15:24:19 -04:00
daniel-c-harvey eecab12f48 Merge branch 'wav-duration-fix' into dev 2026-06-07 15:10:58 -04:00
daniel-c-harvey 858110306c fix: preserve full-track duration after seek-beyond-buffer reinit 2026-06-07 15:09:48 -04:00
daniel-c-harvey 4e6ec75000 Merge branch 'seek-fix' into dev 2026-06-07 15:07:13 -04:00
daniel-c-harvey 8e4d783ec2 chore: Move TrackCard & Friends 2026-06-07 15:06:58 -04:00
daniel-c-harvey daa334a947 fix: seek lower-bound guard and pointer-down callback ordering
AudioPlayer.ts: route seeks below bufferStart to seekBeyondBuffer;
previous missing lower-bound caused clamped playback after first seek.
WaveformSeeker: fire OnSeekStart/OnSeekChange before capturePointer
await to prevent fast-click race that locked _isSeeking true.
Latent: WavOffsetService encodes remaining-only DataSize, overwriting
JS this.duration after seek — not fixed here, scope separately.
2026-06-07 15:02:34 -04:00
daniel-c-harvey bd15b66aee feature: Home Page & Footer Mobile Friendly
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 1m56s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m3s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m22s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m27s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
2026-06-07 13:48:12 -04:00
daniel-c-harvey 4072197313 Merge branch 'hero-animation-seam' into dev 2026-06-07 13:05:33 -04:00
daniel-c-harvey 22452815c6 docs: mark WASM seam G1/R1 resolved in audit doc 2026-06-07 13:02:21 -04:00
daniel-c-harvey 8ba3a10e15 fix: gate hero fade-up on SSR pass only to stop double-fire on WASM hydration 2026-06-07 12:57:54 -04:00
daniel-c-harvey ba31e124f2 docs: WASM SSR-handoff seam audit and remediation plan 2026-06-07 10:09:40 -04:00
daniel-c-harvey 86d70c1af6 Merge branch 'hero-xs' into dev 2026-06-07 10:06:23 -04:00
daniel-c-harvey e04f780014 fix: stack hero-actions buttons full-width at xs (<=599px) 2026-06-07 10:06:20 -04:00
daniel-c-harvey 80a79c1232 Merge branch 'xs-responsive' into dev 2026-06-07 10:01:06 -04:00
daniel-c-harvey 75766154bb fix: correct xs breakpoint from 600px to 599px in Home.razor.css (sm starts at 600px) 2026-06-07 09:50:09 -04:00
daniel-c-harvey cb9c5f9b3c fix: add trailing newline to DeepDrftFooter.razor.css 2026-06-07 09:45:38 -04:00
daniel-c-harvey 5d3ea49de8 fix: stack NowPlayingStats vertically and tighten footer padding at xs (<=599px) 2026-06-07 09:43:51 -04:00
daniel-c-harvey a2b8b12bf0 Merge branch 'p1-w1-original-filename' into dev 2026-06-07 09:03:13 -04:00
daniel-c-harvey fcaf8f0bf6 Merge branch 'waveform-fixes' into dev 2026-06-07 09:00:50 -04:00
daniel-c-harvey 3de88c786a feat: capture and display original upload filename for tracks 2026-06-07 09:00:17 -04:00
daniel-c-harvey 5cdd69d7d9 fix: WaveformSeeker resize drift and mobile fast-tap crash
- Add ResizeObserver (JS observeResize/unobserveResize + C# OnWidthChanged)
  so _elementWidth stays current after window resize, fixing hover indicator drift
- Move _isSeeking = true before capturePointer await so a fast mobile tap
  that fires pointerup mid-await still commits the seek
- Replace all Duration!.Value null-forgiving dereferences with explicit
  Duration is > 0 guards in all four pointer event handlers
- Silence post-dispose resize callback rejections with .catch(() => {})
2026-06-07 09:00:10 -04:00
daniel-c-harvey 6dfb3a2f23 fix: AudioPlayerBar Styles
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m10s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m31s
Deploy DeepDrftManager / Deploy (push) Successful in 1m24s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
2026-06-07 08:19:52 -04:00
daniel-c-harvey 54939721e4 docs: move Phase 6 responsive home page from PLAN.md to COMPLETED.md 2026-06-07 07:56:22 -04:00
daniel-c-harvey ec88759b55 Merge branch 'p6-w1-home-mobile' into dev 2026-06-07 07:53:32 -04:00
daniel-c-harvey 8b3e7e0620 fix: wrap hero and section-split MudGrids in plain HTML elements so CSS isolation scope attributes reach .hero and .section-split rules 2026-06-07 07:48:26 -04:00
daniel-c-harvey 18b5fa9401 feature: Responsive mobile layout for home page
Migrate hero, section-header, and section-split to MudGrid with xs/sm/md breakpoints (Spacing=0 to keep color panels flush); add @media collapse rules for genre/features card grids and the CTA banner. Visual styling unchanged at desktop width.
2026-06-07 07:37:09 -04:00
daniel-c-harvey c4e7b49776 plan: add Phase 6 responsive home page (mobile layout) 2026-06-07 07:27:43 -04:00
daniel-c-harvey 13adb144a6 feature: Mobile Menu & Style Polish 2026-06-07 06:53:21 -04:00
daniel-c-harvey 84a302ce24 feature: Palette Enhancements 2026-06-06 21:24:19 -04:00
daniel-c-harvey 47d0475d3f Merge branch 'palette-light-shift' into dev 2026-06-06 20:49:18 -04:00
daniel-c-harvey 4341d97f12 theme: shift light palette primary to navy-mid, step green scale up one level 2026-06-06 20:49:14 -04:00
daniel-c-harvey bd110c07da Merge branch 'track-card-green-fix' into dev 2026-06-06 20:41:52 -04:00
daniel-c-harvey d1cb85b840 feat: adjust navy wireframe tokens and add green-interactive 2026-06-06 20:41:37 -04:00
daniel-c-harvey 07ba9946ce feat: add --deepdrft-green-interactive token to design token layer 2026-06-06 20:36:46 -04:00
daniel-c-harvey 4b5de088ab fix: correct MudBlazor Tertiary class targets and demote artist to muted off-white in TrackCard 2026-06-06 20:33:21 -04:00
daniel-c-harvey 9ce2631bf4 feature: AudioPlayer Enhancements
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 1m59s
Deploy DeepDrftManager / Build & Publish (push) Successful in 59s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m30s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m27s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-06 20:17:50 -04:00
daniel-c-harvey 475f93c8a3 feature: AudioPlayerBar Layout Enhancements 2026-06-06 19:47:17 -04:00
daniel-c-harvey a4b098b8ea feature: AudioPlayerBar enhancements 2026-06-06 17:48:07 -04:00
daniel-c-harvey 7dfdad2666 docs: archive track detail page to COMPLETED.md; update CLAUDE.md 2026-06-06 17:39:13 -04:00
daniel-c-harvey b1d58c1327 Merge branch 'track-detail-page' into dev 2026-06-06 17:30:10 -04:00
daniel-c-harvey 6b18d7cc1e Player Layout 2026-06-06 17:28:39 -04:00
daniel-c-harvey 93d9b47a67 fix: TrackDetail render mode, pause, and secondary text color 2026-06-06 16:45:07 -04:00
daniel-c-harvey 0dd33a5dfc Add track detail page with clickable cards 2026-06-06 16:33:57 -04:00
daniel-c-harvey 3e4ddbb2a6 docs: spec Track Detail page (/track/{entryKey}) in PLAN.md 2026-06-06 16:11:55 -04:00
daniel-c-harvey 1bb6e29e47 feature: Track Meta Labels on Player 2026-06-06 16:05:45 -04:00
daniel-c-harvey c83b132522 feature: Embed Frame Player 2026-06-06 15:43:09 -04:00
daniel-c-harvey d96c41eafb docs: reconcile PLAN.md and CONTEXT.md with post-split solution state 2026-06-06 15:27:14 -04:00
daniel-c-harvey 9110b4b764 docs: archive play-state icon normalization; update DeepDrftPublic.Client CLAUDE.md
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 1m57s
Deploy DeepDrftManager / Build & Publish (push) Successful in 59s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m34s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m32s
Deploy DeepDrftManager / Deploy (push) Successful in 1m30s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m26s
2026-06-06 11:59:53 -04:00
daniel-c-harvey 526e607f33 Merge branch 'play-icons-w2-component' into dev 2026-06-06 11:52:57 -04:00
daniel-c-harvey 7d3da58573 Style Polish 2026-06-06 11:52:41 -04:00
daniel-c-harvey e3fe401abf Consolidate play/pause icon logic into PlaybackIcons mapper and PlayStateIcon component
Add Disabled parameter to PlayStateIcon; forward to MudIconButton;
pass Disabled="!IsLoaded" from PlayerControls to match Stop button parity.
2026-06-06 10:46:32 -04:00
daniel-c-harvey 1d97729e57 Merge branch 'play-icons-w1-gallery-fix' into dev 2026-06-06 10:09:11 -04:00
daniel-c-harvey 766e98fd2b Reflect real playback state on gallery cards and toggle pause/resume
Add IsPaused/OnPause to TrackCard, make TracksGallery controlled, and
drive the active track from PlayerService.CurrentTrack as the single
source of truth.
2026-06-06 10:09:07 -04:00
daniel-c-harvey d055c2a548 WASM State Fixes 2026-06-06 09:59:43 -04:00
daniel-c-harvey 75bf93c2bb CMS Home autoredirect to /tracks 2026-06-06 09:52:50 -04:00
daniel-c-harvey b746645f97 WaveformSeeker Improvements 2026-06-06 09:52:31 -04:00
daniel-c-harvey ab9db6d0ec Merge branch 'track-card-css-fix2' into dev 2026-06-05 20:48:27 -04:00
daniel-c-harvey 3dc9fc2446 fix(css): solid navy fallback, force green FAB+chip on dark card 2026-06-05 18:31:56 -04:00
daniel-c-harvey 59dbfb8aab docs: note preprocessing panel fold into TrackList tabs 2026-06-05 18:27:53 -04:00
daniel-c-harvey 76e16fe32e Merge branch 'merge-preprocessing-tab' into dev 2026-06-05 18:21:36 -04:00
daniel-c-harvey 97c8439ed7 Fold waveform preprocessing into tracks page as tab 2026-06-05 18:20:33 -04:00
daniel-c-harvey cabc8654d1 Merge branch 'waveform-w3-cms' into dev 2026-06-05 17:59:52 -04:00
daniel-c-harvey f468fafaba Merge branch 'track-card-css-scope' into dev 2026-06-05 17:57:13 -04:00
daniel-c-harvey af6ed6130f docs: log WaveformSeeker W3 completion in COMPLETED.md 2026-06-05 17:57:11 -04:00
daniel-c-harvey 6e25ad3085 Add CMS waveform pre-processing panel with backfill endpoints
GET api/track/waveform-status and POST api/track/{id}/waveform (ApiKey);
CmsTrackService methods; TrackPreProcessing page with per-row and
sequential bulk generation; nav links from TrackList and Index.
2026-06-05 17:56:25 -04:00
daniel-c-harvey 75db127708 docs: log track card CSS scoping in COMPLETED.md 2026-06-05 17:56:21 -04:00
daniel-c-harvey 84307dabde fix(css): ::deep track text color rules to pierce MudText 2026-06-05 17:41:56 -04:00
daniel-c-harvey 1b493434d6 Merge branch 'waveform-w2-seeker' into dev 2026-06-05 17:37:01 -04:00
daniel-c-harvey 2ee0667aa2 docs: log WaveformSeeker W2 completion in COMPLETED.md 2026-06-05 17:36:03 -04:00
daniel-c-harvey 9c916245c1 refactor(css): scope track card styles; apply NowPlayingCard color vocabulary 2026-06-05 17:35:16 -04:00
daniel-c-harvey 8de7342352 Replace MudSlider seekbar with WaveformSeeker loudness-waveform control
DOM bar chart with clip-overlay progress split; pointer-capture drag;
WaveformProfile fetched on load (fire-and-forget, cancellable); flat
fallback when no profile; small lazily-loaded waveformSeeker.js for
getBoundingClientRect and setPointerCapture.
2026-06-05 17:35:11 -04:00
daniel-c-harvey acd76e0601 docs: mark track-view CSS consolidation completed 2026-06-05 17:00:36 -04:00
daniel-c-harvey 7c89220667 Merge branch 'waveform-w1-t2-api' into dev 2026-06-05 16:58:59 -04:00
daniel-c-harvey 9cfcd5f67a docs: log WaveformSeeker W1-T2 completion in COMPLETED.md 2026-06-05 16:58:38 -04:00
daniel-c-harvey 9538310c43 Merge branch 'track-css-consolidation' into dev 2026-06-05 16:58:12 -04:00
daniel-c-harvey b3473aa37e refactor(css): consolidate track-view layout and card text color rules; switch genre chip to Outlined variant 2026-06-05 16:58:07 -04:00
daniel-c-harvey de4583b759 Add waveform profile HTTP transport: API endpoint, public proxy, content client method 2026-06-05 16:57:42 -04:00
daniel-c-harvey 9d39843982 Merge branch 'waveform-w1-t3-layout' into dev 2026-06-05 16:50:09 -04:00
daniel-c-harvey edf45bb8de Merge branch 'waveform-w1-t1-computation' into dev 2026-06-05 16:50:04 -04:00
daniel-c-harvey 9854d51940 docs(product): track-view CSS consolidation audit and spec 2026-06-05 16:43:19 -04:00
daniel-c-harvey 92f860897b docs: log WaveformSeeker W1-T1 and W1-T3 completions in COMPLETED.md 2026-06-05 16:40:22 -04:00
daniel-c-harvey cc1fa60a4d refactor(player): move SpectrumVisualizer into VolumeZone above volume slider
Rename VolumeControls to VolumeZone; stack 24-bucket SpectrumVisualizer above volume
slider; remove it from PlayerSeekZone. MudSlider stays as seek placeholder. Pin
flex-shrink:0 on volume-zone; add Class param to VolumeZone for layout flexibility.
2026-06-05 16:38:13 -04:00
daniel-c-harvey fa57861dbf Add server-side waveform loudness profiling on track upload
ILoudnessAlgorithm strategy (RmsLoudnessAlgorithm first impl), WaveformProfileService
stores quantized byte[] sidecar in new MediaFileVault (profiles vault), wired into
UnifiedTrackService.UploadAsync; failure is logged and swallowed. WaveformProfileDto
and WaveformProfileOptions in shared projects.
2026-06-05 16:38:02 -04:00
daniel-c-harvey 7c401d75b5 docs: mark track-card plain-shell refactor completed 2026-06-05 16:27:51 -04:00
daniel-c-harvey 3c17260f32 Merge branch 'track-card-plain-shell' into dev 2026-06-05 16:26:20 -04:00
daniel-c-harvey 61c5bee5d7 refactor(track-card): replace MudCard/MudPaper shells with plain divs, drop !important from section 8 backgrounds 2026-06-05 16:26:17 -04:00
daniel-c-harvey eed99df0dd Merge branch 'track-card-flash-fix' into dev 2026-06-05 16:15:31 -04:00
daniel-c-harvey 1986aed902 fix(css): eliminate track card flash — transparent container, stable fallback base color, unconditional text defaults 2026-06-05 16:15:27 -04:00
daniel-c-harvey c10d315a7b docs(product): add approved WaveformSeeker spec
Loudness-waveform seekbar replacing MudSlider; ILoudnessAlgorithm
abstraction (RMS first, LUFS future); vault sidecar storage; CMS
PreProcessing panel for backfill; VolumeZone rename. All decisions
resolved 2026-06-05.
2026-06-05 15:44:40 -04:00
daniel-c-harvey b9b2c131a8 docs: mark track-card glass theming completed 2026-06-05 15:36:40 -04:00
daniel-c-harvey 231ed399a3 Merge branch 'track-card-glass' into dev 2026-06-05 15:26:56 -04:00
daniel-c-harvey d9664988ad Player Bar Cosmetics 2026-06-05 15:26:49 -04:00
daniel-c-harvey b22b57069d style(track-card): glass theming — remove MudBlazor color overrides, add theme-scoped CSS for title/artist/meta hierarchy and navy-glass fallback panel 2026-06-05 15:18:56 -04:00
daniel-c-harvey a86ccae432 Merge branch 'playerbar-timestamp-move' into dev 2026-06-05 14:59:02 -04:00
daniel-c-harvey 87f722fa58 refactor(player): move TimestampLabel from PlayerTransportZone to PlayerSeekZone so volume centers against buttons row height 2026-06-05 14:38:38 -04:00
daniel-c-harvey 31d2c2ee7e Merge branch 'playerbar-layout-fix' into dev 2026-06-05 14:29:53 -04:00
daniel-c-harvey 78c6803e6b fix(css): halve volume control width and pin it to flex-start at wide breakpoints 2026-06-05 14:28:50 -04:00
daniel-c-harvey 8178174275 Merge branch 'audioplayer-unified' into dev 2026-06-05 14:15:07 -04:00
daniel-c-harvey ffb71b6d71 docs: move AudioPlayerBar unification from PLAN.md to COMPLETED.md 2026-06-05 14:14:45 -04:00
daniel-c-harvey cbc43300b2 fix(css): remove ::deep from PlayerTransportZone root-element selectors, replace dead controls-left rule 2026-06-05 14:08:16 -04:00
daniel-c-harvey 190d8d044f Unify AudioPlayerBar to one responsive CSS layout and fix SpectrumVisualizer startup via StateChanged subscription 2026-06-05 14:04:31 -04:00
daniel-c-harvey 4887454911 docs(plan): add AudioPlayerBar responsive unification proposal 2026-06-05 13:52:52 -04:00
daniel-c-harvey 0c5ebae9c9 chore: move SpectrumVisualizer above seek slider in PlayerSeekZone 2026-06-05 13:52:46 -04:00
daniel-c-harvey 91214336c5 chore: move spectrum visualizer above seek slider; fix controls-left CSS scoping 2026-06-05 13:52:05 -04:00
daniel-c-harvey 4616fbf0e1 Merge branch 'mobile-seek-dry' into dev 2026-06-04 20:31:22 -04:00
daniel-c-harvey 72e9f71fbc Refactor mobile AudioPlayerBar seek to use PlayerSeekZone, removing inline duplicate gesture code 2026-06-04 20:12:57 -04:00
daniel-c-harvey b6572bead0 chore: set Microsoft.AspNetCore log level to Warning 2026-06-04 20:08:28 -04:00
daniel-c-harvey f07ab4b235 fix(css): add ::deep prefix to MudBlazor component classes in AudioPlayerBar scoped styles 2026-06-04 20:04:27 -04:00
daniel-c-harvey 73e0eea328 Merge branch 'seek-pointerleave-fix' into dev 2026-06-04 19:55:08 -04:00
daniel-c-harvey dbf02a9426 fix(seek): guard HandlePointerLeave with _isSeeking to prevent spurious seek-to-zero on mouse-out 2026-06-04 19:53:22 -04:00
daniel-c-harvey b24c6ff78e Merge branch 'player-desktop-redesign' into dev 2026-06-04 19:34:27 -04:00
daniel-c-harvey de0c01ef4d docs: record desktop AudioPlayerBar MudBlazor theme migration 2026-06-04 19:32:27 -04:00
daniel-c-harvey 8420ab8d37 Migrate desktop AudioPlayerBar to MudBlazor theme surface 2026-06-04 19:28:14 -04:00
daniel-c-harvey a57e0f71c4 docs(product): add AudioPlayerBar desktop redesign proposal 2026-06-04 18:49:23 -04:00
daniel-c-harvey 7622e94ba2 Merge branch 'remove-audio-debug-logs' into dev 2026-06-04 18:46:22 -04:00
daniel-c-harvey 034e9d5633 chore: remove debug console.log calls from audio TS interop 2026-06-04 18:40:45 -04:00
daniel-c-harvey db8a44fc79 Home Page Style Normalization Fixes (Animations) 2026-06-04 18:23:59 -04:00
daniel-c-harvey 6e274b7395 Merge branch 'focus-ring-fix' into dev 2026-06-04 18:22:31 -04:00
daniel-c-harvey 21b7661ca8 fix: suppress h1 focus ring caused by FocusOnNavigate in both Blazor apps 2026-06-04 18:18:23 -04:00
daniel-c-harvey 79591fe4e4 Merge branch 'ci-setup-node' into dev
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m16s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m23s
2026-06-04 17:47:12 -04:00
daniel-c-harvey c69c25c6dc ci: add setup-node@v4 to deploy-public build job
Microsoft.TypeScript.MSBuild requires node on PATH during dotnet publish.
Without an explicit setup step, TS compilation silently skips on runners
that don't pre-install Node, leaving wwwroot/js/audio/ empty in the artifact.
2026-06-04 17:46:47 -04:00
daniel-c-harvey 4171b493fd Merge branch 'public-static-fix' into dev 2026-06-04 17:45:13 -04:00
daniel-c-harvey fe8ddff41c docs: document request pipeline and UseStaticFiles/MapStaticAssets relationship in DeepDrftPublic 2026-06-04 17:43:46 -04:00
daniel-c-harvey 58a94fe315 docs: explain why UseStaticFiles is not redundant with MapStaticAssets 2026-06-04 17:42:15 -04:00
daniel-c-harvey 757c1d5c85 fix: add UseStaticFiles() after UseAntiforgery() so JS audio module is served with correct Content-Type in production 2026-06-04 17:40:10 -04:00
daniel-c-harvey 194a76ce4c Workflow Build Trigger
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m13s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m6s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m23s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m26s
2026-06-04 14:32:20 -04:00
159 changed files with 8712 additions and 1097 deletions
+4
View File
@@ -21,6 +21,10 @@ jobs:
with:
dotnet-version: '10.0.x'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install wasm-tools workload
run: dotnet workload install wasm-tools
+4 -1
View File
@@ -311,4 +311,7 @@ __pycache__/
Database/Vaults/*
# TypeScript output
**/wwwroot/js/*
**/wwwroot/js/*
# ...except hand-authored client JS modules (not TS compile output).
!DeepDrftPublic.Client/wwwroot/js/
!DeepDrftPublic.Client/wwwroot/js/*.js
+730
View File
@@ -6,6 +6,736 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 2.4 — Interactivity-gap loading guard on dead-during-prerender controls
**Status:** Fully landed on 2026-06-08 (implementation complete, reviewed and merged to dev).
Guard controls that are dead during the SSR→interactive handoff window (12s on fast loads, 5s+ on cold WASM cache) so they *look* inactive until the Blazor runtime attaches, then re-render into their live form. The listener reaches for **play** first — a play button that looks armed but eats the click reads as "the site is broken," not "the site is loading." This is a credibility/perceived-quality fix on the primary action.
**Implementation approach:** Extend the existing `RendererInfo.IsInteractive` pattern already established in `PlayStateIcon.razor` and `DeepDrftHero.razor`. Add `Disabled="@(!RendererInfo.IsInteractive)"` (or the HTML equivalent) to unguarded controls during the SSR phase. No global overlay/scrim (rejected — it fights the prerender's purpose and risks colliding with Blazor's `#components-reconnect-modal`); per-control guarding leaves the working parts (plain `<a>` links, idle UI) live. Each control carries its own inline gate — mild duplication over a shared `<InteractivityGate>` wrapper is deliberately accepted (over-engineering for ~4 call sites; would obscure the per-control rendering differences). Consistent with existing patterns.
**Guarded controls (as implemented):**
- **`TrackCard.razor` play `MudFab` (grid + list mode) — HIGHEST PRIORITY.** Disabled during the gap (greyed, non-interactive via MudBlazor's built-in disabled state). Card looks *composed but not-yet-armed*, not alarmed. Re-enables once `RendererInfo.IsInteractive` flips. Note: `/tracks` bridges *data* across the seam via `PersistentComponentState` — but bridging data ≠ wiring handlers; the gap still exists on a cold WASM cache load.
- **`TracksView.razor` `MudToggleGroup` (grid/list switch) + `MudPagination`.** Both gated to `Disabled="true"` during the gap. Lower priority than play, but cheap to include in the same pass and visually consistent.
- **`SharePopover.razor` (on `TrackDetail`).** The Share `MudIconButton` trigger gated to `Disabled="true"` until interactive; the in-popover copy buttons are moot while the trigger is disabled, so the single guard on the trigger suffices.
- **`DeepDrftMenu.razor` "Stream Now" CTA.** Folded `!RendererInfo.IsInteractive` into the existing `disabled="@(...)"` expression (e.g. `disabled="@(_streamLoading || !RendererInfo.IsInteractive)"`) on both desktop and mobile buttons. The label-swap precedent here ("Finding a track…") is the house voice — disabling is the floor.
**What was deliberately left untouched (mirrors `WASM_SEAMS.md` §2 discipline):**
- **Minimized `AudioPlayerBar` dock** — default state shows only `LevelMeterFab`, which is idle (untinted, no animation) until audio plays. Reads correctly during the gap; nothing to guard.
- **Expanded `AudioPlayerBar` transport zone** — already routes its play/pause glyph through the guarded `PlayStateIcon`. Already covered by the existing pattern.
- **`NowPlaying` / `NowPlayingCard`** — reflect live player state; show "Nothing playing" on both passes on a cold load. No dead control; the player is gesture-gated and intentionally non-persisted.
- **Plain `<a href>` links** (track titles → `/track/{key}`, nav links, hero CTAs) — work in static SSR. Out of scope by construction.
**Coexistence constraint:** This guard targets the *initial* SSR→interactive handoff. It does not duplicate or interfere with Blazor's built-in `#components-reconnect-modal` (dropped-circuit recovery, a different lifecycle event). The two are orthogonal — `RendererInfo.IsInteractive` does not flip back to `false` on a *reconnect*, so the guards correctly stay inactive during a reconnect.
**Prerequisite:** None. Pure client-side rendering work in `DeepDrftPublic.Client`; no API or data-layer change.
---
## LevelMeterFab — Continuous vertical fill animation
**Status:** Fully landed on 2026-06-08 (feature complete, component + CSS animation, merged to dev).
Replaced the discrete three-band tint model with a **continuous vertical fill** inside the music-note SVG silhouette. The fill height tracks live audio level bottom-up (0100%); a fixed three-zone gradient (`linearGradient` with `gradientUnits="userSpaceOnUse"`) renders green (060% of note height), yellow (6085%), and orange (85100%) zones. The color at the fill line therefore changes naturally as the level rises. The note shape remains always visible as a dim silhouette at 25% opacity; idle (paused/stopped) shows the silhouette alone.
**Implementation details:**
- **C# side (`LevelMeterFab.razor.cs`)**: Removed discrete `_bandClass` field; replaced with continuous `_fillPercent` (0100). dB → fill % uses a linear map over a 30 to 0 dB window (30 dB = 0% fill, 0 dB = 100%, 12 dB = 60% / yellow boundary, 4.5 dB = 85% / orange boundary). Smoothing envelope operates on the continuous value (attack-fast / release-slow on dB, then map). Computed properties `FillY` and `FillH` expose the rect geometry to the SVG template.
- **SVG (`LevelMeterFab.razor`)**: Two layers — always-on dim silhouette (note path at 25% white) and a clipped fill group (rectangle revealed through the note via `clipPath`, painted with the zone gradient). No color cascade; explicit rgba on silhouette, explicit colors in gradient stops.
- **Gradient anchoring**: `linearGradient` with `gradientUnits="userSpaceOnUse"` (not `objectBoundingBox`) — x1="0" y1="24" x2="0" y2="0" (bottom to top in viewBox coordinates). This pins the zones to fixed heights so the fill line always crosses the same colors at the same levels.
- **CSS (`LevelMeterFab.razor.css`)**: Removed band-tint color transition (no longer applicable). Geometry attributes `y` and `height` are not CSS-animatable in a reliable way; animation is purely the 30fps C# value updates driven by smoothing envelope. Silhouette remains always-on idle visual when `_fillPercent = 0`.
- **Re-render gate**: 0.5% change threshold prevents churn on sub-pixel deltas; renders only on meaningful level swings.
- **Idle behavior**: `StopAnimation` resets `_fillPercent = 0` and `_smoothedDb = SilenceFloorDb`, dropping the column and leaving only the dim silhouette.
Supersedes the earlier discrete-tint `LevelMeterFab` entry from the same component. The new model is load-bearing for real-time level feedback on a commercial dance-music master (8 to 3 dBFS); the meter "breathes" through the green/yellow zones with peaks reaching orange, rather than holding in one band.
---
## Track Gallery View Toggle
**Status:** Fully landed on 2026-06-08 (feature complete, component + layout + CSS, merged to dev).
### Overview
Give the track gallery two switchable view modes behind a page-level toggle: **Mode A — Album Art Grid** (the current responsive 4-column `MudGrid` of 250×250 cards, augmented so that art-bearing cards hide their info overlay at rest and reveal it on hover) and **Mode B — Track Detail List** (a vertical stack of full-width horizontal rows, each a compact track line with play FAB, art thumbnail, artist/title text block, and right-aligned genre/year). The toggle is a two-option control at the top of `TracksView`, defaulting to Grid, with ephemeral page-level state (not persisted). Both modes consume the same `ViewModel.Page.Items` and the same per-card play-state inputs — the only divergence is in `TrackCard`'s rendering, consistent with the "one source, multiple views" convention (`CONTEXT.md §6`).
### Component changes
- **`TracksView.razor` / `.razor.cs` / `.razor.css`** — Add an ephemeral `ViewMode _viewMode = ViewMode.Grid` field and a handler that flips it and calls `StateHasChanged()`. Render the toggle control above `tracks-content` (see Toggle spec). Pass `ViewMode="@_viewMode"` into `<TracksGallery>`. No change to data flow, persistence, or player-state subscription. CSS: a flex row for the toggle header (`justify-content: flex-end`).
- **`TracksGallery.razor` / `.razor.cs` / `.razor.css`** — Add `[Parameter] public ViewMode ViewMode { get; set; } = ViewMode.Grid;`. Branch the template: for `Grid`, keep the existing `MudGrid` / `MudItem` breakpoint layout unchanged; for `List`, render a single flex-column container (`deepdrft-track-list`) that `@foreach`-es the same `Tracks` into `<TrackCard>` rows with no `MudGrid` wrapper. Pass `ViewMode="@ViewMode"` down to each `TrackCard`. The `ActiveTrack` / `IsPlaying` / `IsPaused` / `OnPlay` / `OnPause` wiring is identical in both branches.
- **`TrackCard.razor` / `.razor.cs` / `.razor.css`** — Add `[Parameter] public ViewMode ViewMode { get; set; } = ViewMode.Grid;`. Branch the markup at the top: `ViewMode.Grid` renders the existing card body unchanged (plus the hover behaviour below); `ViewMode.List` renders the horizontal row layout (see Mode B spec). The `hasLink` / `trackHref` computation, `PlayClick`, and `PlayPauseIcon` are shared across both. The `ViewMode` enum lives in a small shared file (e.g. `Controls/GalleryViewMode.cs` or alongside `TrackCard.razor.cs` in the `DeepDrftPublic.Client.Controls` namespace) so both `TracksView`, `TracksGallery`, and `TrackCard` reference one definition.
### Mode A — hover spec (pure CSS, no JS)
- Applies **only** when the card has album art (`deepdrft-track-card-bg` present). The no-art fallback path (`deepdrft-track-card-fallback`) is untouched — its `deepdrft-track-card-content` stays visible at all times exactly as today.
- For art-bearing cards: give `deepdrft-track-card-content` an `opacity: 0` rest state and `opacity: 1` on `.deepdrft-track-card-container:hover .deepdrft-track-card-content`. Add `transition: opacity 180ms ease, background-color 180ms ease`.
- Swap the rest gradient for a **solid navy panel on hover**: at rest the content overlay is transparent/hidden; on hover its background becomes `var(--deepdrft-navy-mid, #162437)` (opaque, full-card) so the info reads cleanly over the art rather than through a gradient. Implement by toggling the `background` on the content layer between transparent (rest) and solid navy (hover), or by fading in a sibling navy panel beneath the content — implementer's call; the observable result is a solid navy reveal, not the current always-on gradient.
- Distinguish art vs. no-art in CSS without new markup by scoping the hide/reveal rules to a container modifier. Add a class to the container when art is present (e.g. `deepdrft-track-card-container--art`) and gate the `opacity: 0` rest rule on it, so fallback cards never pick up the hidden-at-rest behaviour.
- Touch devices have no hover; on coarse pointers the overlay should default to visible. Guard the hidden-at-rest rule with `@media (hover: hover) and (pointer: fine)` so touch users always see the info.
### Mode B — list row spec
- Container: `deepdrft-track-list` is `display: flex; flex-direction: column; gap: 8px;` inside the existing `MudContainer MaxWidth="Large"`. Rows are full-width.
- Row (`deepdrft-track-row`): `display: flex; flex-direction: row; align-items: center; gap: 16px;` with `height: ~7288px`, `padding: 8px 16px`, and the same glass treatment as grid cards — `background: var(--deepdrft-navy-mid, #162437)`, off-white text, `border: 1px solid rgba(250,250,248,0.12)`. This reads on both light and dark themes (matches the fallback-panel rationale already documented in `TrackCard.razor.css`).
- Columns, left to right:
1. **Play FAB** — fixed-width column, vertically centered. Same `<MudFab Color="Color.Tertiary" Size="Size.Medium" StartIcon="@PlayPauseIcon" OnClick="@PlayClick"/>` as grid mode (reuse, do not duplicate logic).
2. **Art thumbnail** — square `~64px` (`flex: 0 0 64px`), vertically centered. Reuse the art `background-image` div for art-present; a `deepdrft-track-card-fallback`-style navy square for art-absent.
3. **Text block**`flex: 1 1 auto; min-width: 0;` two stacked rows: Artist (`Typo.subtitle1`, `deepdrft-track-artist`-weight) on top, Track Name (`Typo.caption`/body, `deepdrft-track-title`) below. Both `text-truncate`. Note the visual order here is Artist-over-Title, inverse of the grid card — intentional per the row sketch.
4. **Right metadata** — fixed/`flex: 0 0 auto` column, `text-align: right`, two stacked rows: Genre chip (`MudChip`, same green-accent outline styling) top-right, Year caption bottom-right.
- Linking: wrap the art + text columns in the same `<a href="@trackHref" class="deepdrft-track-card-link">` pattern used by the grid card, so the row navigates to `/track/{EntryKey}` while the FAB (outside the anchor) remains the sole playback entry point. Preserve the `display: contents` approach so the flex row layout is unaffected by the anchor.
- The active-state icon (`PlayPauseIcon` driven by `IsPlaying`/`IsPaused`) works identically — no list-specific play-state logic.
### Toggle spec
- Component: `MudToggleGroup<ViewMode>` with two `MudToggleItem`s (icon-only), or a pair of `MudToggleIconButton`s — `MudToggleGroup` is the cleaner fit for a 2-value exclusive switch. Icons: `Icons.Material.Filled.ViewModule` (Grid) and `Icons.Material.Filled.ViewList` (List).
- Placement: top of `TracksView`, above `tracks-content`, aligned right. Sits in its own header row; does not displace the existing centered gallery or the footer pagination.
- Binding: `@bind-Value="_viewMode"` (or `SelectedValue` + `SelectedValueChanged`) on the toggle; the setter triggers re-render. State is a plain page field — **not** persisted to cookie or `PersistentComponentState`.
- Default: `ViewMode.Grid`.
- Skeleton/loading state (`ViewModel.Page == null`) is unaffected — keep the existing skeleton grid; the toggle may render disabled or hidden while loading (implementer's call).
### Acceptance criteria
- The TracksView page shows a two-option grid/list toggle, right-aligned at the top, defaulting to grid.
- **Grid mode, art card:** at rest the card shows only album art (no title/artist/genre/year/FAB overlay); on hover a solid navy panel fades in over the art revealing all info and the play FAB; moving the pointer away hides it again. Transition is smooth (~180ms), no flicker.
- **Grid mode, no-art card:** the navy fallback card shows title/artist/genre/year/FAB at all times, with no hover change — identical to current behaviour.
- **Touch / coarse-pointer devices:** grid art cards show their info overlay by default (no permanently hidden info).
- **List mode:** tracks render as a vertical stack of full-width rows, each ≤~88px tall, with play FAB at far left, ~64px art thumbnail (or navy placeholder), artist-over-title text block, and right-aligned genre chip over year.
- Clicking a row (outside the FAB) navigates to that track's detail page; clicking the FAB plays/pauses without navigating, in both modes.
- The play/pause icon and active state reflect the live player exactly as in grid mode, in both modes.
- List rows are legible on both light and dark themes.
- Toggling between modes is instant, preserves the current page and player state, and resets to grid on page reload (no persistence).
### Out of scope
- Persisting the selected view mode (cookie / `PersistentComponentState` / query string) — explicitly ephemeral this ticket.
- Mobile-specific gestures (long-press, swipe) beyond the coarse-pointer hover fallback above.
- Keyboard navigation beyond what the anchor + `MudFab` give by default; no roving-tabindex or arrow-key list traversal.
- Any change to sorting, filtering, pagination, or the `TracksViewModel` data path.
- Album/genre grouping views (covered separately under Phase 2.2).
- Animation of mode transitions (cards/rows reflowing) — a plain re-render is acceptable.
---
## Phase 2.5 — "Stream Now" — random-track instant play
**Status:** Fully landed on 2026-06-07 (feature complete, endpoints + service methods + menu wiring, merged to dev).
- **What:** The nav-bar "Stream Now ▶" CTA (desktop and mobile, in `DeepDrftMenu.razor`) today just navigates to `/tracks`. Change it to **pick a random track from the library and start playing it immediately**, in place, without forcing the user onto the gallery page.
- **Why it matters:** It is the single most prominent call-to-action on the site and currently does the least interesting thing — it dumps the listener on a grid and asks them to choose. "Stream Now" should mean *now*: one click, music plays. It is also the lowest-friction way for a first-time visitor to hear the collective's output, which is the whole point of the public site. Borrowed pattern: the "shuffle play" / "I'm feeling lucky" affordance (Spotify's shuffle, Bandcamp's "play random").
#### UX flow
1. User clicks "Stream Now ▶" (desktop CTA or mobile menu item).
2. Button enters a brief loading affordance (disabled + subtle pulse/spinner) while a track is selected — the selection requires at least one HTTP round-trip, so this is not instantaneous.
3. A random track is chosen from the full library via `GET api/track/random` (server-side `ORDER BY RANDOM() LIMIT 1`).
4. The player begins streaming that track via the existing `AudioPlayerBar` dock at the bottom of the layout. The dock is already cascaded into every page by `AudioPlayerProvider` in `MainLayout`, so it appears/animates in exactly as it does when a gallery card is clicked.
5. The user does **not** navigate. They stay on whatever page they were on (most likely `Home`). Music plays; the dock is the player surface.
6. On mobile, the menu closes (`CloseMobileMenu`) as part of the click, same as the existing nav links.
#### Edge cases
- **Empty library (`TotalCount == 0`):** No track to play. The button surfaces a non-blocking, transient message ("No tracks yet") and does nothing else. Does not navigate, does not error-toast aggressively. This is a legitimate cold-start state, not a failure.
- **Metadata fetch fails (HTTP error):** Surfaces a transient error on the button ("Couldn't reach the library — try again"), re-enables the button, does not navigate. Reuses the existing `ApiResult` failure check pattern (`result is { Success: true, ... }`).
- **Track fails to stream (selected track is valid metadata but the audio stream errors):** Already handled downstream by `StreamingAudioPlayerService` / error handlers and surfaced through `IPlayerService.ErrorMessage` and the dock. Stream Now does not duplicate stream-error handling in the menu; it hands off to the same `SelectTrackStreaming` path every other play uses, and inherits that path's error behavior.
- **Player already playing something:** Stream Now interrupts it and starts the random track. No confirmation prompt — "Stream Now" is an explicit user command to play something new.
- **Repeat clicks / same-track-twice:** Acceptable for v1 to occasionally re-pick the currently-playing track. If it becomes annoying, a cheap "exclude `PlayerService.CurrentTrack?.Id`" filter on the candidate set is a one-line follow-up; noted for future.
#### Implementation
**API endpoint (`DeepDrftAPI`):**
- New `GET api/track/random` (unauthenticated, mirroring `GET api/track/page`) returning a single `TrackDto` via `ORDER BY RANDOM() LIMIT 1` (or the EF-Core equivalent) server-side.
**Service methods:**
- New method on `ITrackDataService` / `TrackClientDataService`: `Task<ApiResult<TrackDto?>> GetRandomTrack()`, calling `GET api/track/random` via `TrackClient`.
**Menu wiring (`DeepDrftMenu.razor`):**
- Injects `ITrackDataService` and cascaded `IStreamingPlayerService`. Click handler: calls `GetRandomTrack()`, on success calls `PlayerService.SelectTrackStreaming(track)`, on empty/failure shows transient message.
**AudioContext user-gesture constraint:**
- Browsers (Safari most strictly) only allow an `AudioContext` to start inside a user-gesture call stack. `SelectTrackStreaming` starts the context. Stream Now does an `await GetRandomTrack()` (network) before calling `SelectTrackStreaming` — an intervening `await` can lose gesture context on Safari. Mitigation: `IStreamingPlayerService.WarmAudioContext()` method added, called synchronous with the gesture at the start of the click handler, before the network await.
#### Acceptance criteria — as implemented
- Clicking "Stream Now ▶" (desktop CTA) with a non-empty library selects a track uniformly at random (server-side) and begins streaming it via the existing dock, without navigating away.
- Clicking "Stream Now ▶" in the mobile menu does the same and closes the mobile menu.
- Selection issues **exactly one** HTTP request (`GET api/track/random`).
- With an empty library, the button shows a transient "no tracks" message and does not navigate or throw.
- With a failed metadata fetch, the button shows a transient error, re-enables, and does not navigate.
- A track that streams-errors after selection surfaces through the *existing* player error path — no new error handling in the menu.
- The menu component contains no track-fetch logic inline: selection goes through `ITrackDataService.GetRandomTrack()`; playback goes through `PlayerService.SelectTrackStreaming`. No duplication.
- Audio plays on the first click after a cold load on Chrome and Safari — user-gesture/AudioContext constraint satisfied via `WarmAudioContext()` hook.
- While selection is in flight, the button is disabled to prevent double-launch.
---
## Phase 2.1 — Cover art / image vault wired through
**Status:** Fully landed on 2026-06-07 across three waves (Wave 1: API + vault; Wave 2-A: public proxy + TrackCard; Wave 2-B: CMS upload UI), merged to dev.
- **What:** `MediaVaultType.Image` is implemented end-to-end and exercised by tests, but the production surface only registers a `tracks` vault of type `Audio`. `ImagePath` on `TrackEntity` is a free-form URL string today; it should resolve to an entry in an image vault served by `DeepDrftContent`.
- **Why it matters:** Prerequisite for any album/release/genre view that wants to look like a music site rather than a list of rows. Also closes a free-form-string surface area that will otherwise calcify.
- **Shape:**
- Register a second vault (`images` or `art`, type `Image`) in `Startup.ConfigureDomainServices` and in the CLI.
- Add `GET api/image/{entryKey}` (unauthenticated, mirrors track read) and `PUT api/image/{entryKey}` (ApiKey, mirrors track write) on `DeepDrftContent`.
- Change `TrackEntity.ImagePath` semantics from "URL" to "image vault entry key" (column rename optional — could remain `image_path` with semantic shift, or could become `image_entry_key` for clarity).
- Add an image processor sibling of `AudioProcessor`.
- **Prerequisite:** None.
- **Constraint:** This is a small schema-semantics migration. Existing rows have `null` ImagePath in production so there is no data to migrate, but commit before the field has real content to avoid a backfill.
---
## Embeddable iframe player
**Status:** Feature complete on 2026-06-07 (commit `c83b132 feature: Embed Frame Player`, merged to dev).
A standalone, chrome-free player surface intended for embedding in an `<iframe>` on external pages (e.g. a Bandcamp-style "play this track here" widget on a third-party blog or the collective's socials). Distinct from the dock player, which lives inside the full site chrome.
**Shape as implemented:**
- `Layout/EmbedLayout.razor` — a minimal layout: `MudThemeProvider` + `AudioPlayerProvider` wrapping `@Body`, with no nav, menu, or marketing chrome. Reuses the dark-mode `PersistentComponentState` round-trip (`CONTEXT.md §3.6`) so an embedded player still honours the theme.
- `Pages/FramePlayer.razor` — routed at `/FramePlayer`, uses `EmbedLayout`, renders a single `<AudioPlayerBar Fixed />`. Reads a `TrackEntryKey` from the query string and auto-selects that track on load.
- `Services/ITrackDataService.cs` + `TrackClientDataService.cs` — a new track-metadata fetch seam (`GetPage` + `GetTrack(trackId)`) so a component can resolve a single track by key without the gallery VM. Render-mode-agnostic (one seam, SSR and WASM both served by it).
**Why it matters:** An embeddable player turns every external mention of a DeepDrft track into a play surface. It is the lightest-weight distribution lever the product has — no app install, no account, just a link that plays. Fits the collective's "get the music in front of people" posture.
**Deferred:** CORS for arbitrary external embedders — handle when a concrete external host requires it.
---
## Phase 1.1 — Backward seek
**Status:** Landed on 2026-06-07 (commits `daa334a`, `8581103` on seek-fix branch, merged to dev).
- **What:** Seeking to a position *below* `playbackOffset` currently clamps silently to the start of the in-memory buffer segment instead of going to the user's chosen time. The forward "seek beyond buffer" path already exists in `WavOffsetService` + the client's offset-request path; backward seek is the missing mirror.
- **Why it matters:** The single highest-impact missing feature in the player. Scrub-bar drags backward feel broken — they appear to seek but land in the wrong place.
- **Shape:** Reuse the existing `GET api/track/{id}?offset=` pathway. The client decision becomes "is the target inside the decoded window?" — if yes, jump within the buffer (existing behaviour); if no (forward or backward), tear down the decoder and re-request from the byte-aligned offset.
- **Implementation:** `WaveformSeeker` control supports both forward and backward seeking. The seek logic decides whether to jump within the decoded buffer or tear down and re-request from a byte-aligned offset regardless of direction. Backward seek observes the same `blockAlign` rounding-down as forward seek (enforced in `WavOffsetService.alignedOffset` and `StreamDecoder.calculateByteOffset`). Teardown/reinit respects the generation-counter pattern introduced by the concurrent-seek fix.
---
## Phase 6 — Responsive home page (mobile layout)
**Status:** All six slices landed on 2026-06-07 (branches `home-mobile-grid`, `home-mobile-hero`, `home-mobile-cta`, merged to dev).
The home page (`DeepDrftPublic.Client/Pages/Home.razor` + `Home.razor.css`) is built entirely on hand-rolled CSS grids with **no responsive breakpoints**. Every horizontal split is a fixed column count that holds on desktop and collapses on mobile — six genre cards in one row, four feature cards in one row, two 50/50 splits, and a `space-between` CTA banner all overflow or squash below ~960px. This phase migrates the layout to be mobile-first while preserving the wireframe-faithful visual styling.
**Guiding principle for the whole phase: separate *layout* from *style*.** The scoped CSS in `Home.razor.css` does two jobs — it positions columns (the part that breaks on mobile) and it paints the design (colors, fonts, padding, hover states, pseudo-element flourishes). Only the *column-positioning* job migrates. Colors, typography, padding, `::before`/`::after` decorations, and hover transitions stay in scoped CSS untouched.
**Two tools, used deliberately:**
- **`MudGrid` + `MudItem`** (with `xs`/`sm`/`md` breakpoints) for splits where MudBlazor's margin-based gutters are acceptable: hero, section-header, section-split, CTA banner. This is the house pattern already used in `DeepDrftShared.Client/Components/TracksGallery.razor` (`<MudItem xs="12" sm="6" md="4" lg="3">`). Match it. Breakpoints: xs=0, sm=600, md=960, lg=1280, xl=1920. MudGrid breakpoint attributes are CSS-only at runtime — **do not** inject `IBreakpointService` or any breakpoint-observer service into the component.
- **CSS `@media` query on the existing scoped grid** for the two card blocks (genre grid, features grid). These two are explicitly *not* MudGrid candidates — see 6.1 for why. Adding a media query that overrides `grid-template-columns` is the minimal, correct move there.
**The one trap to avoid (read before touching the card grids):** the genre grid and features grid use `gap: 1px` (genre) / shared `border-right` (features) to render the cards as a single block divided by **hairline rules** — the cards touch, and the 1px gap *is* the divider line. `MudGrid`'s `Spacing` parameter produces margin-based gutters (multiples of 4px, with outer margin), which **cannot reproduce a shared hairline edge**. Porting these two grids to `MudGrid` would silently destroy the hairline-divider aesthetic. Keep them as CSS grid; only add breakpoints.
### 6.1 Genre grid + features grid — CSS media queries only
- **What:** `.genre-grid` (`repeat(6, 1fr)`) and `.features-grid` (`repeat(4, 1fr)`) get responsive column counts via `@media` overrides in `Home.razor.css`. No markup change to the grid containers themselves.
- **Why MudGrid is wrong here:** Both grids render cards as a contiguous block separated by 1px hairline rules (`.genre-grid` via `gap: 1px` over a border-colored background; `.features-grid` via per-card `border-right`). MudGrid's `Spacing` gutters are margins, not shared edges — switching would break the visual. Pure CSS keeps the hairline intact while still going responsive.
- **Stacking behavior:**
- Genre grid: md+ `repeat(6, 1fr)` (current); sm `repeat(3, 1fr)`; xs `repeat(2, 1fr)`. (Six genres divide cleanly into 3 and 2 — no orphan row.)
- Features grid: md+ `repeat(4, 1fr)` (current); sm `repeat(2, 1fr)`; xs `1fr` (single column stack).
- **Scoped CSS that must change:** Add two `@media (max-width: 960px)` and `@media (max-width: 600px)` blocks overriding `grid-template-columns` on `.genre-grid` and `.features-grid`. For `.features-grid` at the stacked/2-col breakpoints, the per-card `border-right` produces a dangling right border on the last card in each visual row — switch the hairline strategy at those breakpoints (e.g. apply `border-bottom` on cards and drop `border-right`, or move to `gap: 1px` like the genre grid). Specify the exact rule when implementing; the constraint is "no dangling/missing hairlines at any breakpoint."
- **Order of independence:** Fully independent. Touches only `Home.razor.css`, no markup. Can be the first slice landed and verified in isolation.
### 6.2 Hero — MudGrid for content, CSS for the background color split
- **What:** `.hero` is `grid-template-columns: 1fr 1fr` at `min-height: 100vh`, with `.hero-left` painted white and `.hero-right` painted navy — a full-viewport color split. Migrate the *content* columns to `MudGrid`; keep the *background color split* in CSS.
- **Why split the treatment:** MudGrid rows/items do not carry per-column background colors that bleed to the full viewport height. The white/navy vertical split is a visual property of the section, not of the content columns. Wrap `DeepDrftHero` and `NowPlaying` in `<MudItem xs="12" md="6">` inside a `<MudGrid>`, but keep the white/navy backgrounds on the section via CSS.
- **Stacking behavior:**
- md+: 50/50 split — hero copy left (white), NowPlaying right (navy). Current desktop look preserved.
- xs/sm: stack to single column — `DeepDrftHero` on top, `NowPlaying` below. The 100vh constraint should relax to `min-height: auto` (or a smaller min) when stacked, so the two stacked panels don't each demand a full viewport.
- **Scoped CSS that must change:**
- `.hero` keeps `min-height: 100vh` at md+; add `@media (max-width: 960px)` relaxing it (e.g. `min-height: auto`) and switching the background from a left/right split to a top/bottom split (or letting each `MudItem` carry its own background at the stacked breakpoint).
- The white/navy split: at md+ this can stay a CSS background on `.hero` (e.g. a `linear-gradient(to right, white 50%, navy 50%)` on the section, or backgrounds on the two MudItems via scoped classes). At xs/sm the split becomes top/bottom. Implementer picks gradient-on-section vs. background-per-item; the gradient-on-section approach survives the MudGrid gutter cleanly (gutters show the section background, not white margins).
- Remove `.hero`'s own `display: grid; grid-template-columns: 1fr 1fr` (MudGrid now owns column layout). Keep `overflow: hidden`.
- **Order of independence:** Independent of all other sections. Has the most CSS nuance (the color split) — schedule it where there's time to verify the split holds at every breakpoint, including the MudGrid gutter not showing a white seam.
- **Constraint:** `DeepDrftHero` and `NowPlaying` are child components with their own scoped CSS — **do not refactor them in this pass.** Layout is Home.razor's responsibility only.
### 6.3 Section header — MudGrid
- **What:** `.section-header` is `grid-template-columns: 1fr 2fr` (label+title left, body paragraph right) with `align-items: end`. Migrate to `MudGrid`.
- **Stacking behavior:** md+ keep the 1fr/2fr asymmetry via `<MudItem md="4">` (title) + `<MudItem md="8">` (body). xs/sm stack to `xs="12"` each — title block on top, body paragraph below.
- **Scoped CSS that must change:** Remove `display: grid; grid-template-columns: 1fr 2fr; gap: 4rem` from `.section-header`. The `align-items: end` baseline-alignment is a desktop nicety that's meaningless when stacked — preserve it at md+ only (MudGrid `Align.End` on the row, or a scoped rule). `.section-body`'s `align-self: end` similarly only applies in the side-by-side layout; harmless when stacked but can be dropped from the stacked breakpoint.
- **Order of independence:** Independent. Small, low-risk — good warm-up slice.
### 6.4 Section split (origin + connect) — MudGrid
- **What:** `.section-split` is `grid-template-columns: 1fr 1fr` at `min-height: 60vh` — green "Origin" panel left, white "Connect" panel right, each a full-bleed colored column. Same shape as the hero (colored columns) but lower stakes (60vh, not full-viewport, and the colors are per-panel not a single split).
- **Stacking behavior:** md+ 50/50. xs/sm stack — Origin (green) on top, Connect (white) below.
- **Scoped CSS that must change:** Replace the grid container with `<MudGrid>` + two `<MudItem xs="12" md="6">`. Here the per-panel backgrounds (`.split-left` green, `.split-right` white) live on the panels themselves, so — unlike the hero — the color survives a MudGrid gutter only if the gutter is removed or the panels fill their items edge-to-edge. **Set `MudGrid Spacing="0"`** so the green and white panels meet with no white seam between them, preserving the current flush-color-block look. The `.split-left::before` decorative circle stays untouched. Relax `min-height: 60vh` to `auto` at the stacked breakpoint so each panel sizes to its content.
- **Order of independence:** Independent. The `Spacing="0"` decision here is the same family of problem as the hero seam — landing 6.2 first will surface the seam-handling approach to reuse here.
### 6.5 CTA banner — MudGrid or flex-wrap
- **What:** `.cta-banner` is `display: flex; justify-content: space-between` — headline left, two action buttons right. `.cta-actions` is an inline flex row of two buttons.
- **Stacking behavior:** md+ keep headline-left / actions-right. xs/sm stack — headline on top, actions below. At xs the two buttons should go full-width-stacked (or wrap) rather than sitting cramped side by side.
- **Approach — recommend the lighter touch:** This one does **not** need MudGrid. The container is already flex; adding `flex-wrap: wrap` + a media query that flips `flex-direction: column` and `align-items: stretch` at `max-width: 600px` achieves the stack with the least churn. MudGrid is also fine (`<MudItem xs="12" md="6">` × 2) if consistency with the other sections is preferred — but flex-column is fewer moving parts for a two-element banner. **Pick flex unless the implementer wants every section uniformly on MudGrid.**
- **Scoped CSS that must change:**
- `.cta-banner`: add `@media (max-width: 600px)``flex-direction: column; align-items: flex-start; gap: 2rem`.
- `.cta-actions`: add `flex-wrap: wrap` always; at xs, `width: 100%` with the two buttons (`.btn-white`, `.btn-outline-white`) going `flex: 1` or full-width so they don't crowd.
- The giant `.cta-banner::before` "DRFT" watermark (22rem) will overflow badly on mobile — add a media-query rule shrinking its `font-size` at xs (e.g. `clamp` or a fixed smaller size) or hiding it, so it doesn't force horizontal scroll. **This is a hidden overflow source independent of the flex layout — do not skip it.**
- **Order of independence:** Independent. The watermark-overflow fix is the non-obvious part; the flex stack itself is trivial.
### Phase 6 sequencing summary
All six slices are independent and touch only `Home.razor` + `Home.razor.css` (no child components, no shared CSS, no other pages). They can land in any order or in parallel. Recommended order by ascending risk: **6.3 (section header) → 6.1 (card grids) → 6.5 (CTA banner) → 6.4 (section split) → 6.2 (hero)** — warm up on the trivial MudGrid swap, get the no-MudGrid card grids done, then tackle the two color-split sections (6.4, 6.2) last since they share the gutter-seam problem and the second reuses the first's solution.
- **Why it matters:** The public site is the front door for a music collective whose listeners are disproportionately on phones (social-shared links, live-session discovery). A home page that overflows horizontally on mobile undercuts the entire "get the music in front of people" posture (`PLAN.md` in-flight iframe item makes the same bet). This is table-stakes polish, not a feature.
- **Prerequisite:** None. Pure presentation work on one page.
- **Constraint:** Do not refactor `DeepDrftHero` or `NowPlaying` (6.2 constraint). Do not touch `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (shared CSS) — all changes are scoped to `Home.razor.css`. Preserve every color/font/decoration; this phase changes *where columns break*, nothing about how the page looks at desktop width.
---
## Play-State Icon Normalization
**Status:** Phases 14 landed on 2026-06-06 (branches `track-card-play-state-wave1`, `track-card-play-state-wave2`, merged to dev).
### Phase 1 — Fix the gallery bug (correctness, smallest viable change)
**Landed 2026-06-06.**
Bound `TrackCard.IsPlaying` to real playback state instead of selection identity. In `TracksView`/`TracksGallery`, active track is now computed as `PlayerService.IsPlaying && CurrentTrack?.Id == track.Id`. Switched the card glyph from `MusicNote` to the `PlayArrow`/`Pause` vocabulary via `IsPaused` and `OnPause` parameters. Expanded `TracksView.OnPlayerStateChanged` to re-render on any state change, not only on `!IsLoaded` — ensures the gallery correctly reflects pause, play, track-change, and end-of-playback transitions.
**Component changes:**
- `TrackCard.razor` — added `[Parameter] bool IsPaused`, `[Parameter] EventCallback OnPause` parameters; removed `MusicNote` icon; now conditionally renders `PlayArrow` when not playing or `Pause` when playing.
- `TracksView.razor` — removed `_selectedTrack` field (selection now fully derived from service); removed `_clickCount`, `_lifecycleStatus`, `TestInteractivity` dev scaffolding; `OnPlayerStateChanged` now calls `StateHasChanged()` unconditionally instead of only on `!IsLoaded`.
- `TracksGallery.razor` — removed internal `SelectedTrack` mutation and `StateHasChanged` calls on play click; now fully controlled by parent; `SelectedTrack` parameter is read-only.
**Architecture notes:**
- Resolves the reported bug: gallery card now shows correct play/pause icon reflecting actual playback state.
- Enabling pause affordance on cards required extending `TrackCard` with `IsPaused` + `OnPause`, preserving the component's presentational contract (stays parameter-driven, lives in shared library).
- `TracksView.OnPlayerStateChanged` subscription pattern unchanged; expansion from selective to unconditional re-render ensures high-frequency state changes (like spectrum animation or per-sample progress) do not cause visual lag in the gallery.
### Phase 2 — Collapse dual selection state (SRP, prevents regression)
**Landed 2026-06-06.**
Eliminated divergence between `TracksView._selectedTrack` and `PlayerService.CurrentTrack`. `TracksGallery` is now fully controlled — the parent supplies and owns the active-track identity via parameter binding. Selection state is single-sourced from the player service.
**Component changes:**
- `TracksGallery.razor` — removed parameter-field write in `HandlePlayClick`; no longer calls `StateHasChanged()` on click. Raises `SelectedTrackChanged` callback for the parent to route.
- `TracksView.razor` — removed `_selectedTrack` backing field and its local mutation.
**Architecture notes:**
- Resolves the secondary defect: gallery's notion of "active track" can no longer lag the player.
- `TracksGallery` now a pure presentational component (reads `SelectedTrack`, raises `SelectedTrackChanged`, renders); all state derivation lives in the parent or the service.
### Phase 3 — Introduce the single transport-state resolver (DRY)
**Landed 2026-06-06.**
Introduced a unified glyph-mapping source: `PlaybackIcons.Resolve()` static method in `DeepDrftPublic.Client/Helpers/PlaybackIcons.cs`. This is the sole function responsible for mapping `(IsPlaying, IsPaused, trackId?, CurrentTrackId?)` to the correct transport icon (`PlayArrow`, `Pause`, or null). Replaces all hand-rolled ternaries across `TrackCard`, `PlayerControls`, and other surfaces.
**New code (`DeepDrftPublic.Client/Helpers`):**
- `PlaybackIcons.cs` — static `Resolve(bool isPlaying, bool isPaused, long? trackId, long? currentTrackId)` method returning `(string? Icon, bool IsActive, bool IsPaused)` tuple. Icon mapping is the single source of truth.
**Component changes:**
- `PlayerControls.razor(.cs)``IsPlaying` parameter removed from the `AudioPlayerBar → PlayerTransportZone → PlayerControls` chain. Instead, `PlayerControls` now subscribes to `IPlayerService.StateChanged` directly and calls `PlaybackIcons.Resolve()` to determine which icon to render and whether buttons are enabled/disabled.
- `TrackCard.razor` — consumes the tuple returned by `PlaybackIcons.Resolve()` to set `Icon`, `IsActive` (CSS class for highlighting), and `Disabled` state on the FAB.
**Architecture notes:**
- Eliminates the three-way duplication of "which icon for this state" logic.
- Icon vocabulary is now standardized across all surfaces (`PlayArrow`/`Pause` pair, no `MusicNote`).
- Future surfaces (queue list, now-playing chip, etc.) call the same `Resolve()` function instead of re-implementing the mapping.
### Phase 4 (optional, deferred) — Promote to a PlayStateIcon component
**Landed 2026-06-06.**
Created a new `PlayStateIcon.razor` component in `DeepDrftPublic.Client/Controls/` that encapsulates subscription + icon mapping + rendering. Rather than each surface calling `PlaybackIcons.Resolve()` and threading icons through parameters, surfaces now drop in `<PlayStateIcon />` and the component handles cascading, state subscription, and icon selection in one place.
**New component (`DeepDrftPublic.Client/Controls/PlayStateIcon.razor`):**
- Injects `IPlayerService` and subscribes to `StateChanged` on mount.
- Cascades `[CascadingParameter] DarkModeSettings DarkMode` for theming.
- Renders an icon button (or FAB) with the correct glyph via `PlaybackIcons.Resolve()`.
- Forwards `Disabled` parameter to the rendered MudIconButton/MudFab.
- Raises `OnClick` callback when user clicks.
**Component changes:**
- `PlayerControls.razor` — refactored to render its play/pause button via `<PlayStateIcon />` instead of a parameter-driven button. `IsPlaying` parameter removed from the component signature.
- The `AudioPlayerBar → PlayerTransportZone → PlayerControls` chain no longer threads `IsPlaying`/`IsPaused` down; subscription happens inside `PlayStateIcon`.
**Architecture notes:**
- `PlayStateIcon` handles the seam between `IPlayerService` (source of truth) and transport-icon rendering (presentation). This was the third surface (after `TrackCard` and `PlayerControls`); Phase 4 was triggered by the appearance of the third call site.
- Reduces parameter threading in the component tree (no more passing state flags through intermediate layers).
- New surfaces that need play/pause icons (queue list, hover-row play button, etc.) now have a reusable, off-the-shelf component instead of re-implementing subscription and mapping.
---
## WaveformSeeker Wave 3 — CMS PreProcessing panel
**Status:** W3 (CMS track-preprocessing panel) refactored on 2026-06-05 (branch `waveform-w3-cms`, merged to dev).
### W3 — CMS PreProcessing panel
**Landed 2026-06-05. Refactored 2026-06-05.**
Implemented the CMS surface for on-demand waveform profile generation. Initial implementation created a new `/tracks/preprocessing` page; refactored to fold the preprocessing panel into `TrackList.razor` as a second `MudTabPanel` alongside the existing Tracks tab.
**API endpoints (`DeepDrftAPI`):**
- `GET api/track/waveform-status` (ApiKey) — returns `WaveformStatusDto[]` with per-track profile existence (one entry per track in the database, indicating whether a profile sidecar exists in the vault).
- `POST api/track/{trackId}/waveform` (ApiKey) — triggers on-demand profile compute and store for an existing track. Skips if profile already exists; errors surface gracefully (no profile → HTTP 404, track not found → HTTP 400).
**Models (`DeepDrftModels`):**
- `WaveformStatusDto` — carries `TrackId`, `EntryKey`, `TrackName`, `HasProfile` boolean, and metadata for display/sorting.
**CMS service (`ICmsTrackService` / `CmsTrackService` in `DeepDrftManager`):**
- `GetWaveformStatusAsync()` — service method wrapping the `api/track/waveform-status` call; returns `Result<WaveformStatusDto[]>` for error handling.
- `GenerateWaveformProfileAsync(entryKey)` — service method wrapping the per-track generation endpoint; returns `Result<bool>` (success → true, profile already exists → true, error → false with result code).
**CMS UI (`DeepDrftManager/Components/Pages/Tracks/TrackList.razor`):**
- Added "Preprocessing" `MudTabPanel` as the second tab in `TrackList.razor`, alongside the existing "Tracks" tab.
- Table layout within the panel: track name, artist, "Profile Status" indicator (✓ or ○), with a per-row `Generate` button.
- Sequential "Generate All Missing" bulk action button — iterates tracks with `HasProfile == false`, calls `GenerateWaveformProfileAsync`, shows progress. On completion, refreshes the table.
- The standalone `TrackPreProcessing.razor` page at `/tracks/preprocessing` was eliminated; the page route is no longer exposed.
- Nav link to preprocessing removed from `Index.razor` dashboard (consolidation makes a separate link unnecessary; the tab is discoverable from `TrackList.razor`).
**Architecture notes:**
- Waveform generation on-demand (not automatic on upload like in W1) is intentional: Wave 1 profiles were computed for all future-uploaded tracks; Wave 3 adds a retroactive tool to populate profiles for existing tracks uploaded before Wave 1. The bulk action supports batching.
- Service calls are fire-and-forget-result, not throw-on-error — `GenerateWaveformProfileAsync` returns a `Result` for the caller to inspect. This matches the FileDatabase philosophy (errors in compute/store are swallowed at the service boundary, callers check return values).
- Profile endpoint uses the same `WaveformProfileService` that computes profiles during upload — no new algorithm or storage path introduced. CMS can only trigger on-demand what the upload path does automatically.
- HTTP cache headers are deferred (same as W1-T2). Each `api/track/waveform-status` call lists all tracks and their current state; this is acceptable for the admin surface where refreshes are infrequent.
- **Consolidation rationale:** Folding the preprocessing panel into `TrackList` reduces UI fragmentation — track management (list, add, edit, delete, preprocess) lives in one cohesive view rather than split across separate pages. The tab structure keeps preprocessing distinct from the main track listing without requiring a dedicated route.
---
## WaveformSeeker Wave 2 — DOM seekbar + Interop module
**Status:** W2 (WaveformSeeker component) landed on 2026-06-05 (branch `waveform-w2-seeker`, pending merge to dev).
### W2 — WaveformSeeker component (seekbar replacement)
**Landed 2026-06-05.**
Implemented the interactive WaveformSeeker component: a bar-chart-styled seekbar replacing `MudSlider` in `PlayerSeekZone`, with DOM-rendered progress split via CSS and lazy-loaded pointer-capture drag interop.
**Component changes (`DeepDrftPublic.Client/Controls/AudioPlayerBar`):**
- `WaveformSeeker.razor` (+ `.cs`, `.css`) — new component consuming `WaveformProfile double[]?` and `Duration`, rendering bars as DOM elements with clip-overlay progress. Single CSS variable (`--seek-position`) changes per seek gesture; no per-bar re-render.
- Pointer-capture drag wired via `waveformSeeker.js` (ES module, lazy-loaded). Calculates seek target from click/drag position and invokes `OnSeekRequested` callback (delegates to `IPlayerService.SeekAsync`).
- Flat floor-height fallback when profile is unavailable — seek gesture always works, with or without loudness data.
- `PlayerSeekZone.razor` — now hosts `WaveformSeeker` in place of the removed `MudSlider` placeholder.
**Interop changes (`DeepDrftPublic/Interop/audio/`):**
- New `waveformSeeker.ts` module (separate from the TS audio bundle) — `PointerCaptureHandler` class managing `pointerdown` / `pointermove` / `pointerup` lifecycle. Compiled to `waveformSeeker.js` in `wwwroot/js/audio/`.
- Module loaded on first use (not bundled with audio stack) to defer its parse cost until the player is expanded and the seekbar is visible.
**`.gitignore` scoping:**
- Added scoped negation to track hand-authored `waveformSeeker.js` alongside existing TS-output ignore rule — allows the compiled JS to be committed for fast startup without committing intermediate TS compiler outputs.
**Service changes (`IPlayerService` / `AudioPlayerService` / `StreamingAudioPlayerService`):**
- New `WaveformProfile double[]?` property added to service interface and implementations.
- Fetched fire-and-forget on track load via `GetWaveformProfileAsync(trackId, cancellationToken)` — existing HTTP call from W1-T2.
- Cancellable via the track-reset flow (same cancellation token that stops spectrum animation).
- Cleared on reset with all other track state.
**Testing:**
- Manual verification: seekbar renders flat when profile unavailable; dragable when profile present; CSS clip-overlay tracks seek position correctly.
**Architecture notes:**
- WaveformSeeker does not re-fetch the profile — it consumes the same `IPlayerService.WaveformProfile` fetched during track load. No additional HTTP round-trip per seek gesture.
- Interop module (`waveformSeeker.js`) is independent of the audio playback stack — can be updated or replaced without touching audio scheduling logic.
- Pointer-capture semantics ensure seek is responsive even when the browser's event queue is saturated by animation frames.
- Flat fallback ensures seek gestures always work, even on tracks with no profile data (uploaded before W1, or on profile-generation failure).
---
## WaveformSeeker Wave 1 — Loudness profile + layout refactor
**Status:** W1-T1 (backend loudness computation), W1-T2 (HTTP transport), and W1-T3 (player layout refactor) landed on 2026-06-05.
### W1-T1 — Backend waveform loudness profiling
**Landed 2026-06-05.**
Implemented Phase 1 of the WaveformSeeker feature (`product-notes/spectrum-seeker.md`): loudness-profile computation and storage for preprocessed waveform data.
**Backend changes (`DeepDrftContent`):**
- Added `ILoudnessAlgorithm` strategy interface for swappable loudness computation.
- Implemented `RmsLoudnessAlgorithm` — first loudness algorithm using root-mean-square; future LUFS implementation swaps in via the same interface without touching service, wire format, or storage.
- `WaveformProfileService` — computes peak-normalized loudness profile from PCM WAV (one linear buffer pass), buckets by time slice, normalizes to `[0,1]`, stores as byte-quantized sidecar in new `profiles` vault (FileDatabase `MediaFileVault`).
- `WaveformProfileOptions` — config-bound options object carrying `BucketCount` (default 512) and future algorithm-selection knobs.
**Integration changes (`DeepDrftAPI`):**
- Wired `WaveformProfileService` into `UnifiedTrackService.UploadAsync` — profile computed on upload, stored immediately, failure silently swallowed (consistent with FileDatabase philosophy in `CLAUDE.md`).
**Models (`DeepDrftModels`):**
- `WaveformProfileDto` — carries quantized profile data; format independent of algorithm or bucket count.
**Testing (`DeepDrftTests`):**
- 4 new unit tests: RMS algorithm correctness against known-good PCM samples, swappable-algorithm contract (two strategies swap cleanly), and integration with `WaveformProfileService`.
**Architecture notes:**
- Profile is derived binary content; stored in FileDatabase vault sidecar per `CLAUDE.md` principle ("binary content lives in the vault").
- Loudness measure is an abstraction (not hardwired RMS) — RMS→LUFS future change requires only a new `ILoudnessAlgorithm` implementation, no refactoring of service, component, or wire format.
- No external audio-processing dependency pulled in for RMS — reuses existing PCM parser from `AudioProcessor`.
- Cost: one linear pass over PCM buffer at upload (few hundred ms for typical WAV); never on playback path.
### W1-T2 — Waveform profile HTTP transport
**Landed 2026-06-05.**
Implemented Phase 2 of the WaveformSeeker feature: HTTP transport layer for waveform profile data from backend to client, enabling client-side display of loudness profiles in future seeking UI.
**API endpoint (`DeepDrftAPI`):**
- New `GET api/track/{trackId}/waveform` endpoint — unauthenticated, returns `WaveformProfileDto` (base64-encoded quantized bytes + `BucketCount`) on success, 404 if track or profile not found.
- Leverages existing `WaveformProfileService` to load profile from vault on demand.
- No authentication required — mirrors `GET api/track/{id}` streaming policy (public audio access).
**Proxy forward (`DeepDrftPublic`):**
- Thin buffered forward in `TrackProxyController` — proxies request from client to `DeepDrftAPI` waveform endpoint with same path parameters.
- Preserves error semantics: 404 from API passes through to client; network errors surface as HTTP errors.
**HTTP client (`DeepDrftPublic.Client`):**
- New `TrackMediaClient.GetWaveformProfileAsync(trackId, cancellationToken)` method on the content HTTP client.
- 404 response maps to `Result.Failure` (fail-result signal for WaveformSeeker to render flat fallback).
- Network/timeout errors map to separate `Result.Failure` with distinct code.
- Callsite can discriminate via result error code whether to retry (transient) or render fallback (not found).
**Architecture notes:**
- Transport layer is independent of loudness algorithm (W1-T1) — client receives opaque quantized bytes; future algorithm changes on backend do not affect wire format, as long as `BucketCount` is included.
- HTTP caching via ETag/Last-Modified is deferred to Phase 2 optimization work.
- Profile loading from vault is on-demand (not pre-cached in memory) — load cost amortizes across all requests to the same track.
- 404 handling unambiguous: client renders flat fallback, distinguishing "track has no profile" from "track not found" via error code.
### W1-T3 — Player layout refactor (SpectrumVisualizer relocation + VolumeZone rename)
**Landed 2026-06-05.**
Implemented Phase 3 of the WaveformSeeker feature: architectural layout move separating live-spectrum visualization from loudness-over-time seeking.
**Conceptual split:**
- Live-spectrum (FFT frequency bars, `SpectrumVisualizer`) moved from `PlayerSeekZone` → stacked above the volume slider in new `VolumeZone`. Conceptually with the output level.
- Static loudness-over-time (future `WaveformSeeker`) takes over the seek zone. Conceptually with transport position.
**Component changes (`DeepDrftPublic.Client/Controls/AudioPlayerBar`):**
- `VolumeControls.razor` → renamed **`VolumeZone.razor`** for symmetry with transport and seek zones; now a vertical stack hosting `SpectrumVisualizer` above the volume slider.
- `SpectrumVisualizer``BucketCount` parameter defaulted to 24 buckets (down from 32) to fit the narrow volume cluster; set `flex-shrink: 0` to pin the spectrum to a fixed footprint above the volume control.
- `PlayerSeekZone.razor``SpectrumVisualizer` block removed; placeholder for future `WaveformSeeker` component.
**CSS changes (`AudioPlayerBar.razor.css`):**
- Adjusted volume cluster width constraints to accommodate the 24-bucket spectrum stacked above.
- Responsive layout unchanged at 600px breakpoint (single-row transport/volume with full-width seek below on narrow; same 3-zone layout on wide).
**Scope:**
- Pure layout move; zero change to spectrum animation lifecycle, player logic, or seek gesture handling.
- Both `AudioPlayerBar` and `SpectrumVisualizer` components affected.
- Build clean: 0 errors, 0 new warnings.
**Notes for future work:**
- `PlayerSeekZone` is now ready for the `WaveformSeeker` component (W1-T4/Phase 4 onwards).
- Volume cluster can comfortably accommodate 24 FFT bars; 32 would cause visual cramping (why the override exists).
- Spectrum visualization lifecycle (subscription to `StateChanged`, animation via `AudioInteropService.StartSpectrumAnimationAsync`) unchanged — only position in the DOM tree changed.
---
## Phase 2 — Product surface: player and theming
**Status:** Track card CSS scoping landed on 2026-06-05. Track card glass theming landed on 2026-06-05. AudioPlayerBar responsive unification and SpectrumVisualizer fix landed on 2026-06-05. Track view CSS consolidation landed on 2026-06-05.
### Track Card CSS Scoping
**Landed 2026-06-05.**
Moved track card rules from the global stylesheet into an isolated scoped stylesheet, eliminating style leakage and enabling independent maintenance of the component's appearance.
**CSS changes:**
- `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` §8 — removed all track card rules (`.deepdrft-track-card-*`, `.deepdrft-track-title`, `.deepdrft-track-artist`, `.deepdrft-track-meta`); replaced with a pointer comment directing readers to `TrackCard.razor.css`.
- `DeepDrftShared.Client/Components/TrackCard.razor.css` — created new scoped stylesheet with all card rules: container styling, text-colour hierarchy (title, artist, meta), theme-variant selectors (`.deepdrft-theme-dark` / `.deepdrft-theme-light`), and glass background + border styling.
- Applied `::deep` pseudo-selector to the three MudText text-color rules (`deepdrft-track-title`, `deepdrft-track-artist`, `deepdrft-track-meta`) so CSS isolation doesn't suppress colour overrides on MudBlazor elements.
- Eliminated all theme-variant selectors in favour of a single-vocabulary colour scheme: navy-glass fallback, `--deepdrft-white` title, `--deepdrft-green-accent` artist, `rgba(250,250,248,0.45)` meta. Matches the `NowPlayingCard` aesthetic.
- `DeepDrftShared.Client/Components/TracksGallery.razor.css` — moved `.deepdrft-track-gallery-item-center` layout rule from global stylesheet into scoped CSS alongside the existing gallery container rules.
**Scope:**
- Affected components: `TrackCard.razor` (shared, consumed by public site and CMS) and `TracksGallery.razor` (shared).
- CSS in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (global) and two scoped stylesheets.
- Build clean: 0 errors, 0 new warnings.
**Architecture notes:**
- CSS isolation now protects track card rules from accidental mutation by unrelated global changes.
- Light-mode visual is now consistent: single vocabulary eliminates the three-green collision and establishes a stable text hierarchy (off-white title → muted artist → fainter meta).
- Scoped stylesheet pattern mirrors existing usage in other components (`AudioPlayerBar.razor.css`, `NowPlayingCard.razor.css`), establishing a consistent maintenance model.
---
### Track View CSS Consolidation
**Landed 2026-06-05.**
Implemented CSS consolidation and hierarchy fixes across three components: removed dead layout rules, unified horizontal inset ownership, and resolved the three-green collision in dark mode by demoting artist text and changing the genre chip variant.
**Component changes:**
- `DeepDrftPublic.Client/Pages/TracksView.razor` — removed dead `tracks-page-wrapper` class and associated inert flex/height/padding rules; `MudContainer` now owns horizontal inset via `MaxWidth.Large`.
- `DeepDrftShared.Client/Components/TracksGallery.razor.css` — reduced to `box-sizing: border-box`; removed redundant padding and inert height constraint.
- `DeepDrftShared.Client/Components/TrackCard.razor` — changed genre chip from `Variant.Filled` to `Variant.Outlined` to distinguish it from the play FAB.
**CSS changes (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` §8):**
- Text color rules restructured: base `color: inherit`, both dark and light treatments guarded under `.deepdrft-theme-dark` / `.deepdrft-theme-light` ancestors at `0,2,0` specificity.
- Artist text demoted from `green-accent` to `rgba(250,250,248,0.65)` in dark mode (leaving green as a purely accent/interactive signal — FAB and chip border).
- Meta text (album/year) at `rgba(250,250,248,0.45)` in dark mode.
- Genre chip treatment now supports outlined styling (borders + text only, no filled ground).
**Scope:**
- CSS in `deepdrft-styles.css` and scoped stylesheets for `TracksView.razor` and `TracksGallery.razor`.
- Both `DeepDrftPublic.Client` and `DeepDrftShared.Client` components affected.
- Build clean: 0 errors, 0 new warnings.
**Architecture notes:**
- Resolved the three-green visual hierarchy collapse (artist + genre chip + play FAB all rendered the same saturated green). Now: title off-white, artist muted, genre = outlined green tag, FAB = solid green action — a clear three-tier hierarchy matching `NowPlayingCard` vocabulary.
- Consolidated horizontal inset ownership to `MudContainer` (removes duplicate paddings that stacked across three layers).
- Removed inert flex-grow and height rules that encoded a sticky-footer intent that was not actually achieved; page layout via normal block flow is cleaner.
**Status:**
### Track Card Glass Theming
**Landed 2026-06-05.**
Aligned `TrackCard` component visual language with the `NowPlayingCard` aesthetic via glass background + text hierarchy. Two coordinated changes:
**Razor changes (`DeepDrftShared.Client/Components/TrackCard.razor`):**
- Removed `mud-theme-secondary` class and `Color="Color.Surface"` attributes from all four `MudText` elements, handing color control to CSS.
- Added semantic class hooks: `deepdrft-track-title` (track name), `deepdrft-track-artist` (artist), `deepdrft-track-meta` (album and release year).
- Changed MudCard `Elevation="4"``Elevation="0"` to align with glass-panel vocabulary (no drop shadow).
**CSS changes (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` §8):**
- Dark theme: navy-glass fallback panel (`color-mix(in srgb, var(--deepdrft-navy) 55%, transparent)` + `backdrop-filter: blur(8px)` + translucent border), matching `NowPlayingCard` glass vocabulary.
- Text hierarchy (dark): title in off-white, artist in moss-green accent, meta in muted off-white — mirrors the `NowPlayingCard` hierarchy.
- Content scrim behind text (dark): dark navy gradient to guarantee legibility over both glass fallback and album art.
- Light theme: subtle navy-tint fallback on off-white, light text inherits body colour for legibility.
- Glass border on card container (dark): `1px solid rgba(250, 250, 248, 0.12)` for aesthetic consistency.
**Scope:**
- `TrackCard` component in shared `DeepDrftShared.Client` consumed by both public site and CMS.
- CSS in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (public site only, not loaded by CMS).
- Build clean: 0 errors, 0 new warnings.
**Notes for future work:**
- Genre chip text still uses `Color.Primary` (moss-green); it now sits alongside moss-green artist text. Consider a distinct genre-chip treatment (3a) in future polish work.
---
**Status:** AudioPlayerBar responsive unification and SpectrumVisualizer fix landed on 2026-06-05.
### AudioPlayerBar Responsive Unification
**Landed 2026-06-05.**
Collapsed the two divergent Razor trees in `AudioPlayerBar.razor` (`@if (_isDesktop)` / `@else`) into a single markup tree where CSS — not a runtime breakpoint flag — drives the responsive layout. Removed `IBrowserViewportService`, the `_isDesktop` field, `OnAfterRenderAsync`, and the viewport subscription/unsubscription from the code-behind.
**Structural changes:**
- Single `.player-layout` flex container (in `AudioPlayerBar.razor.css`) replaces the dual-branch conditional. Three children (`PlayerTransportZone`, `VolumeControls`, `PlayerSeekZone`) in source order; media query at 600px (`Sm` breakpoint) reorders via CSS `order` property and forces `SeekZone` to full-width below the transport/volume row on narrow viewports.
- `PlayerTransportZone` flips its internal axis (vertical ↔ horizontal) via scoped CSS override of `MudStack` `flex-direction` at the 600px boundary — no parameter added to the component.
- `::deep` prefix removed from `MudBlazor` component-class selectors in `PlayerTransportZone.razor.css` now that axis is purely CSS-driven and no runtime flag determines structure.
- **SpectrumVisualizer bars now appear on first expand** — fixed by subscribing to the multicast `StateChanged` event (same pattern used by `AudioPlayerBar`), ensuring animation is initialized after mount.
**Scope:**
- Unified responsive layout (desktop/mobile branches merged into single tree).
- Both `AudioPlayerBar` and `SpectrumVisualizer` components affected.
- Build clean: 0 errors, 0 new warnings.
**Notes for future work:**
- First-render layout flash eliminated by construction (CSS media query evaluates at paint, not async subscription).
### Track Card Plain-Shell Refactor
**Landed 2026-06-05.**
Eliminated `!important` declarations from track card CSS by replacing MudBlazor surface components with plain HTML. Implemented per `product-notes/track-card-css-architecture.md` Option A.
**Razor changes (`DeepDrftShared.Client/Components/TrackCard.razor`):**
- `MudCard``<div class="deepdrft-track-card-container">`
- Fallback `MudPaper``<div class="deepdrft-track-card-fallback">`
- `MudCardContent``<div class="deepdrft-track-card-content">`
- `MudText`, `MudChip`, `MudFab` unchanged.
**CSS changes (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` §8):**
- Removed four `!important` declarations from `.deepdrft-track-card-container`, `.deepdrft-track-card-fallback` base, and the dark/light theme-scoped variants.
- Plain single-class selectors now win by cascade without `!important`; theme-scoped rules use normal specificity hierarchy.
**Scope:**
- `TrackCard` component in shared `DeepDrftShared.Client` consumed by both public site and CMS.
- CSS in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (public site only).
- Build clean: 0 errors, 0 new warnings.
**Notes for future work:**
- Plain-div shell re-enables CSS isolation as an option (a `TrackCard.razor.css` would now work against the shell divs). Section 8's public-only scoping remains convenient; isolation is optional for future polish.
- Removes the structural mismatch of using a Material surface component (`MudCard`/`MudPaper`) solely as a layout shell. TrackCard now mirrors the construction of `NowPlayingCard` (plain divs + themed CSS).
---
## Track Detail Page (/track/{entryKey})
**Status:** Landed on 2026-06-06 (branch `track-detail-page`, merged to dev). Cover art integration completed on 2026-06-08.
A focused, editorial single-track view in `DeepDrftPublic.Client`. The track gallery answers "what is in the library"; this page answers "tell me about *this* track" — full metadata, cover art, and a single prominent play affordance, styled to feel like a record-sleeve back-cover rather than a form. Link-only for now (reached from a gallery card / Now Playing), not a top-level nav entry.
### Implemented solution
**Components (`DeepDrftPublic.Client/Pages/`):**
- `TrackDetail.razor` + `TrackDetail.razor.cs` — routed at `@page "/track/{EntryKey}"` with `@rendermode InteractiveWebAssembly`. Three render states (loading skeleton, loaded layout, 404 not-found) driven by `TrackDetailViewModel` flags. Cascades `IStreamingPlayerService` for play-affordance wiring. Subscribes to `PlayerService.StateChanged` to keep the play button label in sync with live transport state.
**ViewModel (`DeepDrftPublic.Client/ViewModels/`):**
- `TrackDetailViewModel` — scoped, registered in `Startup.ConfigureDomainServices`. Depends on `ITrackDataService` (render-mode-agnostic seam, existing). Properties: `Track` (loaded DTO), `IsLoading`, `NotFound`. Single `Load(entryKey)` command idempotent per route, fully resetting all three flags on each call to prevent stale track bleed on navigation.
**DI registration (`DeepDrftPublic.Client/Startup.cs`):**
- `TrackDetailViewModel` registered scoped.
**UI layout:**
1. Subtle back-link `← All tracks` to `/tracks`, muted low-emphasis text affordance.
2. Large square cover art block — displays album art via a `MudPaper` div with `background-image: url('api/image/{entryKey}')` when `ImagePath` is present; falls back to placeholder themed `MudPaper` with `Album` glyph when cover unavailable.
3. Title (TrackName, display-serif h3) / artist (h6, primary accent) masthead.
4. Prominent **Play** button under masthead with state-reactive label ("Play" / "Pause" / "Resume" keyed to current track and playback state via `PlayerService` subscription).
5. `MudDivider` separator.
6. Optional-field metadata block (Album, Genre, ReleaseDate) — definition-row layout, rendered only if non-null; all three omit silently if unavailable.
7. Skeleton loading state matching the loaded layout silhouette.
8. 404 messaging on not-found.
**CSS classes (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` §14):**
- `deepdrft-track-detail-container` — centered single column, max-width, auto-margins, vertical padding.
- `deepdrft-track-detail-cover` — square aspect-ratio frame, rounded, subtle shadow/border (light/dark theme-aware), `overflow: hidden` for clean image crop.
- `deepdrft-track-detail-cover-art` — applied to `MudPaper` div; sets `background-size: cover`, `background-position: center` for responsive fill within the cover frame.
- `deepdrft-track-detail-masthead` — title/artist spacing, display-serif via existing `deepdrft-` font classes.
- `deepdrft-track-detail-meta` — metadata block rhythm, small-caps muted labels.
- `deepdrft-track-detail-back` — back-link affordance, muted color, hover treatment.
**Inbound links wired (`DeepDrftShared.Client/Components/TrackCard.razor`):**
- Cover block and title/artist are now `display:contents` anchors to `href="/track/{track.EntryKey}"`, making the entire card clickable to the detail page.
- Play button on the card untouched (still functions independently for gallery playback).
**Architecture notes:**
- Render mode `InteractiveWebAssembly` (server prerender → WASM hydrate) mirrors `TracksView` consistency.
- `TrackDetailViewModel` is scoped (per-instance), not singleton — navigating between `/track/A` and `/track/B` reuses the same scoped instance, so `Load` must fully reset state to prevent cross-navigation bleed.
- Play button implements the same `PlayerService.StateChanged` subscription pattern as `TracksView` — mandatory for label coherence when the dock bar drives state.
- Cover-art integration (2026-06-08): the page now displays album art via a `MudPaper` div with `background-image: url('api/image/{entryKey}')` when `ImagePath` is present; a placeholder with the `Album` glyph renders when unavailable. CSS background rendering degrades gracefully (blank surface) if a vault entry is missing.
- Page is link-only navigation (not in the header `MenuPages`); reachability depends on inbound links from `TrackCard` and Now Playing surfaces, which were wired simultaneously.
---
**Status:** Desktop AudioPlayerBar redesign landed on 2026-06-04.
### Desktop AudioPlayerBar — migrate to MudBlazor theme system
**Landed 2026-06-04.**
Desktop branch of `AudioPlayerBar.razor` migrated off dead CSS palette tokens (`--charleston-*`, `--lowcountry-*`, `--deepdrft-theme-*` — none of which are defined in the live stylesheet) onto the active MudBlazor theme system. This was simultaneously a bug fix (player styling broken against the current palette) and a structural redesign.
**Structural changes:**
- `.player-backdrop` div replaced with `MudPaper Elevation="8"` — surface colour now derives from `--mud-palette-surface` via the live theme, and flips automatically with dark mode (off-white in light, navy in dark).
- Three new zone sub-components extracted: `PlayerTransportZone` (left transport cluster), `PlayerSeekZone` (centre seek+spectrum, owns the seek pointer-handler logic), `PlayerWindowControls` (minimize/close buttons). These remove duplication (seek handlers no longer inline-copied) and name the layout zones explicitly.
- `MudStack` replaces all raw `<div class="d-flex gap-*">` throughout the desktop branch and sub-components (`PlayerControls`, `VolumeControls`, `TimestampLabel`).
- `SpectrumVisualizer` bar colour fixed: `var(--mud-palette-primary)` replaces the undefined `--deepdrft-theme-secondary` token.
- Minimized dock replaced with `MudFab Color="Color.Primary"` — rounded button picking up themed primary colour with no hand-rolled gradient.
- `AudioPlayerBar.razor.css` shrunk from ~176 lines (mostly dead-token theming) to ~74 lines (geometry and positioning only).
**Scope:**
- Desktop branch only (`@if (_isDesktop)`). Mobile branch unchanged by design.
- Build clean: 0 errors, 0 new warnings.
**Notes for future work:**
- Mobile branch is also currently broken against the live palette for the same reason (spectrum bars + shared dead-token rules have no colour). A companion migration for mobile is implied but out of scope for this task — marked for future Phase 2 work.
---
## Deployment Infrastructure
**Status:** CD pipeline infrastructure landed on 2026-06-04.
+76 -50
View File
@@ -2,61 +2,77 @@
Living orientation doc for what this repo is, how it is currently shaped, and where it appears headed. Sits alongside the root `CLAUDE.md` (operational guidance) — this file is the product/architecture view.
> **Drift notice.** The root `CLAUDE.md` and every folder-level `CLAUDE.md` currently in the tree describe the project as `.NET 9`. The most recent commit upgraded all projects to `.NET 10` (every `.csproj` now targets `net10.0`, packages pinned at `10.0.1`). Until those docs are refreshed, treat any framework-version claim in them as stale. The other staleness items are listed at the bottom of this file.
> **Status.** The root `CLAUDE.md` is current — it reflects the post-split ten-project solution, `net10.0`, and the dual-app topology. This file (`CONTEXT.md`) was the lagging document and §2 / §4 / §7 below have been brought back into line with the root `CLAUDE.md` as of 2026-06-06. Folder-level `CLAUDE.md` files are still being swept (`DOC_PLAN.md`); treat framework-version and structural claims in any *folder* `CLAUDE.md` not yet rewritten as potentially stale until that sweep lands.
---
## 1. What this project is
DeepDrftHome is the home + listening surface for **DeepDrft**, a two-person electronic music collective based in Charleston, SC (per `DeepDrftWeb.Client/Pages/Home.razor`). The product is, at minimum:
DeepDrftHome is the home + listening surface for **DeepDrft**, a two-person electronic music collective based in Charleston, SC (per `DeepDrftPublic.Client/Pages/Home.razor`). The product is, at minimum:
- A public-facing site (hero, about, "experience" features).
- A public-facing site (hero, about, "experience" features) at `DeepDrftPublic`.
- A **track gallery** that browses a library of WAV recordings, plays them in-browser with a persistent dock-style player, and supports seek (including seek beyond what's been streamed so far).
- An admin CLI for adding tracks (Terminal.Gui or scripted), running locally against the same dual-database substrate the site uses.
- A browser-based **CMS** (`DeepDrftManager`) for adding, editing, and deleting tracks — gated behind AuthBlocks login and the `Admin` role. This replaced the former `DeepDrftCli` Terminal.Gui admin tool, which has been retired.
The interesting engineering bet is the **dual-database split**: structured track metadata in SQLite via EF Core, and binary media + per-vault indexes in a hand-rolled `FileDatabase` that lives on disk. The split is enforced across two ASP.NET Core hosts so that the browser never reaches the database directly.
The interesting engineering bet is the **dual-database split**: structured track metadata in PostgreSQL via EF Core, and binary media + per-vault indexes in a hand-rolled `FileDatabase` that lives on disk. The split is enforced through a dedicated authority host (`DeepDrftAPI`) so that the browser never reaches the database directly.
---
## 2. Solution shape (current)
Eight projects in `DeepDrftHome.sln`, plus an external `NetBlocks` referenced from `C:\lib\NetBlocks\`.
Ten projects in `DeepDrftHome.sln`, plus an external `NetBlocks` referenced from `C:\lib\NetBlocks\`. The solution is split into **two independent Blazor applications** — the public site (`DeepDrftPublic`) and the CMS (`DeepDrftManager`) — both fronting a single dual-database authority host (`DeepDrftAPI`).
```
DeepDrftWeb ASP.NET Core host. Blazor Web App (Server + WASM render modes).
Owns the SQL-backed API (api/track/page), MudBlazor theme/host,
TypeScript→JS audio interop sources under Interop/.
DeepDrftWeb.Client Blazor WebAssembly assembly. All interactive UI lives here —
pages, controls, player services, dark-mode/theme plumbing,
HTTP clients for both backends.
DeepDrftWeb.Services Class library. EF Core: DeepDrftContext, TrackConfiguration,
Migrations, TrackRepository, TrackService. Sharable between
the web host and the CLI (avoids duplicating data-access).
── Public application ──────────────────────────────────────────────────────
DeepDrftPublic ASP.NET Core host. Blazor Web App (Server + WASM render
modes). Owns the browser-facing proxy controller for
api/track/* (metadata listing + audio streaming),
MudBlazor theme prerender, and TypeScript→JS audio interop
sources under Interop/. The public listening surface.
DeepDrftPublic.Client Blazor WebAssembly assembly. All interactive public UI —
pages, the player stack, dark-mode plumbing, HTTP clients
for the backend. Consumed by DeepDrftPublic.
DeepDrftContent ASP.NET Core host. Binary content API (api/track/{id}).
ApiKey middleware, CORS, ForwardedHeaders. Returns audio bytes
(with optional byte offset) and accepts PUT of AudioBinaryDto.
DeepDrftContent.Services Class library. The FileDatabase implementation in full
── CMS application ─────────────────────────────────────────────────────────
DeepDrftManager ASP.NET Core host. Blazor Web App (InteractiveServer).
Hosts all CMS Razor components/pages (Components/Pages/Cms/,
Components/Pages/Tracks/, Components/Layout/CmsLayout.razor,
Components/Shared/ — inlined from the former DeepDrftCms RCL).
Gated by AuthBlocks login + hierarchical Admin role. All track
operations proxy via ICmsTrackService / CmsTrackService.
── Dual-database authority ─────────────────────────────────────────────────
DeepDrftAPI ASP.NET Core host. The single authority over both databases
(SQL metadata + FileDatabase binary). AuthBlocks API host
(registration, migration/seed, JWT endpoints). Seven track
endpoints (stream, vault write, upload, delete, paged list,
single metadata read, metadata update).
DeepDrftData Class library. EF Core domain logic: DeepDrftContext,
TrackConfiguration, Migrations, TrackRepository, TrackService,
TrackManager. Consumed by DeepDrftAPI and tests.
DeepDrftContent Class library. The FileDatabase implementation in full
(Models, Services, Utils, Abstractions, Constants),
WavOffsetService, AudioProcessor, TrackService (the content-side
orchestrator that processes WAVs and stores them in a vault).
WavOffsetService, AudioProcessor, content-side TrackService.
Consumed by hosts and tests.
── Shared ──────────────────────────────────────────────────────────────────
DeepDrftShared.Client Razor Class Library. Shared Blazor components consumed by
BOTH DeepDrftPublic and DeepDrftManager (e.g. TrackCard,
TracksGallery) for consistency across public and admin surfaces.
DeepDrftModels Shared contracts: TrackEntity, TrackDto, PagingParameters<T>,
PagedResult<T>. The only project all three layers reference.
DeepDrftCli Console app. Two modes: classic `add` / `list` / `help` and
`gui` (Terminal.Gui). Consumes BOTH service libraries directly
(it's a local admin tool, not a network client).
PagedResult<T>, plus waveform DTOs. Every project references this.
DeepDrftTests NUnit. Covers the FileDatabase, MediaVault, IndexSystem,
MediaVaultFactory, SimpleMediaTypeRegistry, utility code, and
model behaviour. References DeepDrftContent.Services.
MediaVaultFactory, SimpleMediaTypeRegistry, utility code, model
behaviour, and the waveform loudness algorithm. References
DeepDrftContent.
NetBlocks (external) Result patterns: Result, ResultContainer<T>, ApiResult<T>,
ApiResultDto<T>. Referenced via absolute path.
```
Two stray .sln files (`WebAPI.sln`, `WebUI.sln`, `CLI.sln`) exist at the root alongside `DeepDrftHome.sln`. `DeepDrftHome.sln` is the canonical solution; the others appear to be subsets.
**Naming history (for readers of older docs/commits):** `DeepDrftWeb``DeepDrftPublic`, `DeepDrftWeb.Client``DeepDrftPublic.Client`, `DeepDrftWeb.Services``DeepDrftData`, `DeepDrftContent.Services``DeepDrftContent` (the host that previously owned the binary API is gone; its proxy duties moved into `DeepDrftPublic`, its authority duties into `DeepDrftAPI`). `DeepDrftCli` and the `DeepDrftCms` RCL have both been removed — the CLI retired in favour of the CMS, and the CMS RCL was inlined into `DeepDrftManager`.
**Subdomain topology (deployment):** `deepdrft.com` (public) and `manage.deepdrft.com` (CMS), behind nginx. CD infrastructure (Gitea workflows + installer scripts + systemd/nginx templates) has landed — see `COMPLETED.md` "Deployment Infrastructure."
---
@@ -147,17 +163,21 @@ In dev, the host serves the original `.ts` sources at `/Interop/...` for source-
Recent commits (newest first):
- `style simplification and publish upgrades for dotnet 10`
- `Styles & Home Page Content Cleanup Mobile Menu System & Dark Mode Cookie Theme Draft`
- `Theming Draft 2`
- `2026 Deep DRFT Theme Draft 1 WIP`
- `Spectrum Visualizer for player & Layout`
- `docs: archive play-state icon normalization; update DeepDrftPublic.Client CLAUDE.md`
- `Consolidate play/pause icon logic into PlaybackIcons mapper and PlayStateIcon component`
- `Reflect real playback state on gallery cards and toggle pause/resume`
- `WASM State Fixes`
- `CMS Home autoredirect to /tracks`
- `WaveformSeeker Improvements` / WaveformSeeker waves 13
- (earlier: AudioPlayerBar responsive unification, CMS build-out, the two-app split, deployment infrastructure)
Three observations:
Observations:
1. **The current arc is presentation, not capability.** The last five commits are framework upgrade, theming, content/layout cleanup, mobile menu, dark-mode persistence, and the spectrum visualiser. The playback substrate, streaming, and seek-beyond-buffer machinery landed earlier and is stable enough to support cosmetic iteration on top.
2. **The "Track Gallery" is the only real page.** `/tracks` is the working surface; `/` is marketing copy. Nav (in `Pages.cs`) defines only `Home` + `Track Gallery`.
3. **Content surface is narrow on purpose.** The DeepDrftContent API exposes exactly two routes: `GET api/track/{id}` (with optional `offset`) and `PUT api/track/{id}` (ApiKey). There is no listing endpoint there; listing lives on DeepDrftWeb because listings are SQL queries.
1. **The big structural moves have landed.** Since the last revision of this doc, three large initiatives shipped: the **two-app split** (public/CMS separation with `DeepDrftAPI` as the dual-database authority), the **browser CMS** replacing the CLI (auth via AuthBlocks, stealth-routed `/cms/*`, full add/list/edit/delete parity), and **CD infrastructure** (Gitea workflows + host installer + systemd/nginx templates). The substrate is no longer the frontier — the product and presentation layers are.
2. **The recent arc is player UX polish.** The latest wave of work is the WaveformSeeker (loudness-profile seekbar), AudioPlayerBar responsive unification, and play-state icon normalization (a single `PlaybackIcons` resolver + `PlayStateIcon` component, gallery cards reflecting real playback state with pause/resume). Presentation iteration on a stable streaming core.
3. **The "Track Gallery" is still the only real public content page.** `/tracks` is the working listening surface; `/` is the (reskinned) marketing home. Nav (in `Layout/Pages.cs`) is still essentially `Home` + `Track Gallery`. The CMS adds admin surfaces under `/cms` but those are not public.
4. **The metadata/streaming surface is consolidated on `DeepDrftAPI`.** It exposes seven track endpoints (stream, vault write, upload, delete, paged list, single-metadata read, metadata update) plus waveform endpoints. `DeepDrftPublic` is a thin browser-facing proxy in front of it; the browser never reaches `DeepDrftAPI` or the databases directly.
5. **In flight (working tree, not yet committed):** an **embeddable iframe player** (`EmbedLayout.razor`, `FramePlayer.razor`, a new `ITrackDataService` seam) — a chrome-free single-track play surface for embedding off-site. Partial and not yet compiling; see `PLAN.md` "In-flight — Embeddable iframe player" for the open questions.
---
@@ -167,7 +187,7 @@ Captured here so the next round of planning has a starting point — none of thi
- **More vault types in active use.** `MediaVaultType.Image` exists end-to-end (tests cover it) but the production surface only registers a `tracks` vault of type `Audio`. The path to releases/albums probably runs through images first (cover art via `ImagePath`, which is currently a free-form URL string).
- **More than one collection view.** The `TrackCard` already conditionally renders `ImagePath`, `Album`, `Genre`, `ReleaseDate` — the data shape supports album-grouped or genre-filtered views without schema work.
- **Upload from the web side, not just the CLI.** The CLI is currently the only producer of tracks. A web-side upload would re-use `DeepDrftContent.Services.TrackService.AddTrackFromWavAsync` and pair it with a `TrackService.Create` on the SQL side. The `[ApiKeyAuthorize]` middleware on `PUT api/track/{id}` is already in place.
- **Web upload — landed.** *(Historical note: this was a "likely direction" when the CLI was the only producer. It has since shipped.)* The CMS (`DeepDrftManager`) now produces tracks via `POST api/track/upload` on `DeepDrftAPI`, proxied through the auth-gated CMS surface. The CLI has been retired. The dual-write rollback gap (`PLAN.md §4.3`) still stands.
- **Live/session content.** The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these yet; they would likely need new vault types (`MediaVaultType.Media` is the obvious home for video) and new entity tables.
- **Non-WAV formats.** Today the producer side is WAV-only (`AudioProcessor.ProcessWavFileAsync` validates RIFF/WAVE/PCM). `MimeTypeExtensions` already knows mp3/flac/aac/ogg/m4a — the gap is a processor per format and a decoder strategy in the JS player (currently WAV-specific).
- **Search / filter on the gallery.** `TracksViewModel` exposes `SortBy` / `IsDescending` but no filter. `TrackService.GetPaged` accepts only sort, not filter. Adding filter would be a natural next step on the same pagination contract.
@@ -187,14 +207,20 @@ Captured here so the next round of planning has a starting point — none of thi
## 7. Staleness in existing docs (for doc-keeper to address)
Captured so the next sweep of folder-level `CLAUDE.md` files can correct in one pass.
Two layers of drift remain. The root `CLAUDE.md` and this `CONTEXT.md` are current; the lag is now in **folder-level `CLAUDE.md` files** and the in-tree `FileDatabase` README. `DOC_PLAN.md` holds the per-folder rewrite briefs, but note that `DOC_PLAN.md` itself was authored against the *pre-split* project names (2026-05-16) and is partly superseded — see the warning at the end of this section.
- Every folder `CLAUDE.md` says ".NET 9" / "ASP.NET Core 9.0"; reality is `net10.0` across the board.
- `DeepDrftModels/CLAUDE.md` and `DeepDrftContent.Services/FileDatabase/README.md` reference `TrackEntity.MediaPath`; the field is `EntryKey` and the column is `entry_key`.
- `DeepDrftContent/CLAUDE.md` describes a `FileDatabase/` tree inside `DeepDrftContent/`; that tree has moved entirely to `DeepDrftContent.Services/FileDatabase/`. The DeepDrftContent host now contains only `Controllers/`, `Middleware/`, `Models/` (settings POCOs), `environment/`, `Program.cs`, `Startup.cs`.
- `DeepDrftContent/CLAUDE.md` documents only the PUT endpoint; the production API now also has `GET api/track/{id}?offset=` (unauthenticated read, with `WavOffsetService` for offset streaming).
- `DeepDrftWeb/CLAUDE.md` describes EF Core, repositories, services, migrations as living inside `DeepDrftWeb/Data` and `DeepDrftWeb/Services`. They have all moved to `DeepDrftWeb.Services`. The only things still in `DeepDrftWeb` are `Controllers/TrackController.cs`, `Services/DarkModeService.cs`, `Startup.cs`, `Program.cs`, `Components/`, `Interop/`, `wwwroot/`.
- `DeepDrftWeb.Client/CLAUDE.md` lists the `Pages/` directory as containing `Counter.razor` / `Weather.razor` (demo); those are gone. The real client structure is `Pages/Home.razor` + `Pages/TracksView.razor`, plus the `Controls/AudioPlayerBar/` cluster, `Controls/AudioPlayerProvider.razor`, `Services/AudioInteropService.cs` + `AudioPlayerService.cs` + `StreamingAudioPlayerService.cs` + `IPlayerService.cs` + dark-mode services, `Common/DarkModeSettings.cs` + `Common/DDIcons.cs`, and `Layout/Pages.cs` + `Layout/DeepDrftMenu.razor`.
- The `DeepDrftWeb.Services` and `DeepDrftContent.Services` projects have **no** `CLAUDE.md` yet — they are where most of the domain logic actually lives, so this is the biggest gap.
- `DeepDrftCli/CLAUDE.md` references `appsettings.json`; the CLI actually loads `environment/connections.json` into `CliSettings` (with `ConnectionString` and `VaultPath`). The "Available Commands" section is otherwise current, including the `gui` Terminal.Gui mode and interactive `add`.
- `DeepDrftContent.Services/FileDatabase/README.md` (an in-tree dev README, not a CLAUDE.md) refers to `ImageDirectoryVault`; the type is `ImageVault`. It also describes `EntryKey` as removed in favour of strings, which is accurate, but its diagram still says "FileDatabase.csproj (.NET 9.0)" — the FileDatabase no longer has its own csproj at all (it's a subdirectory of `DeepDrftContent.Services`).
**Project-rename drift (the big one).** The two-app split renamed or removed most projects. Any folder `CLAUDE.md` still using the old names is wrong at the structural level, not just the framework-version level:
- `DeepDrftWeb``DeepDrftPublic`; `DeepDrftWeb.Client``DeepDrftPublic.Client`; `DeepDrftWeb.Services``DeepDrftData`.
- `DeepDrftContent.Services` (class library) is now just `DeepDrftContent`; the old `DeepDrftContent` *host* is gone — binary-API duties split between the `DeepDrftPublic` proxy and the `DeepDrftAPI` authority.
- `DeepDrftCli` and the `DeepDrftCms` RCL are **deleted**. Any `CLAUDE.md` for them should be removed, not rewritten.
**Known content drift to correct in the sweep:**
- Framework version: any folder `CLAUDE.md` still saying ".NET 9" / "ASP.NET Core 9.0" — reality is `net10.0` across the board.
- `TrackEntity.MediaPath` references (notably the `FileDatabase/README.md`) — the field is `EntryKey`, column `entry_key`.
- The `FileDatabase/README.md` refers to `ImageDirectoryVault` (the type is `ImageVault`) and a "FileDatabase.csproj (.NET 9.0)" that no longer exists (FileDatabase is a subdirectory of `DeepDrftContent`).
- `DeepDrftData` and `DeepDrftContent` are where most domain logic lives and are the highest-value targets for accurate `CLAUDE.md` coverage.
**Already corrected (no longer stale):**
- `DeepDrftPublic.Client/CLAUDE.md` was rewritten in commit `9110b4b` and reflects the current player stack, `PlaybackIcons`/`PlayStateIcon`, and the post-split structure.
> **`DOC_PLAN.md` caveat.** `DOC_PLAN.md` predates the two-app split — its per-folder briefs reference `DeepDrftWeb*`, `DeepDrftCli`, and a SQLite backend (now PostgreSQL). Treat its *intent* (lead-with-truth, cross-reference root, no docs for build output) as still valid, but its *project list and per-folder details* need reconciling against the current ten-project solution before doc-keeper executes against it. Flag to Daniel whether to refresh `DOC_PLAN.md` first or let doc-keeper work from the root `CLAUDE.md` directly.
+24 -4
View File
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## One-line purpose
Dual-database authority for tracks (SQL metadata + FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. ApiKey middleware for track endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
Dual-database authority for tracks (SQL metadata + FileDatabase binary) and images (FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. Two image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for track/image endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
## What lives here now (only)
@@ -104,10 +104,29 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID.
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`.
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request clear optional fields), and persists the DTO via `ITrackService.Update`.
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?` (tri-state: null = no change, "" = clear, value = set).
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request for optional metadata clear those fields; `ImagePath` follows tri-state logic), and persists the DTO via `ITrackService.Update`.
- Returns 200 with the updated `TrackDto` on success. Returns 404 if track not found. Returns 500 on update error.
## The image endpoints (two endpoints)
### POST api/image/upload ([ApiKeyAuthorize])
**Authenticated endpoint.** Accepts an image file upload, stores it in the `images` vault, and returns the entry key.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Form field `image`** (`IFormFile`, required): the image bytes (PNG, JPEG, or other format supported by `ImageProcessor`). Maximum file size 50 MB.
- Calls `FileDatabase.RegisterResourceAsync("images", entryKey, imageBinary)` where `imageBinary` is produced by `ImageProcessor` (computes aspect ratio from headers, defaults 1.0 for unsupported formats).
- Returns 200 with JSON `{ entryKey }` on success. Returns 400 for missing file. Returns 500 if processing or vault operations fail.
### GET api/image/{entryKey} (unauthenticated)
Returns image bytes from the `images` vault.
- **Route parameter `entryKey`** (string): the entry id inside the `images` vault.
- Streams the image file directly from disk without buffering.
- Returns 404 if image not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
## ApiKey middleware behaviour
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
@@ -141,7 +160,8 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
2. Await `FileDatabase.FromAsync(VaultPath)` to load or create the database.
3. Register `FileDatabase` as singleton.
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
5. Register singletons: `WavOffsetService`, `AudioProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
6. Register singletons: `WavOffsetService`, `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
**In `Program.cs`** (SQL + AuthBlocks + wiring):
+125
View File
@@ -0,0 +1,125 @@
using DeepDrftAPI.Middleware;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.Processors;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ImageController : ControllerBase
{
// 50 MB ceiling — cover art is small, but this is generous headroom for high-res masters.
private const int MaxImageBytes = 50_000_000;
// FileDatabase is injected directly because image operations are vault-only: there is no
// SQL row for an image. The link to a track is TrackEntity.ImagePath (the entry key),
// written separately via PUT api/track/meta/{id}.
private readonly FileDatabase _fileDatabase;
private readonly ImageProcessor _imageProcessor;
private readonly ILogger<ImageController> _logger;
public ImageController(
FileDatabase fileDatabase,
ImageProcessor imageProcessor,
ILogger<ImageController> logger)
{
_fileDatabase = fileDatabase;
_imageProcessor = imageProcessor;
_logger = logger;
}
// POST api/image/upload ([ApiKeyAuthorize])
// Stores a cover-art image in the images vault and returns its generated entry key. Images
// are small enough to buffer whole in memory — no temp-file dance like the WAV upload path.
[ApiKeyAuthorize]
[HttpPost("upload")]
[RequestSizeLimit(MaxImageBytes)]
public async Task<ActionResult> UploadImage([FromForm] IFormFile? image, CancellationToken cancellationToken)
{
if (image is null || image.Length == 0)
{
return BadRequest("Image file is required");
}
if (image.Length > MaxImageBytes)
{
return BadRequest($"Image exceeds the {MaxImageBytes} byte limit");
}
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
{
_logger.LogWarning("UploadImage rejected: unsupported content type '{ContentType}'", image.ContentType);
return BadRequest($"Unsupported image content type: {image.ContentType}");
}
byte[] buffer;
await using (var stream = image.OpenReadStream())
using (var memory = new MemoryStream())
{
await stream.CopyToAsync(memory, cancellationToken);
buffer = memory.ToArray();
}
var imageBinary = _imageProcessor.Process(buffer, image.ContentType);
if (imageBinary is null)
{
// Process only returns null for an unsupported content type, already screened above —
// belt-and-suspenders in case ImageProcessor's validation diverges later.
_logger.LogWarning("UploadImage: ImageProcessor rejected content type '{ContentType}'", image.ContentType);
return BadRequest($"Unsupported image content type: {image.ContentType}");
}
var entryKey = Guid.NewGuid().ToString("N");
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
if (!stored)
{
_logger.LogError("UploadImage: vault write failed for entryKey={EntryKey}, contentType={ContentType}, size={Size}",
entryKey, image.ContentType, buffer.Length);
return StatusCode(500, "Failed to store image");
}
_logger.LogInformation("UploadImage succeeded: entryKey={EntryKey}, contentType={ContentType}, size={Size}",
entryKey, image.ContentType, buffer.Length);
return Ok(new { entryKey });
}
// GET api/image/{entryKey} (unauthenticated)
// Streams the image whole from disk. Same disk-streaming pattern as GET api/track/{trackId}
// offset-0 path: File() takes ownership of the inner stream on the success path; the wrapper
// is disposed only on the catch path.
[HttpGet("{entryKey}")]
public async Task<ActionResult> GetImage(string entryKey)
{
var vault = _fileDatabase.GetVault(VaultConstants.Images);
if (vault is null)
{
_logger.LogWarning("Images vault not found");
return NotFound();
}
var mediaStream = await vault.GetEntryStreamAsync(entryKey);
if (mediaStream is null)
{
_logger.LogWarning("Image not found: {EntryKey}", entryKey);
return NotFound();
}
string mimeType;
Stream innerStream;
try
{
mimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
innerStream = mediaStream.Stream;
}
catch
{
await mediaStream.DisposeAsync();
throw;
}
return File(innerStream, mimeType, enableRangeProcessing: false);
}
}
+140 -2
View File
@@ -5,7 +5,9 @@ using DeepDrftContent.Audio;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
@@ -18,6 +20,7 @@ public class TrackController : ControllerBase
private readonly WavOffsetService _wavOffsetService;
private readonly UnifiedTrackService _unifiedService;
private readonly ITrackService _sqlTrackService;
private readonly WaveformProfileService _waveformProfileService;
private readonly ILogger<TrackController> _logger;
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
@@ -32,6 +35,7 @@ public class TrackController : ControllerBase
WavOffsetService wavOffsetService,
UnifiedTrackService unifiedService,
ITrackService sqlTrackService,
WaveformProfileService waveformProfileService,
ILogger<TrackController> logger)
{
_trackContentService = trackContentService;
@@ -39,6 +43,7 @@ public class TrackController : ControllerBase
_wavOffsetService = wavOffsetService;
_unifiedService = unifiedService;
_sqlTrackService = sqlTrackService;
_waveformProfileService = waveformProfileService;
_logger = logger;
}
@@ -67,6 +72,63 @@ public class TrackController : ControllerBase
return Ok(result.Value);
}
// GET api/track/random (unauthenticated)
// Picks one track at random from the full library and returns its metadata. Public, same auth
// posture as GET api/track/page. Selection math lives in the SQL service/repository, not here.
// 404 when the library is empty (a valid state the client renders as "no tracks yet"), 200 +
// TrackDto otherwise. Literal segment, declared before "{trackId}" so it never routes there.
[HttpGet("random")]
public async Task<ActionResult> GetRandom(CancellationToken cancellationToken = default)
{
var result = await _sqlTrackService.GetRandom(cancellationToken);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetRandom failed: {Error}", error);
return StatusCode(500, "Failed to load track");
}
if (result.Value is null)
{
return NotFound();
}
return Ok(result.Value);
}
// GET api/track/waveform-status ([ApiKeyAuthorize])
// Admin backfill view: returns every track with a flag for whether a waveform profile is
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
// segment is never treated as a trackId.
[ApiKeyAuthorize]
[HttpGet("waveform-status")]
public async Task<ActionResult> GetWaveformStatus()
{
var tracks = await _sqlTrackService.GetAll();
if (!tracks.Success || tracks.Value is null)
{
var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetWaveformStatus failed to load tracks: {Error}", error);
return StatusCode(500, "Failed to load tracks");
}
var status = new List<WaveformStatusDto>(tracks.Value.Count);
foreach (var track in tracks.Value)
{
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
status.Add(new WaveformStatusDto
{
TrackId = track.Id,
EntryKey = track.EntryKey,
TrackName = track.TrackName,
HasProfile = profile is not null,
});
}
return Ok(status);
}
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackDto out.
// Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never
// touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write.
@@ -86,11 +148,12 @@ public class TrackController : ControllerBase
[FromForm] string? album,
[FromForm] string? genre,
[FromForm] string? releaseDate,
[FromForm] string? originalFileName,
[FromForm] long createdByUserId,
CancellationToken cancellationToken)
{
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}",
trackName, artist, wav?.Length);
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
trackName, artist, originalFileName, wav?.Length);
if (wav is null || wav.Length == 0)
{
@@ -144,6 +207,7 @@ public class TrackController : ControllerBase
string.IsNullOrWhiteSpace(genre) ? null : genre,
parsedReleaseDate,
createdByUserId,
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
cancellationToken);
if (!result.Success || result.Value is null)
@@ -198,6 +262,28 @@ public class TrackController : ControllerBase
return Ok(result.Value);
}
// GET api/track/meta/by-key/{entryKey}: single track metadata by vault entry key.
// Unauthenticated, like GET api/track/page and GET api/track/{id} — reachable through the
// public proxy. 3-segment route, so no collision with meta/{id:long} or {trackId}.
[HttpGet("meta/by-key/{entryKey}")]
public async Task<ActionResult> GetMetaByKey(string entryKey)
{
var result = await _sqlTrackService.GetByEntryKey(entryKey);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetMetaByKey failed for {EntryKey}: {Error}", entryKey, error);
return StatusCode(500, "Failed to load track");
}
if (result.Value is null)
{
return NotFound();
}
return Ok(result.Value);
}
// PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body.
[ApiKeyAuthorize]
[HttpPut("meta/{id:long}")]
@@ -223,6 +309,10 @@ public class TrackController : ControllerBase
track.Genre = request.Genre;
track.ReleaseDate = request.ReleaseDate;
// Only update ImagePath when the request explicitly provides a value (null = no change, "" = clear).
if (request.ImagePath is not null)
track.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
var update = await _sqlTrackService.Update(track);
if (!update.Success)
{
@@ -348,6 +438,54 @@ public class TrackController : ControllerBase
}
}
// GET api/track/{trackId}/waveform (unauthenticated)
// Returns the stored waveform loudness profile for a track, base64-encoded. Public listener
// data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored
// (existing tracks predate profiling, or computation failed at upload — the frontend falls back
// to a flat seekbar). The "waveform" literal suffix keeps this distinct from the audio route.
[HttpGet("{trackId}/waveform")]
public async Task<ActionResult> GetWaveform(string trackId)
{
var bytes = await _waveformProfileService.GetProfileAsync(trackId);
if (bytes is null)
{
_logger.LogInformation("No waveform profile for track: {TrackId}", trackId);
return NotFound();
}
return Ok(new WaveformProfileDto
{
BucketCount = bytes.Length,
Data = Convert.ToBase64String(bytes),
});
}
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
// Admin backfill: compute and store a waveform profile for an existing track from its vault
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
// WAV cannot be decoded or the vault write fails. Used by the CMS PreProcessing panel for
// tracks that predate the WaveformSeeker feature.
[ApiKeyAuthorize]
[HttpPost("{trackId}/waveform")]
public async Task<ActionResult> GenerateWaveform(string trackId)
{
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
if (audio is null)
{
_logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId);
return NotFound();
}
var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId);
if (!stored)
{
_logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId);
return StatusCode(500, "Failed to generate waveform profile.");
}
return Ok();
}
[ApiKeyAuthorize]
[HttpPut("{trackId}")]
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
-1
View File
@@ -26,4 +26,3 @@
</Project>
@@ -4,9 +4,15 @@ namespace DeepDrftAPI.Models;
/// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never
/// travels over this surface.
/// </summary>
/// <remarks>
/// <paramref name="ImagePath"/> follows tri-state semantics distinct from the other optional
/// fields: <c>null</c> leaves the existing value unchanged, an empty string clears it, and a
/// non-empty value is the images-vault entry key to link.
/// </remarks>
public record UpdateTrackMetadataRequest(
string TrackName,
string Artist,
string? Album,
string? Genre,
DateOnly? ReleaseDate);
DateOnly? ReleaseDate,
string? ImagePath = null);
+24 -1
View File
@@ -1,5 +1,6 @@
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.DTOs;
using NetBlocks.Models;
@@ -18,17 +19,20 @@ public class UnifiedTrackService
private readonly TrackContentService _contentTrackContentService;
private readonly ITrackService _sqlTrackService;
private readonly FileDb _fileDatabase;
private readonly WaveformProfileService _waveformProfileService;
private readonly ILogger<UnifiedTrackService> _logger;
public UnifiedTrackService(
TrackContentService contentTrackContentService,
ITrackService sqlTrackService,
FileDb fileDatabase,
WaveformProfileService waveformProfileService,
ILogger<UnifiedTrackService> logger)
{
_contentTrackContentService = contentTrackContentService;
_sqlTrackService = sqlTrackService;
_fileDatabase = fileDatabase;
_waveformProfileService = waveformProfileService;
_logger = logger;
}
@@ -45,10 +49,11 @@ public class UnifiedTrackService
string? genre,
DateOnly? releaseDate,
long createdByUserId,
string? originalFileName,
CancellationToken ct)
{
var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync(
tempFilePath, trackName, artist, album, genre, releaseDate);
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
if (unpersisted is null)
{
@@ -70,9 +75,27 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
// Best-effort waveform profile: both stores succeeded, so the upload is a success
// regardless of the profile outcome. A missing profile renders as a flat seekbar on the
// frontend, so a failure here is logged and swallowed — never fails the upload.
await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct);
return saveResult;
}
private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct)
{
try
{
var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey);
}
}
/// <summary>
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete
/// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete
+18
View File
@@ -19,6 +19,15 @@ namespace DeepDrftAPI
builder.Services.AddSingleton<AudioProcessor>();
builder.Services.AddSingleton<TrackContentService>();
// Image services
builder.Services.AddSingleton<ImageProcessor>();
// Waveform loudness profiling (upload-time, off the playback path)
builder.Services.Configure<WaveformProfileOptions>(
builder.Configuration.GetSection(nameof(WaveformProfileOptions)));
builder.Services.AddSingleton<ILoudnessAlgorithm, RmsLoudnessAlgorithm>();
builder.Services.AddSingleton<WaveformProfileService>();
// File Database
var fileDatabasePath = CredentialTools.ResolvePathOrThrow("filedatabase", "environment/filedatabase.json");
builder.Configuration.AddJsonFile(fileDatabasePath, optional: false, reloadOnChange: false);
@@ -32,6 +41,7 @@ namespace DeepDrftAPI
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult();
InitializeImageVault(db).GetAwaiter().GetResult();
return db;
});
@@ -45,5 +55,13 @@ namespace DeepDrftAPI
await fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
}
private static async Task InitializeImageVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.Images))
{
await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image);
}
}
}
}
+11 -1
View File
@@ -101,6 +101,16 @@ Used by the content API to serve seek-beyond-buffer requests. The player asks fo
PCM-only today. Other formats (mp3, flac, aac, ogg, m4a) are listed in `MimeTypeExtensions` but not implemented. The processor validates RIFF/WAVE/PCM format — anything else is rejected.
## Image processor
`ImageProcessor.ProcessImageAsync(buffer, mimeType)`:
1. Accepts raw image bytes and MIME type (e.g., `image/png`, `image/jpeg`).
2. Parses PNG or JPEG headers to extract image dimensions.
3. Computes aspect ratio (width / height). Defaults to 1.0 if parsing fails or format is unsupported.
4. Returns `ImageBinary` with MIME type and aspect ratio metadata.
5. **No disk I/O**: operates on `byte[]` only — no file reading required.
## Content-side TrackService (orchestrator)
### AddTrackFromWavAsync(filePath)
@@ -124,7 +134,7 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
## Vault constants
`VaultConstants.Tracks = "tracks"` — the one vault name in production use. New vault names go here when adding new vault types (e.g., `VaultConstants.Images = "images"` if image uploads are added).
`VaultConstants.Tracks = "tracks"` and `VaultConstants.Images = "images"` — the vault names in production use. New vault names go here when adding new vault types.
## Service registration
@@ -9,4 +9,15 @@ public static class VaultConstants
/// Vault name for storing audio tracks
/// </summary>
public const string Tracks = "tracks";
/// <summary>
/// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey.
/// </summary>
public const string WaveformProfiles = "waveform-profiles";
/// <summary>
/// Vault name for storing cover-art images, keyed by a generated entry key referenced
/// from <c>TrackEntity.ImagePath</c>.
/// </summary>
public const string Images = "images";
}
+1
View File
@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
@@ -219,6 +219,32 @@ public class AudioVault : MediaVault
}
}
/// <summary>
/// Concrete vault for plain <see cref="MediaBinary"/> entries (vault type
/// <see cref="MediaVaultType.Media"/>) — bytes plus an extension, no audio/image-specific
/// metadata. Used for sidecar artifacts such as waveform loudness profiles. The base
/// <see cref="MediaVault"/> already handles Media-typed storage via the registry; this only
/// provides the concrete factory the Image and Audio vaults also provide.
/// </summary>
public class MediaFileVault : MediaVault
{
private MediaFileVault(string rootPath, VaultIndex index, IndexFactoryService? factoryService = null)
: base(rootPath, index, factoryService) { }
public static async Task<MediaFileVault?> FromAsync(string rootPath, IndexFactoryService? factoryService = null)
{
var factory = factoryService ?? new IndexFactoryService();
var index = await factory.LoadOrCreateVaultIndexAsync(rootPath, MediaVaultType.Media);
if (index != null)
{
return new MediaFileVault(rootPath, (VaultIndex)index, factory);
}
return null;
}
}
/// <summary>
/// An open read-only stream over a vault entry plus the extension needed to
/// resolve its MIME type. Caller owns the stream and must dispose it.
@@ -11,6 +11,7 @@ public static class MediaVaultFactory
{
return mediaType switch
{
MediaVaultType.Media => await MediaFileVault.FromAsync(rootPath, factoryService),
MediaVaultType.Image => await ImageVault.FromAsync(rootPath, factoryService),
MediaVaultType.Audio => await AudioVault.FromAsync(rootPath, factoryService),
_ => null
@@ -31,7 +31,8 @@ public class SimpleMediaTypeRegistry : IMediaTypeRegistry
dto => MediaBinary.From(dto),
binary => new MediaBinaryDto(binary),
(key, ext, _) => new MetaData(key, ext),
(binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension));
(binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension),
async path => await MediaFileVault.FromAsync(path));
RegisterType<ImageBinary, ImageBinaryParams, ImageBinaryDto, ImageMetaData>(
MediaVaultType.Image,
+64 -1
View File
@@ -45,6 +45,55 @@ public class AudioProcessor
}
}
/// <summary>
/// Extracts the raw PCM data region and format parameters from a WAV buffer, reusing the
/// same chunk-walk and validation as metadata extraction. Returns null if the buffer is not
/// a valid PCM WAV (callers treat a null as "no profile computable" and continue) — unlike
/// <see cref="ExtractWavMetadata"/>, this does NOT fall back to synthetic defaults, because a
/// loudness profile over fabricated silence would be misleading.
/// </summary>
public PcmData? TryExtractPcm(ReadOnlySpan<byte> buffer)
{
// Copy the span to an array so the existing array-based parsers can be reused. The PCM
// slice returned is a view over this array (no second copy of the data region).
var bytes = buffer.ToArray();
var validation = ValidateWavStructure(bytes);
if (!validation.IsValid)
{
return null;
}
WavMetadata metadata;
try
{
metadata = ParseWavMetadata(bytes, validation);
ValidateAudioParameters(metadata);
}
catch
{
return null;
}
// Data bytes begin 8 past the "data" chunk id (4 id + 4 size). Clamp the declared size to
// what is actually present — some encoders write a size that overshoots the file.
var dataStart = validation.DataChunkPos + 8;
if (dataStart > bytes.Length)
{
return null;
}
var available = bytes.Length - dataStart;
var dataLength = Math.Min(metadata.DataSize, available);
if (dataLength <= 0)
{
return null;
}
var pcm = new ReadOnlyMemory<byte>(bytes, dataStart, dataLength);
return new PcmData(pcm, metadata.Channels, metadata.SampleRate, metadata.BitsPerSample);
}
/// <summary>
/// Extracts metadata from WAV file buffer with comprehensive validation
/// </summary>
@@ -268,4 +317,18 @@ public class AudioProcessor
public int FmtChunkPos { get; set; }
public int DataChunkPos { get; set; }
}
}
}
/// <summary>
/// The raw PCM sample region of a WAV plus the format parameters needed to interpret it.
/// <see cref="Pcm"/> is a view over the decoded buffer — the data chunk only, header excluded.
/// </summary>
/// <param name="Pcm">The PCM sample bytes (interleaved by channel, little-endian).</param>
/// <param name="Channels">Number of interleaved channels.</param>
/// <param name="SampleRate">Samples per second.</param>
/// <param name="BitsPerSample">Bit depth per sample (8, 16, 24, or 32).</param>
public readonly record struct PcmData(
ReadOnlyMemory<byte> Pcm,
int Channels,
int SampleRate,
int BitsPerSample);
@@ -0,0 +1,23 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Strategy for reducing a stream of PCM samples to a fixed-length, peak-normalized loudness
/// envelope. Swappable so the loudness measure (RMS today, LUFS later) can change without
/// touching <c>WaveformProfileService</c>, the stored wire format, or the frontend renderer.
/// </summary>
public interface ILoudnessAlgorithm
{
/// <summary>
/// Computes a peak-normalized loudness profile from raw interleaved PCM.
/// </summary>
/// <param name="pcmData">Interleaved, little-endian PCM sample bytes (the WAV data chunk).</param>
/// <param name="channels">Number of interleaved channels; averaged to mono per sample.</param>
/// <param name="sampleRate">Samples per second (unused by RMS but part of the contract for measures that need it).</param>
/// <param name="bitsPerSample">Bit depth (8 unsigned, 16/24/32 signed) used to decode samples.</param>
/// <param name="bucketCount">Number of equal time slices to reduce the signal to.</param>
/// <returns>
/// A <c>double[bucketCount]</c>, each value in [0, 1], peak-normalized so the loudest bucket
/// is 1. All zeros when the signal is silent (peak is 0) or no samples are present.
/// </returns>
double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount);
}
@@ -0,0 +1,129 @@
using System.Buffers.Binary;
using DeepDrftContent.FileDatabase.Models;
namespace DeepDrftContent.Processors;
/// <summary>
/// Processes raw image bytes into an <see cref="ImageBinary"/>, mirroring the shape of
/// <see cref="AudioProcessor"/>. Validates the content type resolves to a known image
/// extension, derives the aspect ratio from the image dimensions where cheaply parseable
/// (PNG, JPEG), and defaults to 1.0 for formats whose headers we don't parse.
/// </summary>
/// <remarks>
/// Operates entirely in memory — no disk I/O. Follows the FileDatabase error-handling
/// philosophy: dimension parsing logs a warning and falls back to a best-effort aspect
/// ratio of 1.0 rather than throwing. Content-type rejection is a caller-facing validation
/// failure (returns null), distinct from a parse hiccup.
/// </remarks>
public class ImageProcessor
{
/// <summary>
/// Builds an <see cref="ImageBinary"/> from raw image bytes and a MIME content type.
/// Returns null when the content type does not resolve to a recognised image extension
/// (the <c>.bin</c> sentinel from <see cref="MimeTypeExtensions.GetExtension"/>).
/// </summary>
public ImageBinary? Process(byte[] imageBytes, string contentType)
{
var extension = MimeTypeExtensions.GetExtension(contentType);
if (extension == ".bin")
{
Console.WriteLine($"Warning: ImageProcessor rejected unsupported content type '{contentType}'");
return null;
}
var aspectRatio = ComputeAspectRatio(imageBytes, extension);
var parameters = new ImageBinaryParams(
Buffer: imageBytes,
Size: imageBytes.Length,
Extension: extension,
AspectRatio: aspectRatio);
return new ImageBinary(parameters);
}
/// <summary>
/// Derives width/height from the format header and returns width/height. Defaults to 1.0
/// for unparsed formats (gif, webp, bmp, svg) and on any parse failure.
/// </summary>
private static double ComputeAspectRatio(byte[] bytes, string extension)
{
try
{
return extension switch
{
".png" => ParsePngAspectRatio(bytes),
".jpg" or ".jpeg" => ParseJpegAspectRatio(bytes),
_ => 1.0,
};
}
catch (Exception ex)
{
Console.WriteLine($"Warning: image dimension parsing failed for '{extension}', defaulting aspect ratio to 1.0: {ex.Message}");
return 1.0;
}
}
/// <summary>
/// PNG: the IHDR chunk places width at bytes 1619 and height at 2023, both big-endian
/// uint32. Guards on the "PNG" signature at bytes 13.
/// </summary>
private static double ParsePngAspectRatio(byte[] bytes)
{
if (bytes.Length < 24 || bytes[1] != 'P' || bytes[2] != 'N' || bytes[3] != 'G')
{
return 1.0;
}
var width = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(16, 4));
var height = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(20, 4));
return Ratio(width, height);
}
/// <summary>
/// JPEG: walk the marker segments from byte 2 looking for SOF0 (0xFF 0xC0) or SOF2
/// (0xFF 0xC2). Height is a big-endian uint16 at marker+5, width at marker+7. Guards on
/// the SOI marker (0xFF 0xD8) at bytes 01.
/// </summary>
private static double ParseJpegAspectRatio(byte[] bytes)
{
if (bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8)
{
return 1.0;
}
var pos = 2;
while (pos + 9 < bytes.Length)
{
// Marker segments begin with 0xFF; skip any fill bytes before the marker id.
if (bytes[pos] != 0xFF)
{
pos++;
continue;
}
var marker = bytes[pos + 1];
if (marker == 0xC0 || marker == 0xC2)
{
var height = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 5, 2));
var width = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 7, 2));
return Ratio(width, height);
}
// Standalone markers (RSTn, SOI, EOI, TEM) carry no length payload; everything
// else has a 2-byte big-endian segment length immediately after the marker id.
if (marker is 0xD8 or 0xD9 or 0x01 || (marker >= 0xD0 && marker <= 0xD7))
{
pos += 2;
continue;
}
var segmentLength = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 2, 2));
pos += 2 + segmentLength;
}
return 1.0;
}
private static double Ratio(uint width, uint height) => height == 0 ? 1.0 : (double)width / height;
}
@@ -0,0 +1,138 @@
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.
/// </summary>
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
{
public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount)
{
if (bucketCount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(bucketCount), "Bucket count must be positive.");
}
var result = new double[bucketCount];
if (channels <= 0)
{
return result;
}
var bytesPerSample = bitsPerSample / 8;
if (bytesPerSample <= 0)
{
return result;
}
var bytesPerFrame = bytesPerSample * channels;
var frameCount = pcmData.Length / bytesPerFrame;
if (frameCount == 0)
{
return result;
}
// Sum of squared mono amplitudes and the frame count, per bucket. A frame's bucket is
// determined by its position in the timeline so buckets are equal-duration slices.
var sumSquares = new double[bucketCount];
var counts = new long[bucketCount];
for (var frame = 0; frame < frameCount; frame++)
{
var frameStart = frame * bytesPerFrame;
double channelSum = 0;
for (var ch = 0; ch < channels; ch++)
{
var sampleStart = frameStart + ch * bytesPerSample;
channelSum += ReadSampleNormalized(pcmData, sampleStart, bitsPerSample);
}
var mono = channelSum / channels;
// long math avoids overflow on large files before the divide back into bucket index.
var bucket = (int)((long)frame * bucketCount / frameCount);
if (bucket >= bucketCount)
{
bucket = bucketCount - 1;
}
sumSquares[bucket] += mono * mono;
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];
}
}
}
if (peak <= 0)
{
// Silence — return all zeros (Array is already zero-initialized).
Array.Clear(result);
return result;
}
for (var i = 0; i < bucketCount; i++)
{
result[i] /= peak;
}
return result;
}
/// <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.
/// </summary>
private static double ReadSampleNormalized(ReadOnlySpan<byte> data, int offset, int bitsPerSample)
{
switch (bitsPerSample)
{
case 8:
// Unsigned, midpoint 128.
return (data[offset] - 128) / 128.0;
case 16:
{
short sample = (short)(data[offset] | (data[offset + 1] << 8));
return sample / 32768.0;
}
case 24:
{
// Sign-extend the 24-bit little-endian value into an int.
int raw = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16);
if ((raw & 0x800000) != 0)
{
raw |= unchecked((int)0xFF000000);
}
return raw / 8388608.0;
}
case 32:
{
int sample = data[offset]
| (data[offset + 1] << 8)
| (data[offset + 2] << 16)
| (data[offset + 3] << 24);
return sample / 2147483648.0;
}
default:
throw new ArgumentOutOfRangeException(
nameof(bitsPerSample), bitsPerSample, "Unsupported PCM bit depth.");
}
}
}
@@ -0,0 +1,11 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Configuration for waveform loudness profiling. <see cref="BucketCount"/> is the stored
/// resolution — the number of loudness buckets computed and persisted per track, which is also
/// the bar count the frontend WaveformSeeker renders.
/// </summary>
public class WaveformProfileOptions
{
public int BucketCount { get; set; } = 512;
}
@@ -0,0 +1,123 @@
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftContent.Processors;
/// <summary>
/// Computes a track's waveform loudness profile from its WAV bytes and persists it as a sidecar
/// in the <see cref="VaultConstants.WaveformProfiles"/> vault, keyed by the track's EntryKey.
/// The profile is the upload-time, off-the-playback-path representation the frontend fetches to
/// render the WaveformSeeker. The loudness measure is injected (<see cref="ILoudnessAlgorithm"/>)
/// so it can be swapped without changing storage or the wire format.
/// </summary>
public class WaveformProfileService
{
private const string ProfileExtension = ".wfp";
private readonly FileDb _fileDatabase;
private readonly AudioProcessor _audioProcessor;
private readonly ILoudnessAlgorithm _loudnessAlgorithm;
private readonly WaveformProfileOptions _options;
private readonly ILogger<WaveformProfileService> _logger;
public WaveformProfileService(
FileDb fileDatabase,
AudioProcessor audioProcessor,
ILoudnessAlgorithm loudnessAlgorithm,
IOptions<WaveformProfileOptions> options,
ILogger<WaveformProfileService> logger)
{
_fileDatabase = fileDatabase;
_audioProcessor = audioProcessor;
_loudnessAlgorithm = loudnessAlgorithm;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Computes the loudness profile from <paramref name="wavBytes"/> and stores it under
/// <paramref name="entryKey"/>. 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>
public async Task<bool> ComputeAndStoreAsync(ReadOnlyMemory<byte> wavBytes, string entryKey)
{
try
{
var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span);
if (pcm is null)
{
_logger.LogWarning(
"Waveform profile not computed for {EntryKey}: WAV PCM could not be extracted.",
entryKey);
return false;
}
var value = pcm.Value;
var profile = _loudnessAlgorithm.Compute(
value.Pcm.Span,
value.Channels,
value.SampleRate,
value.BitsPerSample,
_options.BucketCount);
var quantized = Quantize(profile);
await EnsureVaultAsync();
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
var stored = await _fileDatabase.RegisterResourceAsync(
VaultConstants.WaveformProfiles, entryKey, binary);
if (!stored)
{
_logger.LogWarning("Waveform profile vault write failed for {EntryKey}.", entryKey);
return false;
}
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Waveform profile computation failed for {EntryKey}.", entryKey);
return false;
}
}
/// <summary>
/// Returns the stored quantized profile bytes for a track, or null if no profile is stored
/// (existing tracks predate profiling, and computation may have failed). Each byte is a
/// peak-normalized loudness value in [0, 255].
/// </summary>
public async Task<byte[]?> GetProfileAsync(string entryKey)
{
var binary = await _fileDatabase.LoadResourceAsync<MediaBinary>(
VaultConstants.WaveformProfiles, entryKey);
return binary?.Buffer;
}
/// <summary>
/// Maps each [0, 1] bucket to a [0, 255] byte. 1.0 maps to 255; the multiply-by-255 with a
/// truncating cast keeps every in-range value within a byte without a clamp branch.
/// </summary>
private static byte[] Quantize(double[] profile)
{
var bytes = new byte[profile.Length];
for (var i = 0; i < profile.Length; i++)
{
bytes[i] = (byte)(profile[i] * 255);
}
return bytes;
}
private async Task EnsureVaultAsync()
{
if (!_fileDatabase.HasVault(VaultConstants.WaveformProfiles))
{
await _fileDatabase.CreateVaultAsync(VaultConstants.WaveformProfiles, MediaVaultType.Media);
}
}
}
+5 -2
View File
@@ -29,6 +29,7 @@ public class TrackContentService
/// <param name="album">Optional album name</param>
/// <param name="genre">Optional genre</param>
/// <param name="releaseDate">Optional release date</param>
/// <param name="originalFileName">Optional original browser filename captured at upload time</param>
/// <returns>The track entity with generated ID and media path</returns>
public async Task<TrackEntity?> AddTrackFromWavAsync(
string wavFilePath,
@@ -36,7 +37,8 @@ public class TrackContentService
string artist,
string? album = null,
string? genre = null,
DateOnly? releaseDate = null)
DateOnly? releaseDate = null,
string? originalFileName = null)
{
try
{
@@ -71,7 +73,8 @@ public class TrackContentService
Artist = artist,
Album = album,
Genre = genre,
ReleaseDate = releaseDate
ReleaseDate = releaseDate,
OriginalFileName = originalFileName
};
return trackEntity;
@@ -53,6 +53,10 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
builder.Property(e => e.CreatedByUserId)
.HasColumnName("created_by_user_id");
builder.Property(e => e.OriginalFileName)
.HasMaxLength(500)
.HasColumnName("original_file_name");
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
// "IX_track_is_deleted" regardless of auto-naming conventions.
+7
View File
@@ -12,6 +12,13 @@ namespace DeepDrftData;
public interface ITrackService
{
Task<ResultContainer<TrackDto?>> GetById(long id);
Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey);
/// <summary>
/// Returns a single track chosen uniformly at random, or null when the library is empty
/// (a valid state, not a failure). Backs the public "Stream Now" instant-play feature.
/// </summary>
Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default);
Task<ResultContainer<List<TrackDto>>> GetAll();
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
@@ -0,0 +1,107 @@
// <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("20260607124422_AddOriginalFileName")]
partial class AddOriginalFileName
{
/// <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.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Album")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("album");
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>("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>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.ToTable("track", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddOriginalFileName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "original_file_name",
table: "track",
type: "character varying(500)",
maxLength: 500,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "original_file_name",
table: "track");
}
}
}
@@ -72,6 +72,11 @@ namespace DeepDrftData.Migrations
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
@@ -2,18 +2,46 @@ using Data.Data.Repositories;
using Data.Errors;
using DeepDrftData.Data;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace DeepDrftData.Repositories;
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
{
private readonly DeepDrftContext _context;
public TrackRepository(
DeepDrftContext context,
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
IDbExceptionClassifier? classifier = null)
: base(context, logger, classifier: classifier)
{
_context = context;
}
// Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
// queries the DbSet directly. Returns null on miss (service wraps in ResultContainer).
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
=> await _context.Tracks.FirstOrDefaultAsync(t => t.EntryKey == entryKey);
// Picks one track uniformly at random. Two round-trips (count, then a single offset row)
// rather than ORDER BY random() so the database never sorts the whole table — the catalogue
// is small today but this keeps the cost flat as it grows. Returns null when empty so the
// service surfaces a valid empty-library state, not an error. Queries the DbSet directly,
// mirroring GetByEntryKeyAsync, since the base Repository<> exposes only id-based reads.
public async Task<TrackEntity?> GetRandomAsync(CancellationToken cancellationToken = default)
{
var count = await _context.Tracks.CountAsync(cancellationToken);
if (count == 0)
return null;
var index = Random.Shared.Next(count);
return await _context.Tracks
.OrderBy(t => t.Id)
.Skip(index)
.Take(1)
.FirstOrDefaultAsync(cancellationToken);
}
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
+4 -2
View File
@@ -24,7 +24,8 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Genre = entity.Genre,
ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath,
CreatedByUserId = entity.CreatedByUserId
CreatedByUserId = entity.CreatedByUserId,
OriginalFileName = entity.OriginalFileName
};
public static TrackEntity Convert(TrackDto model) => new()
@@ -39,6 +40,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Genre = model.Genre,
ReleaseDate = model.ReleaseDate,
ImagePath = model.ImagePath,
CreatedByUserId = model.CreatedByUserId
CreatedByUserId = model.CreatedByUserId,
OriginalFileName = model.OriginalFileName
};
}
+32
View File
@@ -46,6 +46,38 @@ public class TrackManager
}
}
// Lookup by vault entry key. No base-name conflict (unlike GetById), so this is a plain
// public method. Mirrors the nullable-on-miss shape of ITrackService.GetById.
public async Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey)
{
try
{
var entity = await Repository.GetByEntryKeyAsync(entryKey);
return ResultContainer<TrackDto?>.CreatePassResult(
entity is null ? null : TrackConverter.Convert(entity));
}
catch (Exception e)
{
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
}
}
// No base-name conflict, so this is a plain public method. Mirrors the nullable-on-empty
// shape of GetById: pass with null when the library has no tracks.
public async Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default)
{
try
{
var entity = await Repository.GetRandomAsync(cancellationToken);
return ResultContainer<TrackDto?>.CreatePassResult(
entity is null ? null : TrackConverter.Convert(entity));
}
catch (Exception e)
{
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<TrackDto>>> GetAll()
{
try
+1
View File
@@ -9,6 +9,7 @@
<DeepDrftFontLinks />
<link href=@Assets["_content/MudBlazor/MudBlazor.min.css"] rel="stylesheet" />
<link rel="stylesheet" href="@Assets["DeepDrftManager.styles.css"]" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/styles/deepdrft-tokens.css"]" />
<ImportMap />
<link rel="icon" type="image/ico" href="deepdrft-logo.ico" />
+7 -5
View File
@@ -1,11 +1,13 @@
@page "/"
@attribute [Authorize]
@layout Layout.CmsLayout
@inject NavigationManager Nav
<PageTitle>DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h3" GutterBottom="true">DeepDrft CMS</MudText>
<MudText Typo="Typo.body1">Administration panel — under construction.</MudText>
</MudContainer>
@code {
protected override void OnInitialized()
{
Nav.NavigateTo("/tracks");
}
}
@@ -1,7 +1,9 @@
@page "/tracks/{Id:long}"
@using DeepDrftManager.Services
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject IHttpClientFactory HttpClientFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Nav
@@ -60,6 +62,48 @@
Label="Genre"
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"
@@ -94,11 +138,25 @@
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);
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser can hit
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses for writes.
private string? ImagePreviewUrl
{
get
{
if (string.IsNullOrEmpty(_form.ImagePath)) return null;
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
if (baseAddress is null) return null;
return new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(_form.ImagePath)}").ToString();
}
}
protected override async Task OnInitializedAsync()
{
await LoadAsync();
@@ -123,14 +181,33 @@
_busy = true;
try
{
// Metadata-only update over HTTP — EntryKey is immutable and not sent. The Content
// API loads the authoritative row and applies these fields.
// 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);
releaseDate,
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath);
if (updated.Success)
{
Snackbar.Add("Track updated.", Severity.Success);
@@ -153,6 +230,17 @@
}
}
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;
@@ -197,6 +285,7 @@
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 static TrackEditForm From(TrackDto track) => new()
@@ -205,6 +294,7 @@
Artist = track.Artist,
Album = track.Album,
Genre = track.Genre,
ImagePath = track.ImagePath,
ReleaseDate = track.ReleaseDate is { } d
? d.ToDateTime(TimeOnly.MinValue)
: null
@@ -1,6 +1,7 @@
@page "/tracks"
@using System.Net
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject IDialogService DialogService
@@ -10,72 +11,184 @@
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
<MudText Typo="Typo.h3">Tracks</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/tracks/new">
Add Track
</MudButton>
</MudStack>
<MudText Typo="Typo.h3" Class="mb-4">Tracks</MudText>
<MudTable T="TrackDto"
@ref="_table"
ServerData="LoadServerData"
Hover="true"
Striped="true"
Dense="true"
Bordered="false"
FixedHeader="true"
RowsPerPage="20"
AllowUnsorted="false">
<NoRecordsContent>
<MudText Typo="Typo.body1">No tracks found.</MudText>
</NoRecordsContent>
<LoadingContent>
<MudText Typo="Typo.body1">Loading tracks…</MudText>
</LoadingContent>
<HeaderContent>
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
<MudTh>Entry Key</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
<MudTd DataLabel="Artist">@context.Artist</MudTd>
<MudTd DataLabel="Album">@(context.Album ?? "—")</MudTd>
<MudTd DataLabel="Genre">@(context.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
<MudTd DataLabel="Actions">
<MudTooltip Text="Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
Href="@($"/tracks/{context.Id}")" />
</MudTooltip>
<MudTooltip Text="Delete">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => ConfirmAndDelete(context))" />
</MudTooltip>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
</PagerContent>
</MudTable>
<MudTabs Elevation="0" Rounded="false" ApplyEffectsToContainer="true" PanelClass="pt-4">
<MudTabPanel Text="Tracks" Icon="@Icons.Material.Filled.LibraryMusic">
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/tracks/new">
Add Track
</MudButton>
</MudStack>
<MudTable T="TrackDto"
@ref="_table"
ServerData="LoadServerData"
Hover="true"
Striped="true"
Dense="true"
Bordered="false"
FixedHeader="true"
RowsPerPage="20"
AllowUnsorted="false">
<NoRecordsContent>
<MudText Typo="Typo.body1">No tracks found.</MudText>
</NoRecordsContent>
<LoadingContent>
<MudText Typo="Typo.body1">Loading tracks…</MudText>
</LoadingContent>
<HeaderContent>
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
<MudTh>Entry Key</MudTh>
<MudTh>File Name</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
<MudTd DataLabel="Artist">@context.Artist</MudTd>
<MudTd DataLabel="Album">@(context.Album ?? "—")</MudTd>
<MudTd DataLabel="Genre">@(context.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
<MudTd DataLabel="File Name"><MudText Typo="Typo.caption" Style="font-family: monospace;">@(context.OriginalFileName ?? "—")</MudText></MudTd>
<MudTd DataLabel="Actions">
<MudTooltip Text="Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
Href="@($"/tracks/{context.Id}")" />
</MudTooltip>
<MudTooltip Text="Delete">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => ConfirmAndDelete(context))" />
</MudTooltip>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
</PagerContent>
</MudTable>
</MudTabPanel>
<MudTabPanel Text="Waveform Pre-Processing" Icon="@Icons.Material.Filled.GraphicEq">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
<MudStack Spacing="0">
<MudText Typo="Typo.h5">Waveform Pre-Processing</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">
Generate loudness profiles for tracks that predate the waveform seeker.
</MudText>
</MudStack>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || _missingCount == 0)"
OnClick="GenerateAllMissing">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Missing (@_missingCount)</span>
}
</MudButton>
</MudStack>
<MudTable T="WaveformStatusDto"
Items="_waveformRows"
Loading="_waveformLoading"
Hover="true"
Striped="true"
Dense="true"
Bordered="false"
FixedHeader="true">
<NoRecordsContent>
<MudText Typo="Typo.body1">No tracks found.</MudText>
</NoRecordsContent>
<LoadingContent>
<MudText Typo="Typo.body1">Loading waveform status…</MudText>
</LoadingContent>
<HeaderContent>
<MudTh>Track Name</MudTh>
<MudTh>Entry Key</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
<MudTd DataLabel="Entry Key">
<MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText>
</MudTd>
<MudTd DataLabel="Profile">
@if (context.HasProfile)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text"
Icon="@Icons.Material.Filled.CheckCircle">Stored</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Text"
Icon="@Icons.Material.Filled.Cancel">Missing</MudChip>
}
</MudTd>
<MudTd DataLabel="Actions">
@if (!context.HasProfile)
{
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.GraphicEq"
Disabled="@(_bulkRunning || IsGenerating(context.EntryKey))"
OnClick="@(() => GenerateOne(context))">
@if (IsGenerating(context.EntryKey))
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating…</span>
}
else
{
<span>Generate</span>
}
</MudButton>
}
</MudTd>
</RowTemplate>
</MudTable>
</MudTabPanel>
</MudTabs>
</MudContainer>
@code {
// Track list fields
private MudTable<TrackDto>? _table;
// Waveform fields
private List<WaveformStatusDto> _waveformRows = new();
private readonly HashSet<string> _generating = new();
private bool _waveformLoading = true;
private bool _bulkRunning;
private int _bulkTotal;
private int _bulkDone;
private int _missingCount => _waveformRows.Count(r => !r.HasProfile);
protected override async Task OnInitializedAsync()
{
await LoadWaveformStatus();
}
// ── Track list methods ──────────────────────────────────────────────────
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
{
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
@@ -129,4 +242,114 @@
Snackbar.Add("Delete failed — please try again.", Severity.Error);
}
}
// ── Waveform pre-processing methods ────────────────────────────────────
private async Task LoadWaveformStatus()
{
_waveformLoading = true;
var result = await CmsTrackService.GetWaveformStatusAsync();
_waveformLoading = false;
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
_waveformRows = new List<WaveformStatusDto>();
return;
}
_waveformRows = result.Value.OrderBy(r => r.HasProfile).ThenBy(r => r.TrackName).ToList();
}
private bool IsGenerating(string entryKey) => _generating.Contains(entryKey);
private async Task GenerateOne(WaveformStatusDto row)
{
if (!await GenerateForRow(row))
{
return;
}
Snackbar.Add($"Generated profile for '{row.TrackName}'.", Severity.Success);
}
private async Task GenerateAllMissing()
{
var missing = _waveformRows.Where(r => !r.HasProfile).ToList();
if (missing.Count == 0)
{
return;
}
_bulkRunning = true;
_bulkTotal = missing.Count;
_bulkDone = 0;
var failures = 0;
// Sequential by design: one request at a time so a large backfill does not flood the API
// with concurrent WAV decodes.
foreach (var row in missing)
{
if (!await GenerateForRow(row))
{
failures++;
}
_bulkDone++;
StateHasChanged();
}
_bulkRunning = false;
var succeeded = missing.Count - failures;
if (failures == 0)
{
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
}
else
{
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
}
}
/// <summary>
/// Runs generation for a single row, flipping its status on success. Returns false on failure
/// (a snackbar is raised here for the per-row path; the bulk path aggregates a summary). Marks
/// the row busy for the duration so its button shows a spinner and stays disabled.
/// </summary>
private async Task<bool> GenerateForRow(WaveformStatusDto row)
{
_generating.Add(row.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(row.EntryKey);
if (result.Success)
{
row.HasProfile = true;
return true;
}
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
if (!_bulkRunning)
{
Snackbar.Add($"Generate failed for '{row.TrackName}': {error}", Severity.Error);
}
return false;
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", row.EntryKey);
if (!_bulkRunning)
{
Snackbar.Add($"Generate failed for '{row.TrackName}' — please try again.", Severity.Error);
}
return false;
}
finally
{
_generating.Remove(row.EntryKey);
StateHasChanged();
}
}
}
@@ -144,6 +144,7 @@
string.IsNullOrWhiteSpace(_album) ? null : _album,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
_selectedFile.Name,
createdByUserId);
if (result.Success)
-1
View File
@@ -17,4 +17,3 @@
</ItemGroup>
</Project>
+159
View File
@@ -39,6 +39,7 @@ public class CmsTrackService : ICmsTrackService
string? album,
string? genre,
string? releaseDate,
string? originalFileName,
long createdByUserId,
CancellationToken ct = default)
{
@@ -54,6 +55,8 @@ public class CmsTrackService : ICmsTrackService
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
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");
multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId");
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -238,9 +241,87 @@ public class CmsTrackService : ICmsTrackService
}
}
private static readonly HashSet<string> KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp"
};
public async Task<ResultContainer<string>> UploadImageAsync(
Stream imageStream,
string fileName,
string contentType,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(contentType) || !KnownImageMimeTypes.Contains(contentType))
{
_logger.LogWarning("UploadImageAsync rejected: unsupported or missing content type '{ContentType}'", contentType);
return ResultContainer<string>.CreateFailResult($"Unsupported image type: {contentType}. Accepted: JPEG, PNG, GIF, WebP, SVG, BMP.");
}
using var multipart = new MultipartFormDataContent();
var imageContent = new StreamContent(imageStream);
imageContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
multipart.Add(imageContent, "image", fileName);
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, "api/image/upload") { Content = multipart };
HttpResponseMessage response;
try
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for image upload of {FileName}", fileName);
return ResultContainer<string>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
var statusCode = (int)response.StatusCode;
if (statusCode >= 500)
{
_logger.LogError("Content API returned {Status} for image upload of {FileName}: {Body}", statusCode, fileName, body);
return ResultContainer<string>.CreateFailResult("Image upload failed on the content server.");
}
// 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is.
_logger.LogWarning("Content API rejected image upload: {Status} {Body}", statusCode, body);
return ResultContainer<string>.CreateFailResult(
string.IsNullOrWhiteSpace(body) ? $"Image upload rejected ({statusCode})." : body);
}
ImageUploadResponse? payload;
try
{
payload = await response.Content.ReadFromJsonAsync<ImageUploadResponse>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize image upload response from Content API");
return ResultContainer<string>.CreateFailResult("Content API returned an unexpected response.");
}
if (payload is null || string.IsNullOrWhiteSpace(payload.EntryKey))
{
_logger.LogError("Content API returned an empty entry key for image upload");
return ResultContainer<string>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<string>.CreatePassResult(payload.EntryKey);
}
}
private sealed record ImageUploadResponse(string EntryKey);
public async Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -251,6 +332,7 @@ public class CmsTrackService : ICmsTrackService
album,
genre,
releaseDate,
imagePath,
};
HttpResponseMessage response;
@@ -281,4 +363,81 @@ public class CmsTrackService : ICmsTrackService
return Result.CreateFailResult("Failed to update track.");
}
}
public async Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.GetAsync("api/track/waveform-status", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for waveform status");
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API waveform status failed: {Status}", (int)response.StatusCode);
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Failed to load waveform status.");
}
WaveformStatusDto[]? status;
try
{
status = await response.Content.ReadFromJsonAsync<WaveformStatusDto[]>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize waveform status from Content API response");
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an unexpected response.");
}
if (status is null)
{
_logger.LogError("Content API returned a null waveform status list");
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<WaveformStatusDto[]>.CreatePassResult(status);
}
}
public async Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform", null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for waveform generation of {EntryKey}", entryKey);
return Result.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return Result.CreatePassResult();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result.CreateFailResult("Track audio not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Content API waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to generate waveform profile.");
}
}
}
+28 -1
View File
@@ -15,6 +15,8 @@ public interface ICmsTrackService
/// Proxy a WAV upload to DeepDrftAPI. The Content API owns the dual-database write and
/// returns the persisted track carrying the SQL-assigned <c>Id</c>. A vault-without-SQL
/// orphan is handled and logged server-side; here it surfaces as a failed result.
/// <paramref name="originalFileName"/> is the browser's filename, captured at upload time and
/// stored as metadata; it is not user-editable afterwards.
/// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream,
@@ -25,6 +27,7 @@ public interface ICmsTrackService
string? album,
string? genre,
string? releaseDate,
string? originalFileName,
long createdByUserId,
CancellationToken ct = default);
@@ -47,12 +50,36 @@ public interface ICmsTrackService
/// </summary>
Task<ResultContainer<TrackDto?>> GetByIdAsync(long id, CancellationToken ct = default);
/// <summary>
/// Upload a cover-art image to the images vault via <c>POST api/image/upload</c>.
/// Returns the generated entry key on success. Maps a 400 to a validation failure message.
/// </summary>
Task<ResultContainer<string>> UploadImageAsync(
Stream imageStream,
string fileName,
string contentType,
CancellationToken ct = default);
/// <summary>
/// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and
/// not part of the update.
/// not part of the update. <paramref name="imagePath"/> is tri-state: <c>null</c> leaves the
/// cover art unchanged, <c>""</c> clears it, and any other value sets it.
/// </summary>
Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
CancellationToken ct = default);
/// <summary>
/// Fetch per-track waveform profile status from <c>GET api/track/waveform-status</c> for the
/// CMS PreProcessing panel. Unpaged — the admin catalogue is small.
/// </summary>
Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default);
/// <summary>
/// Trigger waveform profile generation for a single track via
/// <c>POST api/track/{entryKey}/waveform</c>. Maps a 404 to a "Track audio not found." failure.
/// </summary>
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
}
+2
View File
@@ -0,0 +1,2 @@
/* Suppress the browser focus ring that FocusOnNavigate triggers on h1 after navigation. */
h1:focus-visible { outline: none; }
+1
View File
@@ -18,4 +18,5 @@ public class TrackDto : BaseModel
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public long? CreatedByUserId { get; set; }
public string? OriginalFileName { get; set; }
}
+14
View File
@@ -0,0 +1,14 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Wire contract for a stored waveform loudness profile. <see cref="Data"/> is the base64
/// encoding of a <c>byte[BucketCount]</c>, each byte a peak-normalized loudness value in
/// [0, 255] (the quantized form of a [0, 1] float). The frontend renders these as bar heights
/// in the WaveformSeeker. A track with no stored profile yields no DTO (the frontend falls
/// back to a flat seekbar), so this type never represents "absent" — only a present profile.
/// </summary>
public class WaveformProfileDto
{
public int BucketCount { get; set; }
public string Data { get; set; } = string.Empty;
}
+15
View File
@@ -0,0 +1,15 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Per-track waveform profile status for the CMS PreProcessing panel. Tells admins which tracks
/// already carry a stored loudness profile and which predate the WaveformSeeker feature and need
/// backfilling. <see cref="HasProfile"/> is the existence check; <see cref="EntryKey"/> is the
/// vault key used to trigger generation for a missing profile.
/// </summary>
public class WaveformStatusDto
{
public long TrackId { get; set; }
public string EntryKey { get; set; } = string.Empty;
public string TrackName { get; set; } = string.Empty;
public bool HasProfile { get; set; }
}
+1
View File
@@ -15,4 +15,5 @@ public class TrackEntity : BaseEntity, IEntity
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public long? CreatedByUserId { get; set; }
public string? OriginalFileName { get; set; }
}
+11 -5
View File
@@ -10,15 +10,20 @@ 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). **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). **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).
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints).
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
- `AppNavLink.razor`: Nav link with active-page highlight.
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`.
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. 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.
- `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.
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
@@ -30,6 +35,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, offset)``Stream`.
- `ViewModels/`: Component state.
- `TracksViewModel`: Scoped. Holds current page, page size, sort column, descending flag. `SetPage(pageNumber)` calls `TrackClient.GetPageAsync` and updates. Registered in `Startup.ConfigureDomainServices`.
- `TrackDetailViewModel`: Scoped. Holds loaded track, loading flag, not-found flag. `Load(entryKey)` fetches via `ITrackDataService` and resets all flags per call (prevents cross-navigation bleed). Registered in `Startup.ConfigureDomainServices`.
- `Common/`: Shared utilities.
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
- `DDIcons.cs`: Hand-rolled SVG icons (gas-lamp lit/unlit for dark mode toggle).
@@ -68,9 +74,9 @@ Both are configured with JSON serializer settings (case-insensitive property mat
### Component integration
- `AudioPlayerProvider.razor` is the cascading host. It injects `IStreamingPlayerService` (resolved to `StreamingAudioPlayerService` in DI), stores it in a cascade with `IsFixed="true"`, and keeps it alive across navigation.
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Minimize-state mutations (`Expand`, `ToggleMinimized`, `Close`) all route through a private `SetMinimized(bool value)` mutator, which guards no-ops, fires the `OnMinimized` callback, and calls `StateHasChanged()`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
- `SpectrumVisualizer.razor` calls `AudioInteropService.GetSpectrumData()` on a timer, receives bar heights, renders via MudBlazor `MudChart` or custom canvas.
- `TracksView.razor` injects `TracksViewModel` + cascaded `IStreamingPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrackStreaming(track)`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` and clears `_selectedTrack` when `!PlayerService.IsLoaded` (covers Stop, Unload, and end-of-track).
- `TracksView.razor` injects `TracksViewModel` + cascaded `IStreamingPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrackStreaming(track)`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` and calls `StateHasChanged()` unconditionally on any state change, ensuring the gallery correctly reflects play/pause/track-change transitions. Active-track state is derived from `PlayerService.CurrentTrack` and `PlayerService.IsPlaying` (no local `_selectedTrack` field).
## Dark-mode plumbing
@@ -50,4 +50,48 @@ public class TrackClient
? ApiResult<PagedResult<TrackDto>>.CreatePassResult(paged)
: ApiResult<PagedResult<TrackDto>>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches a random track from the public library. A 404 means the library is empty — a valid
/// state, not an error — so it returns a pass result with a null value. Any other non-success
/// status is a genuine failure.
/// </summary>
public async Task<ApiResult<TrackDto?>> GetRandom()
{
var response = await _http.GetAsync("api/track/random");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<TrackDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<TrackDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var track = JsonSerializer.Deserialize<TrackDto>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return track is not null
? ApiResult<TrackDto?>.CreatePassResult(track)
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
{
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
if (!response.IsSuccessStatusCode)
return ApiResult<TrackDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var track = JsonSerializer.Deserialize<TrackDto>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return track is not null
? ApiResult<TrackDto>.CreatePassResult(track)
: ApiResult<TrackDto>.CreateFailResult("Failed to deserialize response");
}
}
@@ -1,3 +1,6 @@
using System.Net;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using Microsoft.Extensions.DependencyInjection;
using NetBlocks.Models;
@@ -61,4 +64,36 @@ public class TrackMediaClient
return ApiResult<TrackMediaResponse>.CreateFailResult(e.Message);
}
}
/// <summary>
/// Fetches a track's stored waveform loudness profile. A 404 means no profile is stored
/// (existing tracks predate profiling, or computation failed at upload); callers treat that
/// as "render a flat seekbar" rather than an error, so it surfaces as a fail result with a
/// stable message rather than throwing.
/// </summary>
public async Task<ApiResult<WaveformProfileDto>> GetWaveformProfileAsync(string trackId, CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync($"api/track/{trackId}/waveform", cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ApiResult<WaveformProfileDto>.CreateFailResult("No waveform profile available");
}
response.EnsureSuccessStatusCode();
var profile = await response.Content.ReadFromJsonAsync<WaveformProfileDto>();
if (profile is null)
{
return ApiResult<WaveformProfileDto>.CreateFailResult("Waveform profile response was empty");
}
return ApiResult<WaveformProfileDto>.CreatePassResult(profile);
}
catch (Exception e)
{
return ApiResult<WaveformProfileDto>.CreateFailResult(e.Message);
}
}
}
@@ -1,117 +1,46 @@
@if (_isMinimized)
{
<div class="minimized-dock d-flex align-center justify-center"
@onclick="@ToggleMinimized">
<MudIconButton Icon="@Icons.Material.Filled.ExpandLess"
Color="Color.Primary"
Size="Size.Large"
Class="minimized-button"
OnClick="@ToggleMinimized"/>
<div class="minimized-dock">
<LevelMeterFab OnClick="@ToggleMinimized" />
</div>
}
else
else
{
<div class="player-outer-container d-flex flex-column">
<div class="@PlayerModeClass d-flex flex-column" @ref="_playerRoot">
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
<div class="player-backdrop pa-3">
@if (_isDesktop)
{
@* Desktop Layout *@
<div class="d-flex align-center gap-3">
<div class="controls-left d-flex flex-column align-center gap-2">
<div class="d-flex align-center gap-1">
<PlayerControls IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
</div>
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
</div>
<MudPaper Elevation="8" Class="player-surface pa-3">
<div class="d-flex flex-column flex-grow-1">
<div class="seekbar-flex mx-3"
@onpointerdown="OnSeekStart"
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
@onpointerleave="@(async () => { if (_isSeeking) await OnSeekEnd(_seekPosition); })">
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@DisplayTime"
ValueChanged="@OnSeekChange"
Immediate="true"
Disabled="@(!CanSeek)"/>
</div>
<SpectrumVisualizer />
</div>
<div class="player-layout">
<PlayerTransportZone IsLoaded="IsLoaded"
CanPlay="CanPlay"
IsLoading="IsLoading"
IsStreaming="IsStreaming"
LoadProgress="LoadProgress"
DisplayTime="DisplayTime"
Duration="Duration"
Fixed="Fixed"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"
Class="transport-zone"/>
<div class="volume-right">
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
</div>
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
<div class="meta-zone">
<TrackMetaLabel Track="CurrentTrack"/>
</div>
}
else
{
@* Mobile Layout *@
<div>
<div class="d-flex align-center justify-space-between mb-3">
<div class="d-flex align-center gap-2">
<PlayerControls IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
</div>
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
</div>
<div class="d-flex flex-column flex-grow-1">
<div @onpointerdown="OnSeekStart"
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
@onpointerleave="@(async () => { if (_isSeeking) await OnSeekEnd(_seekPosition); })">
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@DisplayTime"
ValueChanged="@OnSeekChange"
Immediate="true"
Disabled="@(!CanSeek)"/>
</div>
<SpectrumVisualizer />
</div>
</div>
}
@* Control Buttons - positioned absolutely like original *@
<div class="player-controls d-flex align-center justify-center gap-1">
<MudIconButton Icon="@Icons.Material.Filled.Minimize"
Color="Color.Secondary"
Size="Size.Small"
OnClick="@ToggleMinimized"/>
<MudIconButton Icon="@Icons.Material.Filled.Close"
Color="Color.Secondary"
Size="Size.Small"
OnClick="@Close"/>
<PlayerSeekZone OnSeekStart="@OnSeekStart"
OnSeekEnd="@OnSeekEnd"
OnSeekChange="@OnSeekChange"
Class="seek-zone"/>
</div>
</div>
@* Minimize / close — positioned absolutely top-right *@
@if (!Fixed)
{
<PlayerWindowControls OnMinimize="@ToggleMinimized" OnClose="@Close"/>
}
</MudPaper>
</MudContainer>
@if (!string.IsNullOrEmpty(ErrorMessage))
@@ -124,7 +53,4 @@ else
</MudAlert>
}
</div>
@* Spacer to prevent content overlap *@
<div class="player-spacer"></div>
}
}
@@ -1,29 +1,49 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
using MudBlazor.Services;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Inject] public required IBrowserViewportService BrowserViewportService { get; set; }
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public EventCallback<bool> OnMinimized { get; set; }
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
private bool _isMinimized = true;
private bool _isSeeking = false;
private double _seekPosition = 0;
private bool _isDesktop = true;
private Guid _viewportSubscriptionId;
private IStreamingPlayerService? _subscribedService;
// 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.
private ElementReference _playerRoot;
private IJSObjectReference? _spacerModule;
private bool _spacerObserved;
private bool IsLoaded => PlayerService?.IsLoaded ?? false;
private bool IsLoading => PlayerService?.IsLoading ?? false;
/// <summary>
/// A track is staged when it has been selected as the current track but not yet loaded into
/// the audio context (the embed's pre-gesture state). The first play click loads + plays it.
/// </summary>
private bool IsStaged => PlayerService is { IsLoaded: false, IsLoading: false, CurrentTrack: not null };
/// <summary>Play is available once a track is loaded, or staged and waiting for the first gesture.</summary>
private bool CanPlay => IsLoaded || IsStaged;
private bool IsStreaming => PlayerService?.CanStartStreaming ?? false;
private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false;
private bool IsPlaying => PlayerService?.IsPlaying ?? false;
private bool IsPaused => PlayerService?.IsPaused ?? false;
private double? Duration => PlayerService?.Duration;
private TrackDto? CurrentTrack => PlayerService?.CurrentTrack;
private double Volume => PlayerService?.Volume ?? 0;
private double LoadProgress => PlayerService?.LoadProgress ?? 0;
private string? ErrorMessage => PlayerService?.ErrorMessage;
@@ -32,15 +52,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
/// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary>
private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0);
/// <summary>
/// Seek is enabled once track is loaded AND duration is known (from WAV header).
/// This allows seeking even during streaming, including seeking beyond buffered content.
/// </summary>
private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0;
private string PlayerModeClass => Fixed ? "player-fixed" : "player-dock";
protected override void OnParametersSet()
{
if (Fixed)
{
_isMinimized = false;
}
// PlayerService is cascaded by AudioPlayerProvider; once it arrives,
// wire our track-selection handler. The provider owns OnStateChanged —
// we intentionally do NOT wrap or replace it. Because the cascade is
@@ -60,18 +80,71 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private async Task Expand()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_isMinimized)
// 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;
var module = await GetSpacerModuleAsync();
if (module is null) return;
if (shouldObserve)
await module.InvokeVoidAsync("observe", _playerRoot);
else
await module.InvokeVoidAsync("unobserve");
_spacerObserved = shouldObserve;
}
private async Task<IJSObjectReference?> GetSpacerModuleAsync()
{
try
{
_isMinimized = false;
StateHasChanged();
return _spacerModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/layout/spacer.js");
}
catch (JSException)
{
// Module failed to load — the spacer falls back to 0px (no overlap
// guard, but the player still works). Nothing actionable here.
return null;
}
}
private async Task Expand() => await SetMinimized(false);
/// <summary>
/// The single assignment site for <see cref="_isMinimized"/>. Guards no-op transitions,
/// fires <see cref="OnMinimized"/> so MainLayout's spacer class stays in sync, and renders
/// so OnAfterRenderAsync re-evaluates the ResizeObserver on every transition path.
/// The <c>Fixed</c> branch in OnParametersSet intentionally bypasses this — it is a
/// prerender/parameter pass, not a user-driven transition, and the embed host has no spacer.
/// </summary>
private async Task SetMinimized(bool value)
{
if (_isMinimized == value) return;
_isMinimized = value;
if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(value);
StateHasChanged();
}
private async Task TogglePlayPause()
{
if (PlayerService == null) return;
// Gesture-gated start: a staged-but-unloaded track (the embed autoplay path) is loaded on
// the first play click — the user gesture the browser requires before audio can start.
if (IsStaged)
{
await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!);
return;
}
await PlayerService.TogglePlayPause();
}
@@ -112,11 +185,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
PlayerService?.ClearError();
}
private void ToggleMinimized()
{
_isMinimized = !_isMinimized;
StateHasChanged();
}
private async Task ToggleMinimized() => await SetMinimized(!_isMinimized);
private async Task Close()
{
@@ -125,35 +194,9 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
await PlayerService.Unload();
}
if (!_isMinimized)
{
_isMinimized = true;
StateHasChanged();
}
await SetMinimized(true);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var breakpoint = await BrowserViewportService.GetCurrentBreakpointAsync();
_isDesktop = breakpoint >= Breakpoint.Sm;
_viewportSubscriptionId = Guid.NewGuid();
await BrowserViewportService.SubscribeAsync(
_viewportSubscriptionId,
args =>
{
_isDesktop = args.Breakpoint >= Breakpoint.Sm;
InvokeAsync(StateHasChanged);
},
new ResizeOptions { NotifyOnBreakpointOnly = true },
fireImmediately: true);
StateHasChanged();
}
}
public async ValueTask DisposeAsync()
{
if (_subscribedService != null)
@@ -161,6 +204,20 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
await BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId);
if (_spacerModule is not null)
{
try
{
// Clear the var so a torn-down player can't strand a spacer height.
await _spacerModule.InvokeVoidAsync("unobserve");
await _spacerModule.DisposeAsync();
}
catch (JSException)
{
// Runtime already gone (navigation/teardown) — nothing to clean up.
}
_spacerModule = null;
}
}
}
@@ -1,7 +1,8 @@
/* Preserve key visual styles while simplifying layout */
/* Geometry, positioning, and animation only.
Colour, surface, and elevation come from MudBlazor theme props. */
/* Player outer container - fixed positioning */
.player-outer-container {
/* Fixed dock to the viewport bottom */
.player-dock {
position: fixed;
bottom: 0;
left: 0;
@@ -11,166 +12,118 @@
margin: 0;
}
/* Player inner container */
.player-inner-container {
padding: 1rem;
padding-bottom: 1.5rem;
.player-fixed {
position: relative;
top: 0;
left: 0;
right: 0;
z-index: 1200;
padding: 0;
margin: 0;
}
/* Custom backdrop blur container */
.player-backdrop {
::deep .player-inner-container {
padding: 0.75rem;
}
/* The visible surface is a MudPaper; scoped CSS only sets geometry + a hairline accent */
::deep .player-surface {
position: relative;
background: var(--deepdrft-theme-background-gray);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-radius: 1rem;
border: 2px solid var(--deepdrft-theme-primary);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
color: inherit;
transition: all 0.3s ease;
border-radius: 16px;
border: 1px solid var(--mud-palette-primary);
overflow: hidden;
margin-bottom: 1rem;
}
/* Charleston (Light Mode) - Iron frame effect */
:global(.deepdrft-theme-light) .player-backdrop {
background: color-mix(in srgb, var(--charleston-cream) 92%, transparent);
border: 2px solid var(--charleston-iron);
box-shadow: 0 4px 20px color-mix(in srgb, var(--charleston-iron) 20%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--charleston-iron) 5%, transparent);
color: var(--charleston-iron);
}
/* Lowcountry (Dark Mode) - Warm sunset glow effect */
:global(.deepdrft-theme-dark) .player-backdrop {
background: color-mix(in srgb, var(--lowcountry-night) 88%, transparent);
border: 1px solid color-mix(in srgb, var(--lowcountry-coral) 50%, transparent);
box-shadow: 0 0 20px color-mix(in srgb, var(--lowcountry-coral) 25%, transparent),
0 0 40px color-mix(in srgb, var(--lowcountry-twilight) 15%, transparent),
0 4px 20px rgba(0, 0, 0, 0.4);
color: var(--lowcountry-moonlight);
}
/* Control buttons positioning */
.player-controls {
/* Minimize / close cluster, pinned top-right of the surface */
::deep .player-surface .player-window-controls {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
/* Minimized floating dock with gradient */
/* Minimized floating dock — positioning + hover only; colour from MudFab */
.minimized-dock {
position: fixed;
bottom: 60px;
right: 60px;
bottom: 30px;
right: 30px;
z-index: 1300;
width: 60px;
height: 60px;
border-radius: 50%;
cursor: pointer;
background: linear-gradient(135deg,
var(--deepdrft-theme-primary) 0%,
var(--deepdrft-theme-secondary) 50%,
var(--deepdrft-theme-tertiary) 100%
);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border: 2px solid var(--deepdrft-theme-secondary);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}
/* Charleston (Light Mode) - Iron dock */
:global(.deepdrft-theme-light) .minimized-dock {
background: linear-gradient(135deg, var(--charleston-iron) 0%, var(--charleston-rose) 50%, var(--charleston-gold) 100%);
border: 2px solid var(--charleston-iron);
box-shadow: 0 4px 15px color-mix(in srgb, var(--charleston-iron) 40%, transparent);
}
/* Lowcountry (Dark Mode) - Warm sunset dock */
:global(.deepdrft-theme-dark) .minimized-dock {
background: linear-gradient(135deg, var(--lowcountry-coral) 0%, var(--lowcountry-twilight) 50%, var(--lowcountry-gold) 100%);
border: 2px solid color-mix(in srgb, var(--lowcountry-coral) 60%, transparent);
box-shadow: 0 0 20px color-mix(in srgb, var(--lowcountry-coral) 40%, transparent),
0 0 40px color-mix(in srgb, var(--lowcountry-twilight) 20%, transparent);
}
.minimized-dock:hover {
transform: scale(1.1);
}
:global(.deepdrft-theme-light) .minimized-dock:hover {
box-shadow: 0 6px 20px color-mix(in srgb, var(--charleston-iron) 50%, transparent);
}
:global(.deepdrft-theme-dark) .minimized-dock:hover {
box-shadow: 0 0 30px color-mix(in srgb, var(--lowcountry-coral) 50%, transparent),
0 0 50px color-mix(in srgb, var(--lowcountry-twilight) 30%, transparent);
}
/* Minimized button styles */
.minimized-button {
border-radius: 50% !important;
background: transparent !important;
color: white !important;
transition: all 0.3s ease !important;
box-shadow: none !important;
border: none !important;
width: 48px !important;
height: 48px !important;
}
/* Spacer to prevent content overlap */
.player-spacer {
height: 140px;
width: 100%;
flex-shrink: 0;
}
/* Essential layout adjustments only */
.controls-left {
min-width: 200px;
}
.seekbar-visualizer-container {
flex: 1;
display: flex;
flex-direction: column;
}
.seekbar-flex {
flex: 1;
}
.volume-right {
/*min-width: 140px;*/
}
/* Mobile responsive adjustments */
@media (max-width: 768px) {
.minimized-dock {
bottom: 15px;
right: 15px;
width: 56px;
height: 56px;
}
.minimized-button {
width: 44px !important;
height: 44px !important;
::deep .player-surface {
margin-bottom: 0.5rem;
}
.player-inner-container {
padding: 0.75rem;
padding-bottom: 1.25rem;
}
/* Unified responsive player layout CSS Grid with named areas redefined per breakpoint.
The metadata (meta-zone) detaches from the waveform (seek-zone) in the mid band, which a
flex order-swap can't express, so each of the four zones is placed by grid-area and the three
shapes are pure template-area swaps no runtime breakpoint subscription. min-width:0 on the
shrinkable centre zones lets the title truncate and the waveform shrink instead of overflowing. */
.player-layout {
display: grid;
align-items: center;
gap: 8px;
/*padding-right: 2.5rem; !* clear the abs-positioned PlayerWindowControls *!*/
}
::deep .transport-zone { grid-area: transport; }
::deep .meta-zone { grid-area: meta; min-width: 0; }
::deep .seek-zone { grid-area: waveform; min-width: 0; }
::deep .volume-zone { grid-area: volume; }
/* Wide (>= 900px): single row transport and volume flank the centre column; the metadata
sits directly under the waveform (transport/volume span both rows, centred). */
@media (min-width: 900px) {
.player-layout {
grid-template-columns: auto minmax(360px, 1fr) auto;
grid-template-areas:
"transport waveform volume"
"transport meta .";
}
.player-backdrop {
border-radius: 1rem;
margin-bottom: 1.25rem;
}
/* Mid (600900px): metadata rides the top row between transport and volume; the waveform gets
the whole bottom row to itself rather than being squeezed beside the metadata. */
@media (min-width: 600px) and (max-width: 899.98px) {
.player-layout {
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
"transport meta volume"
"waveform waveform waveform";
}
.player-spacer {
height: 160px;
}
/* Narrow (< 600px): transport + volume share the top row; waveform then metadata stack full-width
below the most compressed shape. */
@media (min-width: 420px) and (max-width: 599.98px) {
.player-layout {
grid-template-columns: auto 1fr auto;
grid-template-areas:
"transport . volume"
"waveform waveform waveform"
"meta meta meta";
}
}
}
/* Very Narrow (< 400px): transport + volume share the top row; waveform then metadata stack full-width
below the most compressed shape. */
@media (max-width: 419.98px) {
.player-layout {
grid-template-columns: auto 1fr auto;
grid-template-areas:
"transport . volume"
"waveform waveform waveform"
"meta meta meta";
}
}
@@ -0,0 +1,38 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<button class="lmf-fab-btn" type="button" @onclick="OnClick">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="lmf-icon" aria-hidden="true">
<defs>
<!-- Vertical gradient anchored to viewBox (userSpaceOnUse), bottom -> top.
y1=24 (bottom) is the green end; y2=0 (top) is the orange end. -->
<linearGradient id="lmf-grad-@(IdSuffix)"
gradientUnits="userSpaceOnUse" x1="0" y1="24" x2="0" y2="0">
<stop offset="0%" stop-color="#1A5C38" />
<stop offset="27%" stop-color="#1A5C38" />
<stop offset="33%" stop-color="#2ECC71" />
<stop offset="47%" stop-color="#2ECC71" />
<stop offset="53%" stop-color="#F4C430" />
<stop offset="72%" stop-color="#F4C430" />
<stop offset="78%" stop-color="#FF6B35" />
<stop offset="100%" stop-color="#FF6B35" />
</linearGradient>
<!-- The note silhouette, used to clip the fill rect. -->
<clipPath id="lmf-clip-@(IdSuffix)">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</clipPath>
</defs>
<!-- Always-on dim silhouette: the idle look and the unfilled remainder. -->
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
fill="rgba(255,255,255,0.25)" />
<!-- Clipped fill: a full-width rect revealed through the note shape. -->
<g clip-path="url(#lmf-clip-@(IdSuffix))">
<rect class="lmf-fill-rect"
x="0" width="24"
y="@FillY" height="@FillH"
fill="url(#lmf-grad-@(IdSuffix))" />
</g>
</svg>
</button>
@@ -0,0 +1,146 @@
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
{
[Inject] public required AudioInteropService AudioInterop { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Parameter] public EventCallback OnClick { get; set; }
// Calibration window for true RMS dBFS (AnalyserNode.getFloatTimeDomainData).
// Floor/ceiling define the dB window; the linear map across this 57 dB range places
// a steady dance track (~-14 dBFS) at ~81% fill (upper yellow) and a hot drop
// (~-6 dBFS) at ~95% (deep orange). Attack/release coefficients are by-ear tuning values.
private const double FloorDb = -60.0; // fill = 0%; below this is near-silence (true RMS dBFS)
private const double CeilingDb = -3.0; // fill = 100%; hot peaks on commercial masters (true RMS dBFS)
private const double SilenceFloorDb = -80.0; // matches the analyzer's normalization window
private const double AttackCoefficient = 0.5; // fast rise toward a louder reading
private const double ReleaseCoefficient = 0.12; // slow decay so the column doesn't strobe
private readonly string _instanceId = Guid.NewGuid().ToString();
private bool _isAnimating;
private string? _playerId;
private IStreamingPlayerService? _subscribedService;
private double _smoothedDb = SilenceFloorDb;
private double _fillPercent; // 0..100, the sole render state
private string IdSuffix => _instanceId.Replace("-", "");
private double FillHeight => 24.0 * (_fillPercent / 100.0);
private string FillY => (24.0 - FillHeight).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
private string FillH => FillHeight.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
protected override async Task OnParametersSetAsync()
{
// The cascade is IsFixed, so the provider's re-renders do NOT re-run
// OnParametersSet here. Subscribe to the multicast StateChanged side-channel
// so animation state stays correct independent of parent re-renders —
// notably when the bar minimizes while a track is already playing.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
if (_playerId == null && PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
}
await UpdateAnimationState();
}
private void OnPlayerStateChanged() => InvokeAsync(async () =>
{
if (_playerId == null && PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
}
await UpdateAnimationState();
});
private async Task UpdateAnimationState()
{
if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return;
var shouldAnimate = PlayerService.IsPlaying;
if (shouldAnimate && !_isAnimating)
{
await StartAnimation();
}
else if (!shouldAnimate && _isAnimating)
{
await StopAnimation();
}
}
private async Task StartAnimation()
{
if (_isAnimating || string.IsNullOrEmpty(_playerId)) return;
_isAnimating = true;
_smoothedDb = SilenceFloorDb;
_fillPercent = 0;
await AudioInterop.StartLevelAnimationAsync(_playerId, _instanceId, OnLevelData);
}
private async Task StopAnimation()
{
if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return;
_isAnimating = false;
await AudioInterop.StopLevelAnimationAsync(_playerId, _instanceId);
// Drop the column to empty so only the dim silhouette remains.
if (_fillPercent != 0)
{
_fillPercent = 0;
await InvokeAsync(StateHasChanged);
}
_smoothedDb = SilenceFloorDb;
}
private Task OnLevelData(double db)
{
// db is true RMS dBFS from getFloatTimeDomainData; -Infinity on silence.
var instantDb = double.IsNegativeInfinity(db) || double.IsNaN(db)
? SilenceFloorDb
: Math.Max(db, SilenceFloorDb);
// Attack-fast / release-slow envelope so the column doesn't strobe at 30fps.
var coeff = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient;
_smoothedDb += (instantDb - _smoothedDb) * coeff;
// Linear map of smoothed dB onto a 0-100 fill across the [floor, ceiling] window.
var next = Math.Clamp((_smoothedDb - FloorDb) / (CeilingDb - FloorDb) * 100.0, 0.0, 100.0);
// Re-render only on a meaningful change to avoid 30fps churn over sub-pixel deltas.
if (Math.Abs(next - _fillPercent) >= 0.5)
{
_fillPercent = next;
InvokeAsync(StateHasChanged);
}
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
await StopAnimation();
}
}
@@ -0,0 +1,35 @@
/* Circular FAB matching MudFab Size.Large (56px) with MudBlazor primary palette */
.lmf-fab-btn {
width: 72px;
height: 72px;
border-radius: 50%;
border: none;
cursor: pointer;
background-color: var(--mud-palette-primary);
color: var(--mud-palette-primary-text);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0px 3px 5px -1px rgba(0,0,0,.2),
0px 6px 10px 0px rgba(0,0,0,.14),
0px 1px 18px 0px rgba(0,0,0,.12);
transition: box-shadow 150ms ease-out;
padding: 0;
}
.lmf-fab-btn:hover {
box-shadow: 0px 7px 8px -4px rgba(0,0,0,.2),
0px 12px 17px 2px rgba(0,0,0,.14),
0px 5px 22px 4px rgba(0,0,0,.12);
}
.lmf-fab-btn:focus-visible {
outline: 2px solid var(--mud-palette-primary);
outline-offset: 3px;
}
/* Fill motion is driven by C#-computed SVG geometry, not CSS — no transition here. */
.lmf-icon {
width: 56px;
height: 56px;
}
@@ -1,12 +1,17 @@
<div class="player-buttons">
<MudIconButton Icon="@GetPlayIcon()"
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
@using DeepDrftPublic.Client.Controls
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<PlayStateIcon Size="Size.Large"
Color="Color.Primary"
Size="Size.Large"
OnClick="@TogglePlayPause"
Disabled="!IsLoaded"/>
<MudIconButton Icon="@Icons.Material.Filled.Stop"
Color="Color.Primary"
Size="Size.Large"
OnClick="@Stop"
Disabled="!IsLoaded"/>
</div>
Disabled="!CanPlay"
OnToggle="@TogglePlayPause"/>
@if (!Fixed)
{
<MudIconButton Icon="@Icons.Material.Filled.Stop"
Color="Color.Primary"
Size="Size.Large"
OnClick="@Stop"
Disabled="!IsLoaded"/>
}
</MudStack>
@@ -1,16 +1,19 @@
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class PlayerControls : ComponentBase
{
[Parameter] public required bool IsPlaying { get; set; }
[Parameter] public required bool IsLoaded { get; set; }
/// <summary>
/// Whether the play button is enabled. Distinct from <see cref="IsLoaded"/> so a staged-but-
/// unloaded track (embed pre-gesture state) can still be played: the click loads it. Stop stays
/// gated on <see cref="IsLoaded"/>.
/// </summary>
[Parameter] public bool CanPlay { get; set; }
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public required EventCallback TogglePlayPause { get; set; }
[Parameter] public required EventCallback Stop { get; set; }
private string GetPlayIcon()
{
return IsPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
}
}
}
@@ -1,8 +0,0 @@
/* PlayerControls Component Styles */
/* Button spacing and alignment */
.player-buttons {
display: flex;
align-items: center;
gap: 0.5rem;
}
@@ -0,0 +1,8 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<MudStack Row="false" Spacing="1" Class="@Class">
<WaveformSeeker OnSeekStart="OnSeekStart"
OnSeekChange="OnSeekChange"
OnSeekEnd="OnSeekEnd"
Class="seek-waveform"/>
</MudStack>
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
/// <summary>
/// Centre zone of the player: the <see cref="WaveformSeeker"/>. The seeker owns the pointer-gesture
/// seek logic and reads playback state off the cascaded player service directly; this zone just
/// forwards the seek callbacks up to <see cref="AudioPlayerBar"/> (whose wiring is unchanged).
/// The now-playing metadata (<see cref="TrackMetaLabel"/>) is a sibling zone in the grid, not nested
/// here, so the responsive layouts can place it independently of the waveform.
/// </summary>
public partial class PlayerSeekZone : ComponentBase
{
[Parameter] public EventCallback OnSeekStart { get; set; }
[Parameter] public EventCallback<double> OnSeekEnd { get; set; }
[Parameter] public EventCallback<double> OnSeekChange { get; set; }
[Parameter] public string? Class { get; set; }
}
@@ -0,0 +1,20 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<MudStack Row="false" AlignItems="AlignItems.Center" Spacing="1" Class="@Class">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<PlayerControls IsLoaded="IsLoaded"
CanPlay="CanPlay"
Fixed="Fixed"
TogglePlayPause="TogglePlayPause"
Stop="Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
</MudStack>
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
</MudStack>
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class PlayerTransportZone : ComponentBase
{
[Parameter] public bool IsLoaded { get; set; }
[Parameter] public bool CanPlay { get; set; }
[Parameter] public bool IsLoading { get; set; }
[Parameter] public bool IsStreaming { get; set; }
[Parameter] public double LoadProgress { get; set; }
[Parameter] public double DisplayTime { get; set; }
[Parameter] public double? Duration { get; set; }
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public EventCallback TogglePlayPause { get; set; }
[Parameter] public EventCallback Stop { get; set; }
[Parameter] public string? Class { get; set; }
}
@@ -0,0 +1,4 @@
/* Stable minimum width so the transport cluster doesn't reflow */
.transport-zone {
min-width: 180px;
}
@@ -0,0 +1,13 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="player-window-controls">
<MudIconButton Color="Color.Secondary"
Size="Size.Small"
OnClick="OnMinimize">
&mdash;
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.Close"
Color="Color.Secondary"
Size="Size.Small"
OnClick="OnClose"/>
</MudStack>
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class PlayerWindowControls : ComponentBase
{
[Parameter] public EventCallback OnMinimize { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
}
@@ -9,12 +9,13 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Parameter] public int BucketCount { get; set; } = 32;
[Parameter] public int BucketCount { get; set; } = 24;
private readonly string _instanceId = Guid.NewGuid().ToString();
private double[] _spectrumData = Array.Empty<double>();
private bool _isAnimating = false;
private string? _playerId;
private IStreamingPlayerService? _subscribedService;
private bool IsVisible => (PlayerService?.IsPlaying ?? false) || (PlayerService?.IsPaused ?? false) || _isAnimating;
@@ -25,9 +26,20 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
protected override async Task OnParametersSetAsync()
{
// Provider re-renders cascade down to children and re-run OnParametersSet.
// Pick up the player id once the cascade arrives, then drive animation
// state from the parent's current IsPlaying — no callback wrapping needed.
// The cascade is IsFixed, so the provider's re-renders do NOT re-run
// OnParametersSet here, and this component has no incoming parameters
// that change. Subscribe to the multicast StateChanged side-channel so
// animation state stays correct independent of parent re-renders —
// notably when the bar expands while a track is already playing.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
if (_playerId == null && PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
@@ -36,6 +48,15 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
await UpdateAnimationState();
}
private void OnPlayerStateChanged() => InvokeAsync(async () =>
{
if (_playerId == null && PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
}
await UpdateAnimationState();
});
private async Task UpdateAnimationState()
{
if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return;
@@ -93,6 +114,11 @@ public partial class SpectrumVisualizer : ComponentBase, IAsyncDisposable
public async ValueTask DisposeAsync()
{
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
await StopAnimation();
}
}
@@ -26,22 +26,19 @@
min-width: 4px;
height: var(--bar-height, 2%);
min-height: 2px;
background: var(--deepdrft-theme-secondary);
background-image: linear-gradient(to top,
#1A5C38 0%, #1A5C38 27%,
#2ECC71 33%, #2ECC71 47%,
#F4C430 53%, #F4C430 72%,
#FF6B35 78%, #FF6B35 100%
);
background-size: 100% 40px;
background-position: bottom;
background-repeat: no-repeat;
border-radius: 2px 2px 0 0;
transition: height 0.05s ease-out;
}
/* Charleston (Light Mode) - Iron to gold colored bars */
:global(.deepdrft-theme-light) .spectrum-bar {
background: linear-gradient(to top, var(--charleston-iron) 0%, var(--charleston-rose) 50%, var(--charleston-gold) 100%);
}
/* Lowcountry (Dark Mode) - Coral to gold bars with warm glow */
:global(.deepdrft-theme-dark) .spectrum-bar {
background: linear-gradient(to top, var(--lowcountry-coral) 0%, var(--lowcountry-gold) 100%);
box-shadow: 0 0 4px color-mix(in srgb, var(--lowcountry-gold) 40%, transparent);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.spectrum-container {
@@ -55,5 +52,6 @@
.spectrum-bar {
max-width: 8px;
min-width: 3px;
background-size: 100% 32px;
}
}
@@ -1,5 +1,5 @@
<div class="timestamp-display">
<MudText Typo="Typo.body2" Class="time-text">
<div class="timestamp-display">
<MudText Typo="Typo.caption" Class="time-text">
@FormatTime(CurrentTime) / @(Duration.HasValue ? FormatTime(Duration.Value) : "--:--")
</MudText>
</div>
</div>
@@ -1,12 +1,5 @@
/* TimestampLabel Component Styles */
/* Timestamp display */
/* Layout stability so the timestamp doesn't reflow as digits change */
.timestamp-display {
min-width: 120px;
text-align: center;
}
/* Time text styling */
.time-text {
font-family: monospace;
}
@@ -0,0 +1,40 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
@if (Track is not null)
{
<div class="track-meta-row">
<div class="track-meta-identity">
<a href="@($"/track/{Track.EntryKey}")" style="text-decoration: none;">
<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.Artist
</MudText>
</div>
<div class="track-meta-accents">
@if (!string.IsNullOrEmpty(Track.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@Track.Genre
</MudChip>
}
@if (Track.ReleaseDate.HasValue)
{
<MudText Typo="Typo.caption" Class="track-meta-year">
@Track.ReleaseDate.Value.Year
</MudText>
}
</div>
</div>
}
@@ -0,0 +1,14 @@
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
/// <summary>
/// The now-playing metadata row beneath the <see cref="WaveformSeeker"/>: track title + artist on
/// the left, genre chip + release year on the right. Reads nothing from the player service itself —
/// the current <see cref="TrackDto"/> is passed in by <see cref="PlayerSeekZone"/>.
/// </summary>
public partial class TrackMetaLabel : ComponentBase
{
[Parameter] public TrackDto? Track { get; set; }
}
@@ -0,0 +1,105 @@
/* 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. */
.track-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 2px 4px 0;
}
/* Left group shrinks and truncates so a long title never pushes the chip off-screen. */
.track-meta-identity {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
::deep .track-meta-title {
font-family: var(--deepdrft-font-mono);
min-width: 0;
}
::deep .track-meta-artist {
opacity: 0.75;
min-width: 0;
}
/* Right group keeps its natural size and never shrinks. */
.track-meta-accents {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
::deep .track-meta-year {
opacity: 0.75;
}
/* The metadata's three shapes track the dock's layout bands (same breakpoints as the grid in
AudioPlayerBar.razor.css), not the label's own slot width in the <600 band the slot is actually
full-width yet we still want it fully vertical, which a container query can't express.
Mid band (600900): 2×2 title over artist on the left (start-justified), genre over year on the
right (end-justified). The row stays a row; only the two inner groups go vertical. */
@media (min-width: 600px) and (max-width: 899.98px) {
.track-meta-row {
align-items: flex-start;
}
.track-meta-identity {
flex-direction: column;
align-items: flex-start;
gap: 0;
}
.track-meta-accents {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
::deep .track-meta-sep {
display: none;
}
::deep .track-meta-year {
padding-left: 8px;
}
}
/* Narrow band (<600): fully vertical — title / artist / genre / year all stacked, left-aligned. */
@media (min-width: 420px) and (max-width: 599.98px) {
.track-meta-identity {
flex-direction: column;
align-items: flex-start;
gap: 0;
}
.track-meta-accents {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
::deep .track-meta-sep {
display: none;
}
::deep .track-meta-year {
padding-left: 8px;
}
}
@media (max-width: 419.98px) {
.track-meta-row {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
@@ -1,10 +0,0 @@
<div class="volume-controls">
<MudIcon Icon="@GetVolumeIcon()" Class="volume-icon"/>
<MudSlider T="double"
Min="0"
Max="1"
Step="0.01"
Value="@Volume"
ValueChanged="@VolumeChanged"
Class="volume-slider"/>
</div>
@@ -1,19 +0,0 @@
/* VolumeControls Component Styles */
/* Volume control container */
.volume-controls {
display: flex;
align-items: center;
gap: 0.25rem;
width: 140px;
}
/* Volume icon styling */
.volume-icon {
margin-right: 4px;
}
/* Volume slider styling */
.volume-slider {
width: 100px;
}
@@ -0,0 +1,15 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<MudStack Row="false" AlignItems="AlignItems.Center" Spacing="1" Class="@($"volume-zone {Class}".TrimEnd())">
<SpectrumVisualizer BucketCount="18"/>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="volume-row">
<MudIcon Icon="@GetVolumeIcon()"/>
<MudSlider T="double"
Min="0"
Max="1"
Step="0.01"
Value="@Volume"
ValueChanged="@VolumeChanged"
Class="volume-slider"/>
</MudStack>
</MudStack>
@@ -3,8 +3,9 @@ using MudBlazor;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class VolumeControls : ComponentBase
public partial class VolumeZone : ComponentBase
{
[Parameter] public string? Class { get; set; }
[Parameter] public required double Volume { get; set; }
[Parameter] public required EventCallback<double> VolumeChanged { get; set; }
private string GetVolumeIcon()
@@ -0,0 +1,15 @@
/* Width caps only layout/colour come from MudStack + theme.
The zone stacks the spectrum visualizer above the volume row, both sized to the same
compact width so the spectrum matches the slider. 75px fits 10 spectrum bars (4px min +
2px gap 70px) without clipping under the container's overflow:hidden. */
.volume-zone {
width: 75px;
}
.volume-row {
width: 100%;
}
.volume-slider {
flex: 1;
}
@@ -0,0 +1,45 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
@* High-density loudness-waveform seekbar. Replaces the MudSlider in PlayerSeekZone.
Bars render once; the played/unplayed split is a single clip-width animation on the
muted overlay (see .razor.css), so a seek never re-renders the 200 bar divs. *@
<div class="@($"waveform-seeker {Class}".TrimEnd())"
style="--played-fraction: @PlayedFraction.ToString("F4", System.Globalization.CultureInfo.InvariantCulture);"
@ref="_seekerElement"
@onpointerdown="HandlePointerDown"
@onpointermove="HandlePointerMove"
@onpointerup="HandlePointerUp"
@onpointercancel="HandlePointerUp"
@onpointerleave="HandlePointerLeave">
@* Played layer: every bar in the accent colour. *@
<div class="waveform-layer waveform-layer-played">
@for (var i = 0; i < _renderedBars.Length; i++)
{
<div class="waveform-bar" style="--bar-height: @FormatHeight(_renderedBars[i]);"></div>
}
</div>
@* Unplayed overlay: an identical bar set in the muted colour, clipped to the
unplayed portion. Only its clip width changes on seek — one CSS property. *@
<div class="waveform-layer waveform-layer-unplayed">
@for (var i = 0; i < _renderedBars.Length; i++)
{
<div class="waveform-bar" style="--bar-height: @FormatHeight(_renderedBars[i]);"></div>
}
</div>
@* Playhead rule at the split point. *@
@* <div class="waveform-playhead"></div> *@
@* Hover preview line + time tooltip (suppressed while dragging). *@
@if (_showHover && !_isSeeking)
{
<div class="waveform-hover-line"
style="left: @HoverPercent;"></div>
<div class="waveform-hover-time"
style="left: @HoverPercent;">
@FormatTime(_hoverTime)
</div>
}
</div>
@@ -0,0 +1,308 @@
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
/// <summary>
/// Loudness-waveform seekbar. Renders the current track's profile (off the cascaded player
/// service) as a high-density bar chart and serves as the seek surface. The played/unplayed
/// split is a CSS clip overlay (<c>--played-fraction</c>), so seeking never re-renders bars.
/// Seekability never depends on the profile: with no profile it draws flat floor-height bars
/// that are still fully seekable.
/// </summary>
public partial class WaveformSeeker : ComponentBase, IAsyncDisposable
{
[Inject] public required IJSRuntime JS { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Parameter] public EventCallback OnSeekStart { get; set; }
[Parameter] public EventCallback<double> OnSeekChange { get; set; }
[Parameter] public EventCallback<double> OnSeekEnd { get; set; }
[Parameter] public string? Class { get; set; }
/// <summary>Cap on rendered bars; actual count is min(profile length, this, width-derived).</summary>
[Parameter] public int MaxBars { get; set; } = 200;
/// <summary>Minimum bar height as a fraction of full height, so silence stays a visible hairline.</summary>
private const double HeightFloor = 0.02;
private ElementReference _seekerElement;
private IStreamingPlayerService? _subscribedService;
private IJSObjectReference? _jsModule;
private IJSObjectReference? _resizeObserver;
private DotNetObjectReference<WaveformSeeker>? _dotNetRef;
// Bars currently drawn (normalized [0,1] heights). Recomputed only when the source profile
// identity or the rendered width changes — not on every seek/progress tick.
private double[] _renderedBars = Array.Empty<double>();
// _lastProfileRef: sole tracker for profile-change detection in OnPlayerStateChanged.
// _lastWidth: sole tracker for width-change detection in OnAfterRenderAsync.
// Kept separate so a width rebuild cannot mask a pending profile change.
private double[]? _lastProfileRef;
private double _elementWidth;
// Seek/hover gesture state.
private bool _isSeeking;
private double _seekFraction;
private bool _showHover;
private double _hoverFraction;
private double _hoverTime;
private string HoverPercent => $"{_hoverFraction * 100.0}%";
private double? Duration => PlayerService?.Duration;
private bool CanSeek => (PlayerService?.IsLoaded ?? false) && Duration is > 0;
/// <summary>Fraction of the track that reads as "played" — the drag position while seeking,
/// otherwise live playback position. Drives the clip overlay and the playhead.</summary>
private double PlayedFraction
{
get
{
if (_isSeeking) return _seekFraction;
if (PlayerService is null || Duration is not > 0) return 0;
return Math.Clamp(PlayerService.CurrentTime / Duration.Value, 0, 1);
}
}
protected override void OnInitialized()
{
// Seed flat fallback bars so the control reads as a seekbar on the very first paint,
// before the width is measured (OnAfterRender) or a profile arrives (StateChanged).
RebuildBars();
}
protected override void OnParametersSet()
{
// The cascade is IsFixed, so subscribe to the multicast side-channel to re-render when
// playback position or the fetched profile changes — same pattern as SpectrumVisualizer.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
}
private void OnPlayerStateChanged() => InvokeAsync(() =>
{
// Rebuild bars only if the profile reference changed; position ticks just re-render the
// clip overlay (a CSS var), which StateHasChanged already covers.
var profile = PlayerService?.WaveformProfile;
if (!ReferenceEquals(profile, _lastProfileRef))
{
_lastProfileRef = profile;
RebuildBars();
}
StateHasChanged();
});
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/waveformSeeker.js");
}
if (_jsModule is null) return;
if (firstRender)
{
var width = await _jsModule.InvokeAsync<double>("getWidth", _seekerElement);
if (width > 0 && Math.Abs(width - _elementWidth) > 1)
{
_elementWidth = width;
RebuildBars();
StateHasChanged();
}
_dotNetRef = DotNetObjectReference.Create(this);
_resizeObserver = await _jsModule.InvokeAsync<IJSObjectReference>("observeResize", _seekerElement, _dotNetRef);
}
}
[JSInvokable]
public void OnWidthChanged(double width)
{
if (width > 0 && Math.Abs(width - _elementWidth) > 1)
{
_elementWidth = width;
RebuildBars();
InvokeAsync(StateHasChanged);
}
}
/// <summary>
/// Recomputes the drawn bar set: count derived from available width (capped by MaxBars and the
/// source length), heights downsampled from the profile by peak. With no profile, emits flat
/// floor-height bars so the control still reads as — and behaves as — a seekbar.
/// </summary>
private void RebuildBars()
{
var profile = PlayerService?.WaveformProfile;
var widthBars = _elementWidth > 0 ? (int)(_elementWidth / BarPitchPx) : MaxBars;
var barCount = Math.Clamp(widthBars, 1, MaxBars);
if (profile is null || profile.Length == 0)
{
// Flat fallback — floor-height bars, fully seekable.
var flat = new double[barCount];
Array.Fill(flat, HeightFloor);
_renderedBars = flat;
return;
}
barCount = Math.Min(barCount, profile.Length);
var bars = new double[barCount];
for (var i = 0; i < barCount; i++)
{
// Source range [start, end) for this rendered bar; peak (max) over it for the punchy look.
var start = (int)((long)i * profile.Length / barCount);
var end = (int)((long)(i + 1) * profile.Length / barCount);
if (end <= start) end = start + 1;
var peak = 0.0;
for (var j = start; j < end && j < profile.Length; j++)
{
if (profile[j] > peak) peak = profile[j];
}
bars[i] = Math.Max(HeightFloor, peak);
}
_renderedBars = bars;
}
/// <summary>Bar + gap pitch in px, matched to SpectrumVisualizer (≈4px min bar + 2px gap).</summary>
private const double BarPitchPx = 6.0;
private async Task HandlePointerDown(PointerEventArgs e)
{
if (!CanSeek) return;
_isSeeking = true;
_seekFraction = FractionFromOffset(e.OffsetX);
// Fire seek-start notifications BEFORE awaiting capturePointer. In Blazor WASM a JS
// interop await yields to the browser event loop. A fast click can fire pointerup
// during that window: HandlePointerUp runs (OnSeekEnd, _isSeeking = false), then
// HandlePointerDown resumes and calls OnSeekStart (_isSeeking stuck true, display
// frozen). Notifying first ensures the ordering is always Start → End, never End → Start.
if (Duration is not > 0) { _isSeeking = false; return; }
await OnSeekStart.InvokeAsync();
await OnSeekChange.InvokeAsync(_seekFraction * Duration.Value);
// Capture AFTER seek-start is notified so a fast pointerup cannot reorder
// OnSeekEnd before OnSeekStart in AudioPlayerBar.
if (_jsModule is not null)
{
try
{
await _jsModule.InvokeVoidAsync("capturePointer", _seekerElement, e.PointerId);
}
catch
{
// Capture is a UX nicety; gesture still works within element bounds.
}
}
}
private async Task HandlePointerMove(PointerEventArgs e)
{
if (!CanSeek) return;
if (Duration is not > 0) return;
var fraction = FractionFromOffset(e.OffsetX);
if (_isSeeking)
{
_seekFraction = fraction;
await OnSeekChange.InvokeAsync(fraction * Duration.Value);
}
else
{
_showHover = true;
_hoverFraction = fraction;
_hoverTime = fraction * Duration.Value;
StateHasChanged();
}
}
private async Task HandlePointerUp(PointerEventArgs e)
{
if (!_isSeeking) return;
_isSeeking = false;
if (Duration is not > 0) return;
_seekFraction = FractionFromOffset(e.OffsetX);
await OnSeekEnd.InvokeAsync(_seekFraction * Duration.Value);
}
private async Task HandlePointerLeave()
{
// Pointer capture keeps a drag alive past the edge, so a leave during seeking means the
// gesture genuinely ended without a pointerup (rare). Commit at the last known position.
if (_isSeeking)
{
_isSeeking = false;
if (Duration is > 0)
await OnSeekEnd.InvokeAsync(_seekFraction * Duration.Value);
}
if (_showHover)
{
_showHover = false;
StateHasChanged();
}
}
private double FractionFromOffset(double offsetX)
{
if (_elementWidth <= 0) return 0;
return Math.Clamp(offsetX / _elementWidth, 0, 1);
}
private static string FormatHeight(double normalized) =>
$"{(normalized * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}%";
private static string FormatTime(double seconds)
{
if (double.IsNaN(seconds) || seconds < 0) seconds = 0;
var ts = TimeSpan.FromSeconds(seconds);
return $"{(int)ts.TotalMinutes}:{ts.Seconds:D2}";
}
public async ValueTask DisposeAsync()
{
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
if (_resizeObserver is not null)
{
try { await (_jsModule?.InvokeVoidAsync("unobserveResize", _resizeObserver) ?? ValueTask.CompletedTask); } catch { }
try { await _resizeObserver.DisposeAsync(); } catch { }
_resizeObserver = null;
}
_dotNetRef?.Dispose();
_dotNetRef = null;
if (_jsModule is not null)
{
try
{
await _jsModule.DisposeAsync();
}
catch (JSDisconnectedException)
{
// The circuit/runtime is already gone (navigation/teardown); nothing to release.
}
_jsModule = null;
}
}
}
@@ -0,0 +1,92 @@
/* Geometry only. Colours come from shared --deepdrft-* theme tokens (deepdrft-tokens.css). */
.waveform-seeker {
position: relative;
width: 100%;
height: 48px;
cursor: pointer;
touch-action: none; /* let pointer-capture drag own the gesture, not the browser's scroll */
user-select: none;
}
/* The two bar layers stack in the same box. The played layer paints all bars in the accent
colour; the unplayed layer paints an identical set in muted and is clipped to the unplayed
portion, so the only thing that moves on seek is one clip-path inset (--played-fraction). */
.waveform-layer {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2px;
padding: 0 2px;
pointer-events: none;
}
.waveform-bar {
flex: 1;
width: 6px;
height: var(--bar-height, 2%);
min-height: 2px;
border-radius: 2px 2px 0 0;
}
.waveform-layer-played .waveform-bar {
background: var(--deepdrft-green-accent);
}
.waveform-layer-unplayed {
/* Reveal only the unplayed (right) portion; the played portion shows the accent layer beneath. */
clip-path: inset(0 0 0 calc(var(--played-fraction, 0) * 100%));
transition: clip-path 0.08s linear;
}
.waveform-layer-unplayed .waveform-bar {
background: var(--deepdrft-muted);
}
/*!* Playhead rule at the split point. *!*/
/*.waveform-playhead {*/
/* position: absolute;*/
/* top: 0;*/
/* bottom: 0;*/
/* left: calc(var(--played-fraction, 0) * 100%);*/
/* width: 2px;*/
/* margin-left: -1px;*/
/* background: var(--deepdrft-green-accent);*/
/* pointer-events: none;*/
/* transition: left 0.08s linear;*/
/*}*/
/* Hover preview line — faint vertical rule under the cursor. */
.waveform-hover-line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
margin-left: -0.5px;
background: var(--deepdrft-muted);
opacity: 0.6;
pointer-events: none;
}
/* Hover time tooltip. */
.waveform-hover-time {
position: absolute;
bottom: calc(100% + 2px);
transform: translateX(-50%);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.7rem;
line-height: 1.2;
white-space: nowrap;
color: var(--deepdrft-white);
background: var(--deepdrft-navy-mid);
pointer-events: none;
}
@media (max-width: 768px) {
.waveform-seeker {
height: 40px;
}
}
@@ -1,10 +1,14 @@
<div class="hero-eyebrow fade-up">Charleston, South Carolina</div>
<h1 class="hero-title fade-up">Deep<br /><em>Drft</em></h1>
<p class="hero-subtitle fade-up">Electronic Music Collective</p>
<p class="hero-desc fade-up">
<div class="hero-eyebrow @AnimClass">Charleston, South Carolina</div>
<h1 class="hero-title @AnimClass">Deep<br /><em>DRFT</em></h1>
<p class="hero-subtitle @AnimClass">Electronic Music Collective</p>
<p class="hero-desc @AnimClass">
We craft immersive electronic soundscapes &mdash; live; built from synthesizers, drum machines, and raw intention.
</p>
<div class="hero-actions fade-up">
<div class="hero-actions @AnimClass">
<a class="btn-primary" href="/tracks">Start Streaming</a>
<a class="btn-ghost" href="/tracks">Browse Tracks</a>
</div>
</div>
@code {
private string AnimClass => RendererInfo.IsInteractive ? string.Empty : "fade-up";
}
@@ -4,6 +4,11 @@
to { opacity: 1; transform: none; }
}
.fade-up {
opacity: 0;
animation: fade-up 0.8s ease forwards;
}
.hero-eyebrow {
font-family: var(--deepdrft-font-mono);
font-size: 0.65rem;
@@ -106,3 +111,15 @@
}
.btn-ghost:hover { border-color: var(--deepdrft-navy); }
@media (max-width: 599px) {
.hero-actions {
flex-direction: column;
align-items: stretch;
}
.btn-primary,
.btn-ghost {
text-align: center;
}
}
@@ -0,0 +1,7 @@
namespace DeepDrftPublic.Client.Controls;
public enum GalleryViewMode
{
Grid,
List
}
@@ -1,3 +1,8 @@
@keyframes pulse-ring {
0%, 100% { opacity: 0.15; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.4; transform: translate(-50%, -50%) scale(1.03); }
}
.now-playing-content {
position: relative;
z-index: 2;
@@ -1,3 +1,13 @@
@keyframes wave-dance {
from { height: var(--h-lo, 4px); }
to { height: var(--h-hi, 20px); }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.now-playing {
background: rgba(250, 250, 248, 0.06);
border: 1px solid rgba(250, 250, 248, 0.12);
@@ -26,3 +26,10 @@
text-transform: uppercase;
margin-top: 0.4rem;
}
@media (max-width: 599px) {
.hero-stat-row {
flex-direction: column;
gap: 0.75rem;
}
}
@@ -0,0 +1,22 @@
@namespace DeepDrftPublic.Client.Controls
<div class="icon-container">
@if (!RendererInfo.IsInteractive)
{
@* Interactive runtime (WASM, or Server on first visit) not attached yet — the prerendered
button has no wired click handler, so clicks would vanish. Show a spinner in its place
until the component hydrates, at which point it re-renders into the live button. *@
<MudProgressCircular Color="Color"
Size="Size"
Indeterminate="true"
Class="mud-icon-button" />
}
else
{
<MudIconButton Icon="@Icon"
Color="Color"
Size="Size"
Disabled="@Disabled"
OnClick="@OnToggle"/>
}
</div>
@@ -0,0 +1,62 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Helpers;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Renders the play/pause transport glyph for either a specific track or global playback,
/// reading live state off the cascaded <see cref="IStreamingPlayerService"/>. The icon is
/// always resolved through <see cref="PlaybackIcons.Resolve"/>.
/// </summary>
public partial class PlayStateIcon : ComponentBase, IDisposable
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
/// <summary>
/// When non-null, the icon reflects this track's playback state (active only when it is the
/// current track). When null, it reflects global playback state.
/// </summary>
[Parameter] public TrackDto? Track { get; set; }
[Parameter] public Size Size { get; set; } = Size.Medium;
[Parameter] public Color Color { get; set; } = Color.Primary;
[Parameter] public bool Disabled { get; set; } = false;
[Parameter] public EventCallback OnToggle { get; set; }
private IStreamingPlayerService? _subscribedService;
private bool IsActive => Track is null || PlayerService?.CurrentTrack?.Id == Track.Id;
private bool IsPlaying => IsActive && (PlayerService?.IsPlaying ?? false);
private bool IsPaused => IsActive && (PlayerService?.IsPaused ?? false);
private string Icon => PlaybackIcons.Resolve(IsPlaying, IsPaused);
protected override void OnParametersSet()
{
// 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.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
}
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
public void Dispose()
{
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
}
}
@@ -0,0 +1,14 @@
.icon-container {
display: flex;
justify-content: center;
align-content: center;
background-color: var(--deepdrft-soft);
border-radius: 50%;
height: 60px;
width: 60px;
transition: background-color 1s ease-in-out;
}
.icon-container:hover {
background-color: color-mix(var(--deepdrft-soft), var(--deepdrft-navy-mid) 25%);
}
@@ -0,0 +1,62 @@
@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>
<MudOverlay Visible="@_open" OnClick="@Close" AutoClose="true" />
<MudPopover Open="@_open"
Fixed="false"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
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>
}
</MudStack>
</MudPopover>
@@ -0,0 +1,91 @@
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.
/// </summary>
public partial class SharePopover : ComponentBase, IDisposable
{
[Parameter] public string? EntryKey { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
[Inject] public required IJSRuntime JS { get; set; }
private bool _open;
private bool _embed;
private bool _linkCopied;
private bool _embedCopied;
private readonly CancellationTokenSource _cts = new();
private bool Embed
{
get => _embed;
set
{
_embed = value;
if (!value) _embedCopied = false;
}
}
private string TrackUrl => $"{Navigation.BaseUri}track/{EntryKey}";
private string EmbedSnippet =>
$"""<iframe src="{Navigation.BaseUri}FramePlayer?TrackEntryKey={EntryKey}" width="640" height="96" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
private void Toggle() => _open = !_open;
private void Close() => _open = false;
private async Task CopyLink()
{
if (await CopyToClipboard(TrackUrl))
{
_linkCopied = true;
await ResetAfterDelay(() => _linkCopied = false);
}
}
private async Task CopyEmbed()
{
if (await CopyToClipboard(EmbedSnippet))
{
_embedCopied = true;
await ResetAfterDelay(() => _embedCopied = false);
}
}
private async Task<bool> CopyToClipboard(string text)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
return true;
}
catch (Exception)
{
return false;
}
}
private async Task ResetAfterDelay(Action reset)
{
try
{
await Task.Delay(1500, _cts.Token);
}
catch (TaskCanceledException)
{
return;
}
reset();
StateHasChanged();
}
public void Dispose() => _cts.Cancel();
}
@@ -0,0 +1,213 @@
@{
var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey);
var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null;
var hasArt = !string.IsNullOrEmpty(TrackModel?.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?.ImagePath))
{
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.ImagePath)');">
</div>
}
else
{
<div class="deepdrft-track-card-fallback"></div>
}
</a>
}
else if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
{
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.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?.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?.Artist
</MudText>
</div>
}
<div class="deepdrft-track-info-middle">
@if (!string.IsNullOrEmpty(TrackModel?.Album))
{
<MudText Typo="Typo.caption"
Class="deepdrft-track-meta text-truncate">
@TrackModel.Album
</MudText>
}
@if (!string.IsNullOrEmpty(TrackModel?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Genre
</MudChip>
}
</div>
<div class="deepdrft-track-info-bottom">
@if (TrackModel?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption"
Class="deepdrft-track-meta">
@TrackModel.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?.ImagePath))
{
<div class="deepdrft-track-row-thumb"
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.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?.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?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Genre
</MudChip>
}
@if (TrackModel?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
@TrackModel.ReleaseDate.Value.Year
</MudText>
}
</div>
</a>
}
else
{
@* same structure without anchor *@
@if (!string.IsNullOrEmpty(TrackModel?.ImagePath))
{
<div class="deepdrft-track-row-thumb"
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.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?.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?.Genre))
{
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Outlined"
Color="Color.Tertiary"
Class="deepdrft-genre-chip">
@TrackModel.Genre
</MudChip>
}
@if (TrackModel?.ReleaseDate.HasValue == true)
{
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
@TrackModel.ReleaseDate.Value.Year
</MudText>
}
</div>
}
</div>
}
@@ -0,0 +1,34 @@
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);
}
}
}
@@ -0,0 +1,197 @@
/* 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);
}
@@ -0,0 +1,36 @@
@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>
}
@@ -0,0 +1,26 @@
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;
}
@@ -0,0 +1,15 @@
.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%;
}
@@ -21,10 +21,4 @@
<ProjectReference Include="..\DeepDrftShared.Client\DeepDrftShared.Client.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Layout\NavMenu.razor">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,14 @@
using MudBlazor;
namespace DeepDrftPublic.Client.Helpers;
/// <summary>
/// Single source of truth for mapping playback state to a transport glyph across
/// DeepDrftPublic.Client. Surfaces that render a play/pause icon call <see cref="Resolve"/>
/// instead of inlining their own ternary.
/// </summary>
public static class PlaybackIcons
{
public static string Resolve(bool isPlaying, bool isPaused)
=> (isPlaying && !isPaused) ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
}
@@ -0,0 +1,8 @@
<footer class="deepdrft-footer">
<div class="deepdrft-footer-logo d-none d-sm-inline">Deep <span>DRFT</span></div>
<ul class="deepdrft-footer-links">
<li><a href="#">About</a></li>
<li><a href="#">Contact</a></li>
</ul>
<div class="deepdrft-footer-copy">© 2026 Deep DRFT</div>
</footer>
@@ -0,0 +1,66 @@
.deepdrft-footer {
background: var(--deepdrft-white);
border-top: 1px solid var(--deepdrft-border);
padding: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
}
.deepdrft-footer-logo {
font-family: var(--deepdrft-font-display);
font-size: 1.5rem;
font-weight: 400;
color: var(--deepdrft-navy);
}
.deepdrft-footer-logo span {
/*font-style: italic;*/
color: var(--deepdrft-green);
}
.deepdrft-footer-links {
display: flex;
gap: 2rem;
list-style: none;
margin: 0;
padding: 0;
}
.deepdrft-footer-links a {
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--deepdrft-muted);
text-decoration: none;
transition: color 0.2s;
}
.deepdrft-footer-links a:hover { color: var(--deepdrft-navy); }
.deepdrft-footer-copy {
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: var(--deepdrft-muted);
}
@media (max-width: 440px) {
.deepdrft-footer {
padding: 1.5rem;
/*flex-wrap: wrap;*/
gap: 1rem;
}
.deepdrft-footer-links {
flex-direction: column;
gap: 0.25rem;
}
.deepdrft-footer-copy {
/*width: 100%;*/
justify-self: right;
}
}

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