80 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
59 changed files with 2474 additions and 334 deletions
+210 -3
View File
@@ -6,6 +6,212 @@ 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) ## 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). **Status:** All six slices landed on 2026-06-07 (branches `home-mobile-grid`, `home-mobile-hero`, `home-mobile-cta`, merged to dev).
@@ -459,7 +665,7 @@ Eliminated `!important` declarations from track card CSS by replacing MudBlazor
## Track Detail Page (/track/{entryKey}) ## Track Detail Page (/track/{entryKey})
**Status:** Landed on 2026-06-06 (branch `track-detail-page`, merged to dev). **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. 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.
@@ -476,7 +682,7 @@ A focused, editorial single-track view in `DeepDrftPublic.Client`. The track gal
**UI layout:** **UI layout:**
1. Subtle back-link `← All tracks` to `/tracks`, muted low-emphasis text affordance. 1. Subtle back-link `← All tracks` to `/tracks`, muted low-emphasis text affordance.
2. Large square cover art block — placeholder themed `MudPaper` with `Album` glyph when cover unavailable (default state pending 2.1 image-vault wiring); will display `<img src>` once 2.1 lands. 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. 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). 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. 5. `MudDivider` separator.
@@ -487,6 +693,7 @@ A focused, editorial single-track view in `DeepDrftPublic.Client`. The track gal
**CSS classes (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` §14):** **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-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` — 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-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-meta` — metadata block rhythm, small-caps muted labels.
- `deepdrft-track-detail-back` — back-link affordance, muted color, hover treatment. - `deepdrft-track-detail-back` — back-link affordance, muted color, hover treatment.
@@ -499,7 +706,7 @@ A focused, editorial single-track view in `DeepDrftPublic.Client`. The track gal
- Render mode `InteractiveWebAssembly` (server prerender → WASM hydrate) mirrors `TracksView` consistency. - 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. - `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. - Play button implements the same `PlayerService.StateChanged` subscription pattern as `TracksView` — mandatory for label coherence when the dock bar drives state.
- Cover-art default (placeholder) is intentional and designed to be the resting state, not degraded; the page ships immediately without waiting for 2.1 image-vault wiring. Once 2.1 lands and `api/image/{entryKey}` exists on `DeepDrftContent`, the `<img src>` binding swaps in without further component changes (the placeholder's `onerror` fallback ensures graceful degradation if a vault entry is missing). - 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. - 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.
--- ---
+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 ## 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) ## 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`. - **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID. - **Route parameter `id`** (long): the SQL track ID.
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`. - **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 clear optional fields), and persists the DTO via `ITrackService.Update`. - 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. - 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 ## ApiKey middleware behaviour
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata. `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. 2. Await `FileDatabase.FromAsync(VaultPath)` to load or create the database.
3. Register `FileDatabase` as singleton. 3. Register `FileDatabase` as singleton.
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing). 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): **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);
}
}
@@ -72,6 +72,30 @@ public class TrackController : ControllerBase
return Ok(result.Value); 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]) // GET api/track/waveform-status ([ApiKeyAuthorize])
// Admin backfill view: returns every track with a flag for whether a waveform profile is // 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 // stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
@@ -285,6 +309,10 @@ public class TrackController : ControllerBase
track.Genre = request.Genre; track.Genre = request.Genre;
track.ReleaseDate = request.ReleaseDate; 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); var update = await _sqlTrackService.Update(track);
if (!update.Success) if (!update.Success)
{ {
@@ -4,9 +4,15 @@ namespace DeepDrftAPI.Models;
/// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never /// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never
/// travels over this surface. /// travels over this surface.
/// </summary> /// </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( public record UpdateTrackMetadataRequest(
string TrackName, string TrackName,
string Artist, string Artist,
string? Album, string? Album,
string? Genre, string? Genre,
DateOnly? ReleaseDate); DateOnly? ReleaseDate,
string? ImagePath = null);
+12
View File
@@ -19,6 +19,9 @@ namespace DeepDrftAPI
builder.Services.AddSingleton<AudioProcessor>(); builder.Services.AddSingleton<AudioProcessor>();
builder.Services.AddSingleton<TrackContentService>(); builder.Services.AddSingleton<TrackContentService>();
// Image services
builder.Services.AddSingleton<ImageProcessor>();
// Waveform loudness profiling (upload-time, off the playback path) // Waveform loudness profiling (upload-time, off the playback path)
builder.Services.Configure<WaveformProfileOptions>( builder.Services.Configure<WaveformProfileOptions>(
builder.Configuration.GetSection(nameof(WaveformProfileOptions))); builder.Configuration.GetSection(nameof(WaveformProfileOptions)));
@@ -38,6 +41,7 @@ namespace DeepDrftAPI
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult(); var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
if (db is null) throw new Exception("Unable to initialize file database"); if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult(); InitializeTrackVault(db).GetAwaiter().GetResult();
InitializeImageVault(db).GetAwaiter().GetResult();
return db; return db;
}); });
@@ -51,5 +55,13 @@ namespace DeepDrftAPI
await fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio); 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. 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) ## Content-side TrackService (orchestrator)
### AddTrackFromWavAsync(filePath) ### AddTrackFromWavAsync(filePath)
@@ -124,7 +134,7 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
## Vault constants ## 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 ## Service registration
@@ -14,4 +14,10 @@ public static class VaultConstants
/// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey. /// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey.
/// </summary> /// </summary>
public const string WaveformProfiles = "waveform-profiles"; 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";
} }
@@ -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;
}
+6
View File
@@ -13,6 +13,12 @@ public interface ITrackService
{ {
Task<ResultContainer<TrackDto?>> GetById(long id); Task<ResultContainer<TrackDto?>> GetById(long id);
Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey); 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<List<TrackDto>>> GetAll();
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default); Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack); Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
@@ -25,6 +25,25 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey) public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
=> await _context.Tracks.FirstOrDefaultAsync(t => t.EntryKey == 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) protected override void UpdateEntity(TrackEntity target, TrackEntity source)
{ {
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
+16
View File
@@ -62,6 +62,22 @@ public class TrackManager
} }
} }
// 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() public async Task<ResultContainer<List<TrackDto>>> GetAll()
{ {
try try
@@ -1,7 +1,9 @@
@page "/tracks/{Id:long}" @page "/tracks/{Id:long}"
@using DeepDrftManager.Services @using DeepDrftManager.Services
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize] @attribute [Authorize]
@inject ICmsTrackService CmsTrackService @inject ICmsTrackService CmsTrackService
@inject IHttpClientFactory HttpClientFactory
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@inject NavigationManager Nav @inject NavigationManager Nav
@@ -60,6 +62,48 @@
Label="Genre" Label="Genre"
Variant="Variant.Outlined" /> 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" <MudDatePicker @bind-Date="_form.ReleaseDate"
Label="Release Date" Label="Release Date"
DateFormat="yyyy-MM-dd" DateFormat="yyyy-MM-dd"
@@ -94,11 +138,25 @@
private TrackEditForm _form = new(); private TrackEditForm _form = new();
private bool _loading = true; private bool _loading = true;
private bool _busy; private bool _busy;
private IBrowserFile? _selectedImageFile;
private bool CanSave => private bool CanSave =>
!string.IsNullOrWhiteSpace(_form.TrackName) !string.IsNullOrWhiteSpace(_form.TrackName)
&& !string.IsNullOrWhiteSpace(_form.Artist); && !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() protected override async Task OnInitializedAsync()
{ {
await LoadAsync(); await LoadAsync();
@@ -123,14 +181,33 @@
_busy = true; _busy = true;
try try
{ {
// Metadata-only update over HTTP — EntryKey is immutable and not sent. The Content // Upload any newly picked cover art first; abort the save if it fails so we never
// API loads the authoritative row and applies these fields. // 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 releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
var updated = await CmsTrackService.UpdateAsync( var updated = await CmsTrackService.UpdateAsync(
Id, _form.TrackName, _form.Artist, Id, _form.TrackName, _form.Artist,
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album, string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre, string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
releaseDate); releaseDate,
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath);
if (updated.Success) if (updated.Success)
{ {
Snackbar.Add("Track updated.", Severity.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() private async Task ConfirmDelete()
{ {
if (_track is null) return; if (_track is null) return;
@@ -197,6 +285,7 @@
public string Artist { get; set; } = string.Empty; public string Artist { get; set; } = string.Empty;
public string? Album { get; set; } public string? Album { get; set; }
public string? Genre { get; set; } public string? Genre { get; set; }
public string? ImagePath { get; set; }
public DateTime? ReleaseDate { get; set; } public DateTime? ReleaseDate { get; set; }
public static TrackEditForm From(TrackDto track) => new() public static TrackEditForm From(TrackDto track) => new()
@@ -205,6 +294,7 @@
Artist = track.Artist, Artist = track.Artist,
Album = track.Album, Album = track.Album,
Genre = track.Genre, Genre = track.Genre,
ImagePath = track.ImagePath,
ReleaseDate = track.ReleaseDate is { } d ReleaseDate = track.ReleaseDate is { } d
? d.ToDateTime(TimeOnly.MinValue) ? d.ToDateTime(TimeOnly.MinValue)
: null : null
@@ -241,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( public async Task<Result> UpdateAsync(
long id, string trackName, string artist, long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate, string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var client = _httpClientFactory.CreateClient(ContentCmsClientName); var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -254,6 +332,7 @@ public class CmsTrackService : ICmsTrackService
album, album,
genre, genre,
releaseDate, releaseDate,
imagePath,
}; };
HttpResponseMessage response; HttpResponseMessage response;
+13 -1
View File
@@ -50,13 +50,25 @@ public interface ICmsTrackService
/// </summary> /// </summary>
Task<ResultContainer<TrackDto?>> GetByIdAsync(long id, CancellationToken ct = default); 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> /// <summary>
/// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and /// 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> /// </summary>
Task<Result> UpdateAsync( Task<Result> UpdateAsync(
long id, string trackName, string artist, long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate, string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary> /// <summary>
+2 -1
View File
@@ -20,6 +20,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). - `AudioPlayerBar.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/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/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. - `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
- `Helpers/`: Utilities and mapper functions. - `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. - `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.
@@ -73,7 +74,7 @@ Both are configured with JSON serializer settings (case-insensitive property mat
### Component integration ### 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. - `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. - `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 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). - `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).
@@ -51,6 +51,32 @@ public class TrackClient
: ApiResult<PagedResult<TrackDto>>.CreateFailResult("Failed to deserialize response"); : 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) public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
{ {
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}"); var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
@@ -1,10 +1,7 @@
@if (_isMinimized) @if (_isMinimized)
{ {
<div class="minimized-dock"> <div class="minimized-dock">
<MudFab Color="Color.Primary" <LevelMeterFab OnClick="@ToggleMinimized" />
StartIcon="@Icons.Material.Filled.MusicNote"
Size="Size.Large"
OnClick="@ToggleMinimized"/>
</div> </div>
} }
else else
@@ -115,13 +115,22 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
} }
} }
private async Task Expand() 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) if (_isMinimized == value) return;
{
_isMinimized = false; _isMinimized = value;
StateHasChanged(); if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(value);
} StateHasChanged();
} }
private async Task TogglePlayPause() private async Task TogglePlayPause()
@@ -176,12 +185,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
PlayerService?.ClearError(); PlayerService?.ClearError();
} }
private async Task ToggleMinimized() private async Task ToggleMinimized() => await SetMinimized(!_isMinimized);
{
_isMinimized = !_isMinimized;
if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(_isMinimized);
StateHasChanged();
}
private async Task Close() private async Task Close()
{ {
@@ -190,11 +194,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
await PlayerService.Unload(); await PlayerService.Unload();
} }
if (!_isMinimized) await SetMinimized(true);
{
_isMinimized = true;
StateHasChanged();
}
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -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;
}
@@ -26,7 +26,15 @@
min-width: 4px; min-width: 4px;
height: var(--bar-height, 2%); height: var(--bar-height, 2%);
min-height: 2px; min-height: 2px;
background: var(--mud-palette-primary); 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; border-radius: 2px 2px 0 0;
transition: height 0.05s ease-out; transition: height 0.05s ease-out;
} }
@@ -44,5 +52,6 @@
.spectrum-bar { .spectrum-bar {
max-width: 8px; max-width: 8px;
min-width: 3px; min-width: 3px;
background-size: 100% 32px;
} }
} }
@@ -183,12 +183,20 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable
{ {
if (!CanSeek) return; if (!CanSeek) return;
// Set seeking state BEFORE the async capturePointer so a fast mobile tap that fires
// pointerup before capturePointer returns doesn't miss the commit.
_isSeeking = true; _isSeeking = true;
_seekFraction = FractionFromOffset(e.OffsetX); _seekFraction = FractionFromOffset(e.OffsetX);
// Capture the pointer so a drag that leaves the element keeps tracking until release. // 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) if (_jsModule is not null)
{ {
try try
@@ -197,13 +205,9 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable
} }
catch catch
{ {
// Capture is a UX nicety; if it fails the gesture still works within the element bounds. // Capture is a UX nicety; gesture still works within element bounds.
} }
} }
if (Duration is not > 0) { _isSeeking = false; return; }
await OnSeekStart.InvokeAsync();
await OnSeekChange.InvokeAsync(_seekFraction * Duration.Value);
} }
private async Task HandlePointerMove(PointerEventArgs e) private async Task HandlePointerMove(PointerEventArgs e)
@@ -0,0 +1,7 @@
namespace DeepDrftPublic.Client.Controls;
public enum GalleryViewMode
{
Grid,
List
}
@@ -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>
}
@@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Components;
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Components;
using MudBlazor; using MudBlazor;
namespace DeepDrftShared.Client.Components; namespace DeepDrftPublic.Client.Controls;
public partial class TrackCard : ComponentBase public partial class TrackCard : ComponentBase
{ {
@@ -11,6 +11,7 @@ public partial class TrackCard : ComponentBase
[Parameter] public EventCallback<TrackDto> OnPause { get; set; } [Parameter] public EventCallback<TrackDto> OnPause { get; set; }
[Parameter] public bool IsPlaying { get; set; } = false; [Parameter] public bool IsPlaying { get; set; } = false;
[Parameter] public bool IsPaused { 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". // Pause only when actively playing; every other state (idle, paused) reads as "press to play".
private bool IsActivelyPlaying => IsPlaying && !IsPaused; private bool IsActivelyPlaying => IsPlaying && !IsPaused;
@@ -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>
}
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Components;
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Components;
namespace DeepDrftShared.Client.Components; namespace DeepDrftPublic.Client.Controls;
public partial class TracksGallery : ComponentBase public partial class TracksGallery : ComponentBase
{ {
@@ -13,6 +13,7 @@ public partial class TracksGallery : ComponentBase
[Parameter] public TrackDto? ActiveTrack { get; set; } [Parameter] public TrackDto? ActiveTrack { get; set; }
[Parameter] public bool IsPlaying { get; set; } [Parameter] public bool IsPlaying { get; set; }
[Parameter] public bool IsPaused { 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> OnPlay { get; set; }
[Parameter] public EventCallback<TrackDto> OnPause { get; set; } [Parameter] public EventCallback<TrackDto> OnPause { get; set; }
@@ -6,3 +6,10 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.deepdrft-track-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
+144 -2
View File
@@ -16,7 +16,20 @@
</ul> </ul>
<div class="dd-nav-actions"> <div class="dd-nav-actions">
<a href="/tracks" class="dd-nav-cta">Stream Now &#9654;</a> <button type="button"
class="dd-nav-cta"
disabled="@(_streamLoading || !RendererInfo.IsInteractive)"
aria-busy="@_streamLoading.ToString().ToLowerInvariant()"
@onclick="StreamNow">
@if (_findingTrack)
{
<span>Finding a track&#8230;</span>
}
else
{
<span>Stream Now &#9654;</span>
}
</button>
@* <button type="button" *@ @* <button type="button" *@
@* class="dd-nav-toggle" *@ @* class="dd-nav-toggle" *@
@* aria-label="Toggle dark mode" *@ @* aria-label="Toggle dark mode" *@
@@ -25,6 +38,11 @@
@* @((MarkupString)DarkLightModeIconSvg) *@ @* @((MarkupString)DarkLightModeIconSvg) *@
@* </button> *@ @* </button> *@
</div> </div>
@if (_streamMessage is not null)
{
<p class="dd-nav-stream-message" role="status">@_streamMessage</p>
}
</nav> </nav>
</div> </div>
@@ -60,15 +78,37 @@
</li> </li>
} }
<li> <li>
<a href="/tracks" class="dd-nav-cta" @onclick="CloseMobileMenu">Stream Now &#9654;</a> <button type="button"
class="dd-nav-cta"
disabled="@(_streamLoading || !RendererInfo.IsInteractive)"
aria-busy="@_streamLoading.ToString().ToLowerInvariant()"
@onclick="StreamNowMobile">
@if (_findingTrack)
{
<span>Finding a track&#8230;</span>
}
else
{
<span>Stream Now &#9654;</span>
}
</button>
</li> </li>
</ul> </ul>
} }
@if (_streamMessage is not null)
{
<p class="dd-nav-stream-message" role="status">@_streamMessage</p>
}
</nav> </nav>
</div> </div>
@implements IDisposable
@code { @code {
[Inject] public required DarkModeCookieService DarkModeCookieService { get; set; } [Inject] public required DarkModeCookieService DarkModeCookieService { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// Elevation is vestigial under the frosted-glass design but kept on the parameter // Elevation is vestigial under the frosted-glass design but kept on the parameter
// surface so MainLayout's <DeepDrftMenu Elevation="..."> call site stays intact. // surface so MainLayout's <DeepDrftMenu Elevation="..."> call site stays intact.
@@ -77,6 +117,108 @@
[Parameter] public required EventCallback<bool> IsDarkModeChanged { get; set; } [Parameter] public required EventCallback<bool> IsDarkModeChanged { get; set; }
private bool _mobileMenuOpen; private bool _mobileMenuOpen;
private bool _streamLoading;
private bool _findingTrack;
private string? _streamMessage;
private CancellationTokenSource? _messageCts;
private const string EmptyLibraryMessage = "No tracks yet — check back soon.";
private const string FetchFailedMessage = "Couldn't reach the library — try again.";
private Task StreamNow() => StreamNowCore(closeMobileMenu: false);
private Task StreamNowMobile() => StreamNowCore(closeMobileMenu: true);
private async Task StreamNowCore(bool closeMobileMenu)
{
// Re-entrancy guard: the button is disabled while loading, but guard in code too so a
// double-dispatch can never start two concurrent streams.
if (_streamLoading) return;
_streamLoading = true;
_findingTrack = true;
_streamMessage = null;
// Warm the AudioContext FIRST, inside the gesture's call stack and before the network
// await below. Safari only lets a suspended AudioContext resume while the originating
// user gesture is still active; awaiting GetRandomTrack() first would consume the gesture
// and leave playback silently refused. PlayerService is null only outside the
// AudioPlayerProvider cascade (it should always be present in the public layout).
var warmTask = PlayerService?.WarmAudioContext() ?? Task.CompletedTask;
try
{
await warmTask;
var result = await TrackData.GetRandomTrack();
if (!result.Success)
{
ShowTransientMessage(FetchFailedMessage);
return;
}
if (result.Value is not { } track)
{
ShowTransientMessage(EmptyLibraryMessage);
return;
}
if (closeMobileMenu)
_mobileMenuOpen = false;
// Track is found — flip only the label flag so the button reverts to
// "Stream Now ▶" before the stream begins, while _streamLoading stays true
// to keep the button disabled and the re-entrancy guard intact.
_findingTrack = false;
StateHasChanged();
if (PlayerService is not null)
await PlayerService.SelectTrackStreaming(track);
}
catch (Exception)
{
ShowTransientMessage(FetchFailedMessage);
}
finally
{
_streamLoading = false;
_findingTrack = false;
}
}
private void ShowTransientMessage(string message)
{
_streamMessage = message;
// Cancel any in-flight clear timer so the newest message gets its full display window.
_messageCts?.Cancel();
_messageCts?.Dispose();
_messageCts = new CancellationTokenSource();
var token = _messageCts.Token;
_ = ClearMessageAfterDelayAsync(token);
}
private async Task ClearMessageAfterDelayAsync(CancellationToken token)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(4), token);
}
catch (TaskCanceledException)
{
return;
}
_streamMessage = null;
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
_messageCts?.Cancel();
_messageCts?.Dispose();
}
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
@@ -7,7 +7,7 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable @implements IDisposable
<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" /> <MudThemeProvider Theme="@DeepDrftPalettes.Embed" IsDarkMode="_isDarkMode" />
<div class="@ThemeWrapperClass"> <div class="@ThemeWrapperClass">
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh"> <MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
<AudioPlayerProvider> <AudioPlayerProvider>
@@ -5,6 +5,7 @@
@page "/FramePlayer" @page "/FramePlayer"
@layout EmbedLayout @layout EmbedLayout
@rendermode InteractiveWebAssembly
<AudioPlayerBar Fixed /> <AudioPlayerBar Fixed />
+14 -7
View File
@@ -57,18 +57,25 @@ else if (ViewModel.Track is not null)
<MudText Typo="Typo.h6" Color="Color.Primary">@track.Artist</MudText> <MudText Typo="Typo.h6" Color="Color.Primary">@track.Artist</MudText>
</div> </div>
<div> <MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<SharePopover EntryKey="@track.EntryKey" />
<PlayStateIcon Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack"/> <PlayStateIcon Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack"/>
</div> </MudStack>
</MudStack> </MudStack>
<div class="deepdrft-track-detail-cover"> <div class="deepdrft-track-detail-cover">
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary"> @if (!string.IsNullOrEmpty(track.ImagePath))
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" /> {
</MudPaper> <MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(track.ImagePath)}');")" />
}
else
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</MudPaper>
}
</div> </div>
@if (hasMeta) @if (hasMeta)
{ {
@@ -1,4 +1,5 @@
@page "/tracks" @page "/tracks"
@using DeepDrftPublic.Client.Controls
<PageTitle>DeepDrft Track Gallery</PageTitle> <PageTitle>DeepDrft Track Gallery</PageTitle>
@@ -6,8 +7,19 @@
<div class="tracks-view-container"> <div class="tracks-view-container">
@if (ViewModel.Page != null) @if (ViewModel.Page != null)
{ {
<div class="tracks-view-header">
<MudToggleGroup T="GalleryViewMode" @bind-Value="_viewMode" Disabled="@(!RendererInfo.IsInteractive)" Class="tracks-view-toggle">
<MudToggleItem Value="GalleryViewMode.Grid">
<MudIcon Icon="@Icons.Material.Filled.ViewModule"/>
</MudToggleItem>
<MudToggleItem Value="GalleryViewMode.List">
<MudIcon Icon="@Icons.Material.Filled.ViewList"/>
</MudToggleItem>
</MudToggleGroup>
</div>
<div class="tracks-content"> <div class="tracks-content">
<TracksGallery Tracks="@ViewModel.Page.Items" <TracksGallery Tracks="@ViewModel.Page.Items"
ViewMode="@_viewMode"
ActiveTrack="@PlayerService.CurrentTrack" ActiveTrack="@PlayerService.CurrentTrack"
IsPlaying="@PlayerService.IsPlaying" IsPlaying="@PlayerService.IsPlaying"
IsPaused="@PlayerService.IsPaused" IsPaused="@PlayerService.IsPaused"
@@ -18,6 +30,7 @@
<MudPagination Count="@ViewModel.Page.TotalPages" <MudPagination Count="@ViewModel.Page.TotalPages"
Selected="@ViewModel.Page.Page" Selected="@ViewModel.Page.Page"
SelectedChanged="@SetPage" SelectedChanged="@SetPage"
Disabled="@(!RendererInfo.IsInteractive)"
BoundaryCount="2" BoundaryCount="2"
MiddleCount="3"/> MiddleCount="3"/>
</div> </div>
@@ -1,4 +1,5 @@
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Controls;
using DeepDrftPublic.Client.Services; using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels; using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -17,6 +18,9 @@ public partial class TracksView : ComponentBase, IDisposable
private IStreamingPlayerService? _subscribedService; private IStreamingPlayerService? _subscribedService;
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
// Ephemeral view-mode selection — presentation-only, not persisted across navigation.
private GalleryViewMode _viewMode = GalleryViewMode.Grid;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// Carry the prerendered page across the prerender -> interactive (WASM) seam. // Carry the prerendered page across the prerender -> interactive (WASM) seam.
@@ -18,3 +18,9 @@
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.tracks-view-header {
display: flex;
justify-content: flex-end;
padding: 0 0 12px 0;
}
@@ -243,6 +243,35 @@ public class AudioInteropService : IAsyncDisposable
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId); return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopSpectrumAnimation", playerId, callbackId);
} }
public async Task<AudioOperationResult> StartLevelAnimationAsync(string playerId, string callbackId, Func<double, Task> callback)
{
try
{
var callbackWrapper = new LevelCallback { OnData = callback };
var dotNetObjectRef = DotNetObjectReference.Create(callbackWrapper);
_callbacks[playerId + "_level_" + callbackId] = dotNetObjectRef;
return await _jsRuntime.InvokeAsync<AudioOperationResult>(
"DeepDrftAudio.startLevelAnimation",
playerId, callbackId, dotNetObjectRef, "OnLevelDataCallback");
}
catch (Exception ex)
{
return new AudioOperationResult { Success = false, Error = ex.Message };
}
}
public async Task<AudioOperationResult> StopLevelAnimationAsync(string playerId, string callbackId)
{
var key = playerId + "_level_" + callbackId;
if (_callbacks.TryGetValue(key, out var callback))
{
callback?.Dispose();
_callbacks.Remove(key);
}
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.stopLevelAnimation", playerId, callbackId);
}
public async Task<AudioOperationResult> DisposePlayerAsync(string playerId) public async Task<AudioOperationResult> DisposePlayerAsync(string playerId)
{ {
CleanupPlayerCallbacks(playerId); CleanupPlayerCallbacks(playerId);
@@ -341,6 +370,18 @@ public class SpectrumCallback
} }
} }
public class LevelCallback
{
public Func<double, Task>? OnData { get; set; }
[JSInvokable]
public async Task OnLevelDataCallback(double db)
{
if (OnData != null)
await OnData(db);
}
}
public class AudioOperationResult public class AudioOperationResult
{ {
public bool Success { get; set; } public bool Success { get; set; }
@@ -63,6 +63,17 @@ public interface IStreamingPlayerService : IPlayerService
// Streaming control methods // Streaming control methods
Task SelectTrackStreaming(TrackDto track); Task SelectTrackStreaming(TrackDto track);
/// <summary>
/// Initializes the player (if needed) and resumes the AudioContext. Call this synchronously at
/// the very start of a user-gesture handler — before any <c>await</c> on network I/O — so the
/// gesture is still "active" when the context resumes. Safari refuses to start a suspended
/// AudioContext once the originating gesture has been consumed by an intervening await
/// (e.g. fetching which track to play), so warming here and streaming after is load-bearing.
/// <see cref="SelectTrackStreaming"/> also resumes the context, but only after its own internal
/// awaits — too late for a handler that must first fetch the track to play.
/// </summary>
Task WarmAudioContext();
/// <summary> /// <summary>
/// Stages a track as the current track without touching the audio context or starting the /// Stages a track as the current track without touching the audio context or starting the
/// stream. Used by the embed player, where there is no user gesture on initial load: the track /// stream. Used by the embed player, where there is no user gesture on initial load: the track
@@ -5,11 +5,10 @@ using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services; namespace DeepDrftPublic.Client.Services;
/// <summary> /// <summary>
/// Track metadata fetch abstraction. Both SSR and WASM passes are served by /// Track metadata fetch abstraction. Both SSR and WASM renders are served by
/// <c>TrackClientDataService</c> in this assembly, which delegates to /// <c>TrackClientDataService</c> in this assembly, which delegates to
/// <see cref="Clients.TrackClient"/> over HTTP. /// <see cref="Clients.TrackClient"/> over HTTP. Components inject this single seam
/// /// so they do not branch on render mode.
/// Components inject this single seam so they do not branch on render mode.
/// </summary> /// </summary>
public interface ITrackDataService public interface ITrackDataService
{ {
@@ -20,4 +19,11 @@ public interface ITrackDataService
bool sortDescending = false); bool sortDescending = false);
Task<ApiResult<TrackDto>> GetTrack(string trackId); Task<ApiResult<TrackDto>> GetTrack(string trackId);
/// <summary>
/// Fetches a random track from the public library for instant play. Success with a value on a
/// hit; success with a null value when the library is empty (a valid state, not a failure);
/// failure on any other transport error.
/// </summary>
Task<ApiResult<TrackDto?>> GetRandomTrack();
} }
@@ -46,6 +46,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
await SelectTrackStreaming(track); await SelectTrackStreaming(track);
} }
/// <inheritdoc />
public async Task WarmAudioContext()
{
await EnsureInitializedAsync();
await _audioInterop.EnsureAudioContextReady(PlayerId);
}
public async Task SelectTrackStreaming(TrackDto track) public async Task SelectTrackStreaming(TrackDto track)
{ {
await EnsureInitializedAsync(); await EnsureInitializedAsync();
@@ -28,4 +28,7 @@ public class TrackClientDataService : ITrackDataService
public Task<ApiResult<TrackDto>> GetTrack(string trackId) public Task<ApiResult<TrackDto>> GetTrack(string trackId)
=> _trackClient.GetTrack(trackId); => _trackClient.GetTrack(trackId);
public Task<ApiResult<TrackDto?>> GetRandomTrack()
=> _trackClient.GetRandom();
} }
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftPublic.Controllers;
/// <summary>
/// Proxies public image API calls to DeepDrftAPI so the browser never makes
/// cross-origin requests. Mirrors <see cref="TrackProxyController"/>: the WASM
/// client issues relative <c>api/image/{entryKey}</c> requests against this host,
/// which forwards them upstream. SSR prerender calls DeepDrftAPI directly via the
/// same named client — no proxy hop needed on the server side.
/// </summary>
[ApiController]
[Route("api/image")]
public class ImageProxyController : ControllerBase
{
private readonly HttpClient _upstream;
private readonly ILogger<ImageProxyController> _logger;
public ImageProxyController(IHttpClientFactory httpClientFactory, ILogger<ImageProxyController> logger)
{
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
_logger = logger;
}
/// <summary>Proxies image binary streaming by vault entry key from DeepDrftAPI.</summary>
[HttpGet("{entryKey}")]
public async Task<ActionResult> GetImage(string entryKey, CancellationToken ct = default)
{
_logger.LogInformation("Proxying image {EntryKey}", entryKey);
var path = $"api/image/{Uri.EscapeDataString(entryKey)}";
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI image/{EntryKey} failed", entryKey);
return StatusCode(502, "Upstream unavailable");
}
if (!upstream.IsSuccessStatusCode)
{
upstream.Dispose();
_logger.LogWarning("DeepDrftAPI image/{EntryKey} returned {Status}", entryKey, (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
// Do NOT dispose upstream here — File() takes ownership of the response stream
// and disposes it after the body is sent.
var contentType = upstream.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
var contentLength = upstream.Content.Headers.ContentLength;
if (contentLength.HasValue)
Response.ContentLength = contentLength.Value;
var stream = await upstream.Content.ReadAsStreamAsync(ct);
HttpContext.Response.RegisterForDispose(upstream);
return File(stream, contentType, enableRangeProcessing: false);
}
}
@@ -59,6 +59,39 @@ public class TrackProxyController : ControllerBase
} }
} }
/// <summary>
/// Proxies the random-track metadata lookup from DeepDrftAPI. Unauthenticated, same posture as
/// the paged listing. Small JSON, buffered and relayed; a 404 from upstream (empty library)
/// passes through so the client renders it as a valid empty state. Declared before the
/// parameterized "{trackId}" route so the literal segment is never treated as a trackId.
/// </summary>
[HttpGet("random")]
public async Task<ActionResult> GetRandom(CancellationToken ct = default)
{
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync("api/track/random", HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/random failed");
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/random returned {Status}", (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
/// <summary> /// <summary>
/// Proxies single-track metadata lookup by vault entry key from DeepDrftAPI. Unauthenticated, /// Proxies single-track metadata lookup by vault entry key from DeepDrftAPI. Unauthenticated,
/// same posture as the paged listing. Small JSON, so it is buffered and relayed; a 404 from /// same posture as the paged listing. Small JSON, so it is buffered and relayed; a 404 from
+29 -9
View File
@@ -140,9 +140,11 @@ export class AudioPlayer {
this.scheduler.addBuffer(result.buffer); this.scheduler.addBuffer(result.buffer);
} }
// Update duration estimate // Update duration estimate — set once only; reinitializeFromOffset does not
// reset this.duration, so after a seek-beyond-buffer the synthesised header's
// shorter DataSize cannot overwrite the original full-track duration.
const estimatedDuration = this.streamDecoder.getEstimatedDuration(); const estimatedDuration = this.streamDecoder.getEstimatedDuration();
if (estimatedDuration) { if (estimatedDuration && this.duration === 0) {
this.duration = estimatedDuration; this.duration = estimatedDuration;
} }
@@ -225,8 +227,10 @@ export class AudioPlayer {
this.isPlaying = true; this.isPlaying = true;
this.isPaused = false; this.isPaused = false;
// Resume from pause position // Resume from pause position. pausePosition is absolute track time;
this.scheduler.playFromPosition(this.pausePosition); // playFromPosition expects a buffer-relative position (excludes playbackOffset).
const bufferRelativePosition = this.pausePosition - this.scheduler.getPlaybackOffset();
this.scheduler.playFromPosition(Math.max(0, bufferRelativePosition));
this.startProgressTracking(); this.startProgressTracking();
return { success: true }; return { success: true };
@@ -274,14 +278,18 @@ export class AudioPlayer {
return { success: false, error: 'Invalid seek position' }; return { success: false, error: 'Invalid seek position' };
} }
// Get buffered duration (accounting for playback offset) const bufferStart = this.scheduler.getPlaybackOffset();
const bufferedDuration = this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset(); const bufferEnd = this.scheduler.getTotalDuration() + bufferStart;
// Check if seeking within buffered content // Position must be within [bufferStart, bufferEnd] to use buffered content.
if (position <= bufferedDuration) { // A lower-bound check is required: after a seek-beyond-buffer, bufferStart is
// set to the prior seek position. Seeking to a position below bufferStart would
// produce a negative bufferRelativePosition in seekWithinBuffer, silently
// clamping to position 0 of the offset buffer instead of the requested time.
if (position >= bufferStart && position <= bufferEnd) {
return this.seekWithinBuffer(position); return this.seekWithinBuffer(position);
} else { } else {
// Seeking beyond buffer - signal C# to fetch new stream // Seeking outside buffered window - signal C# to fetch new stream
return this.seekBeyondBuffer(position); return this.seekBeyondBuffer(position);
} }
} }
@@ -455,6 +463,18 @@ export class AudioPlayer {
this.contextManager.getSpectrumAnalyzer().removeCallback(callbackId); this.contextManager.getSpectrumAnalyzer().removeCallback(callbackId);
} }
getLevelDb(): number {
return this.contextManager.getSpectrumAnalyzer().getLevelDb();
}
startLevelAnimation(callbackId: string, callback: (db: number) => void): void {
this.contextManager.getSpectrumAnalyzer().addLevelCallback(callbackId, callback);
}
stopLevelAnimation(callbackId: string): void {
this.contextManager.getSpectrumAnalyzer().removeLevelCallback(callbackId);
}
// ==================== Private Methods ==================== // ==================== Private Methods ====================
private resetState(): void { private resetState(): void {
@@ -235,7 +235,9 @@ export class PlaybackScheduler {
const position = this.getCurrentPosition(); const position = this.getCurrentPosition();
this.isActive_ = false; // Prevent handleSourceEnded from scheduling more this.isActive_ = false; // Prevent handleSourceEnded from scheduling more
this.stopAllSources(); this.stopAllSources();
this.playbackAnchorPosition = position; // getCurrentPosition() returns absolute time (anchor + playbackOffset); the anchor
// is buffer-relative, so strip the offset back out before storing it.
this.playbackAnchorPosition = position - this.playbackOffset;
this.playbackAnchorTime = 0; this.playbackAnchorTime = 0;
this.nextScheduleTime = 0; this.nextScheduleTime = 0;
return position; return position;
@@ -16,6 +16,7 @@ export class SpectrumAnalyzer {
private audioContext: AudioContext | null = null; private audioContext: AudioContext | null = null;
private fftSize: number = 2048; private fftSize: number = 2048;
private dataArray: Float32Array<ArrayBuffer> | null = null; private dataArray: Float32Array<ArrayBuffer> | null = null;
private timeDomainArray: Float32Array<ArrayBuffer> | null = null;
// Configuration // Configuration
private bucketCount: number = 32; private bucketCount: number = 32;
@@ -26,6 +27,7 @@ export class SpectrumAnalyzer {
// Animation state - supports multiple callbacks per player // Animation state - supports multiple callbacks per player
private animationId: number | null = null; private animationId: number | null = null;
private callbacks = new Map<string, (data: number[]) => void>(); private callbacks = new Map<string, (data: number[]) => void>();
private levelCallbacks = new Map<string, (db: number) => void>();
private lastFrameTime: number = 0; private lastFrameTime: number = 0;
private targetFrameInterval: number = 1000 / 30; // ~30fps for smooth visuals without excessive interop private targetFrameInterval: number = 1000 / 30; // ~30fps for smooth visuals without excessive interop
@@ -35,6 +37,7 @@ export class SpectrumAnalyzer {
this.analyser.fftSize = this.fftSize; this.analyser.fftSize = this.fftSize;
this.analyser.smoothingTimeConstant = 0.8; this.analyser.smoothingTimeConstant = 0.8;
this.dataArray = new Float32Array(this.analyser.frequencyBinCount); this.dataArray = new Float32Array(this.analyser.frequencyBinCount);
this.timeDomainArray = new Float32Array(this.analyser.fftSize);
return this.analyser; return this.analyser;
} }
@@ -121,6 +124,22 @@ export class SpectrumAnalyzer {
return buckets; return buckets;
} }
/**
* Get the true RMS signal level in dBFS from the time-domain waveform.
* Unlike getFrequencyData (FFT peaks), this reflects the actual signal level
* and calibrates against commercial loudness targets. Returns -Infinity on silence.
*/
getLevelDb(): number {
if (!this.analyser || !this.timeDomainArray) return -Infinity;
this.analyser.getFloatTimeDomainData(this.timeDomainArray);
let sum = 0;
for (let i = 0; i < this.timeDomainArray.length; i++) {
sum += this.timeDomainArray[i] * this.timeDomainArray[i];
}
const rms = Math.sqrt(sum / this.timeDomainArray.length);
return rms > 0 ? 20 * Math.log10(rms) : -Infinity;
}
/** /**
* Apply high-pass, low-pass, and slope correction filters * Apply high-pass, low-pass, and slope correction filters
*/ */
@@ -157,7 +176,7 @@ export class SpectrumAnalyzer {
* Add a callback for spectrum data. Starts animation loop on first subscriber. * Add a callback for spectrum data. Starts animation loop on first subscriber.
*/ */
addCallback(id: string, callback: (data: number[]) => void): void { addCallback(id: string, callback: (data: number[]) => void): void {
const wasEmpty = this.callbacks.size === 0; const wasEmpty = this.callbacks.size === 0 && this.levelCallbacks.size === 0;
this.callbacks.set(id, callback); this.callbacks.set(id, callback);
if (wasEmpty) { if (wasEmpty) {
this.lastFrameTime = 0; this.lastFrameTime = 0;
@@ -170,7 +189,30 @@ export class SpectrumAnalyzer {
*/ */
removeCallback(id: string): void { removeCallback(id: string): void {
this.callbacks.delete(id); this.callbacks.delete(id);
if (this.callbacks.size === 0) { if (this.callbacks.size === 0 && this.levelCallbacks.size === 0) {
this.stopAnimation();
}
}
/**
* Add a callback for true RMS level data (dBFS). Shares the spectrum animation
* loop; starts it only if both callback maps were previously empty.
*/
addLevelCallback(id: string, callback: (db: number) => void): void {
const wasEmpty = this.callbacks.size === 0 && this.levelCallbacks.size === 0;
this.levelCallbacks.set(id, callback);
if (wasEmpty) {
this.lastFrameTime = 0;
this.animationId = requestAnimationFrame(this.animate);
}
}
/**
* Remove a level callback by ID. Stops the shared loop only when both maps are empty.
*/
removeLevelCallback(id: string): void {
this.levelCallbacks.delete(id);
if (this.callbacks.size === 0 && this.levelCallbacks.size === 0) {
this.stopAnimation(); this.stopAnimation();
} }
} }
@@ -186,16 +228,21 @@ export class SpectrumAnalyzer {
} }
private animate = (timestamp: number): void => { private animate = (timestamp: number): void => {
if (this.callbacks.size === 0) return; if (this.callbacks.size === 0 && this.levelCallbacks.size === 0) return;
// Throttle to target frame rate // Throttle to target frame rate
const elapsed = timestamp - this.lastFrameTime; const elapsed = timestamp - this.lastFrameTime;
if (elapsed >= this.targetFrameInterval) { if (elapsed >= this.targetFrameInterval) {
this.lastFrameTime = timestamp - (elapsed % this.targetFrameInterval); this.lastFrameTime = timestamp - (elapsed % this.targetFrameInterval);
const data = this.getFrequencyData();
// Broadcast to all callbacks if (this.callbacks.size > 0) {
for (const cb of this.callbacks.values()) { const data = this.getFrequencyData();
cb(data); for (const cb of this.callbacks.values()) cb(data);
}
if (this.levelCallbacks.size > 0) {
const db = this.getLevelDb();
for (const cb of this.levelCallbacks.values()) cb(db);
} }
} }
@@ -205,8 +252,10 @@ export class SpectrumAnalyzer {
dispose(): void { dispose(): void {
this.stopAnimation(); this.stopAnimation();
this.callbacks.clear(); this.callbacks.clear();
this.levelCallbacks.clear();
this.analyser = null; this.analyser = null;
this.audioContext = null; this.audioContext = null;
this.dataArray = null; this.dataArray = null;
this.timeDomainArray = null;
} }
} }
+26
View File
@@ -198,6 +198,32 @@ const DeepDrftAudio = {
return { success: true }; return { success: true };
}, },
getLevelDb: (playerId: string): number => {
const player = audioPlayers.get(playerId);
return player?.getLevelDb() ?? -Infinity;
},
startLevelAnimation: (
playerId: string,
callbackId: string,
dotNetRef: DotNetObjectReference,
methodName: string
): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
player.startLevelAnimation(callbackId, (db: number) => {
dotNetRef.invokeMethodAsync(methodName, db);
});
return { success: true };
},
stopLevelAnimation: (playerId: string, callbackId: string): AudioResult => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
player.stopLevelAnimation(callbackId);
return { success: true };
},
disposePlayer: (playerId: string): AudioResult => { disposePlayer: (playerId: string): AudioResult => {
const player = audioPlayers.get(playerId); const player = audioPlayers.get(playerId);
if (player) { if (player) {
+28
View File
@@ -24,6 +24,11 @@ Startup.ConfigureDomainServices(builder);
// Add services to the container. // Add services to the container.
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddCors(options =>
{
options.AddPolicy("FramePlayerEmbedPolicy", policy =>
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
});
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents() .AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents(); .AddInteractiveWebAssemblyComponents();
@@ -72,6 +77,29 @@ else
} }
} }
// CORS policy registered for hygiene and potential direct cross-origin API consumers.
// The FramePlayer embed use case does not require this: WASM inside a cross-site iframe
// fetches to the same deepdrft.com origin, so all API calls are same-origin.
app.UseCors("FramePlayerEmbedPolicy");
// For requests to /FramePlayer, remove any X-Frame-Options header and set a permissive
// frame-ancestors CSP so the page can be embedded in iframes on any external domain.
// OnStarting fires just before headers are flushed, ensuring this overrides headers set
// by other middleware (e.g. HSTS, reverse proxy).
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/FramePlayer", StringComparison.OrdinalIgnoreCase))
{
context.Response.OnStarting(() =>
{
context.Response.Headers.Remove("X-Frame-Options");
context.Response.Headers["Content-Security-Policy"] = "frame-ancestors *";
return Task.CompletedTask;
});
}
await next();
});
// Antiforgery is required by Blazor form handling. Authentication / authorization // Antiforgery is required by Blazor form handling. Authentication / authorization
// middleware is intentionally absent — this host is fully anonymous. // middleware is intentionally absent — this host is fully anonymous.
app.UseAntiforgery(); app.UseAntiforgery();
@@ -317,6 +317,14 @@ h2, h3, h4, h5, h6,
font-size: 72px; font-size: 72px;
} }
/* Album art fills the square frame; background-size:cover handles any aspect ratio. */
.deepdrft-track-detail-cover-art {
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.deepdrft-track-detail-masthead { .deepdrft-track-detail-masthead {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -339,6 +347,23 @@ h2, h3, h4, h5, h6,
font-family: var(--deepdrft-font-mono) !important; font-family: var(--deepdrft-font-mono) !important;
} }
.deepdrft-share-popover-body {
padding: 0.75rem 1rem;
min-width: 280px;
max-width: 360px;
}
/* Monospace snippet so the iframe markup stays legible inside the readonly field. */
.deepdrft-share-embed-field {
flex: 1 1 auto;
}
.deepdrft-share-embed-field .mud-input-slot {
font-family: var(--deepdrft-font-mono) !important;
font-size: 0.75rem;
word-break: break-all;
}
/* display:contents so the anchor wraps the card's cover and title without /* display:contents so the anchor wraps the card's cover and title without
introducing its own box the container's positioning context and the introducing its own box the container's positioning context and the
content column's flex layout are both preserved. */ content column's flex layout are both preserved. */
@@ -74,6 +74,34 @@ public static class DeepDrftPalettes
// Semantic (Info/Success/Warning/Error) intentionally left at MudBlazor defaults // Semantic (Info/Success/Warning/Error) intentionally left at MudBlazor defaults
}; };
// Embed light palette — identical to Light but Background is transparent so
// the embedding page shows through.
public static PaletteLight EmbedLight { get; } = new()
{
Primary = Light.Primary,
PrimaryDarken = Light.PrimaryDarken,
Secondary = Light.Secondary,
Tertiary = Light.Tertiary,
Background = "rgba(0,0,0,0)", // transparent
BackgroundGray = Light.BackgroundGray,
Surface = Light.Surface,
AppbarBackground = Light.AppbarBackground,
AppbarText = Light.AppbarText,
TextPrimary = Light.TextPrimary,
TextSecondary = Light.TextSecondary,
TextDisabled = Light.TextDisabled,
ActionDefault = Light.ActionDefault,
ActionDisabled = Light.ActionDisabled,
ActionDisabledBackground = Light.ActionDisabledBackground,
Divider = Light.Divider,
DividerLight = Light.DividerLight,
TableLines = Light.TableLines,
LinesDefault = Light.LinesDefault,
LinesInputs = Light.LinesInputs,
OverlayLight = Light.OverlayLight,
OverlayDark = Light.OverlayDark,
};
// Wireframe dark palette - navy ground / green-accent / off-white. // Wireframe dark palette - navy ground / green-accent / off-white.
// Mirrors the light palette's vocabulary on a dark ground; the coral/lowcountry // Mirrors the light palette's vocabulary on a dark ground; the coral/lowcountry
// identity has been retired. On dark, green-accent (#3D7A68) becomes the primary // identity has been retired. On dark, green-accent (#3D7A68) becomes the primary
@@ -109,6 +137,35 @@ public static class DeepDrftPalettes
// Semantic (Info/Success/Warning/Error) intentionally left at MudBlazor defaults // Semantic (Info/Success/Warning/Error) intentionally left at MudBlazor defaults
}; };
// Embed dark palette — identical to Dark but Background is transparent.
public static PaletteDark EmbedDark { get; } = new()
{
Primary = Dark.Primary,
PrimaryDarken = Dark.PrimaryDarken,
Secondary = Dark.Secondary,
Tertiary = Dark.Tertiary,
Background = "rgba(0,0,0,0)", // transparent
Surface = Dark.Surface,
AppbarBackground = Dark.AppbarBackground,
AppbarText = Dark.AppbarText,
DrawerBackground = Dark.DrawerBackground,
DrawerText = Dark.DrawerText,
DrawerIcon = Dark.DrawerIcon,
TextPrimary = Dark.TextPrimary,
TextSecondary = Dark.TextSecondary,
TextDisabled = Dark.TextDisabled,
ActionDefault = Dark.ActionDefault,
ActionDisabled = Dark.ActionDisabled,
ActionDisabledBackground = Dark.ActionDisabledBackground,
Divider = Dark.Divider,
DividerLight = Dark.DividerLight,
TableLines = Dark.TableLines,
LinesDefault = Dark.LinesDefault,
LinesInputs = Dark.LinesInputs,
OverlayLight = Dark.OverlayLight,
OverlayDark = Dark.OverlayDark,
};
// Shared typography - Cormorant Garamond for display, Geist Mono for mono, DM Sans for body. // Shared typography - Cormorant Garamond for display, Geist Mono for mono, DM Sans for body.
public static Typography Typography { get; } = new() public static Typography Typography { get; } = new()
{ {
@@ -148,5 +205,16 @@ public static class DeepDrftPalettes
PaletteDark = Dark, PaletteDark = Dark,
Typography = Typography, Typography = Typography,
}; };
/// <summary>
/// Embed MudTheme — identical to <see cref="Default"/> but with transparent
/// Background on both palettes so the embedding page shows through the root.
/// </summary>
public static MudTheme Embed { get; } = new()
{
PaletteLight = EmbedLight,
PaletteDark = EmbedDark,
Typography = Typography,
};
} }
#pragma warning restore CS8601 #pragma warning restore CS8601
@@ -1,109 +0,0 @@
@{
var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey);
var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null;
}
<div class="deepdrft-track-card-container">
@* 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('@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('@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"
OnClick="@PlayClick"/>
</div>
</div>
</div>
@@ -1,88 +0,0 @@
/* Container transparent so the absolute-positioned fallback panel or album art
controls the card's background. Glass edge matches NowPlayingCard vocabulary. */
.deepdrft-track-card-container {
width: 250px;
height: 250px;
min-width: 250px;
position: relative;
overflow: hidden;
background: transparent;
border: 1px solid rgba(250, 250, 248, 0.12);
}
.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;
}
}
@@ -1,16 +0,0 @@
<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"
IsPlaying="@(IsPlaying && ActiveTrack?.Id == track.Id)"
IsPaused="@(IsPaused && ActiveTrack?.Id == track.Id)"
OnPlay="@HandlePlayClick"
OnPause="@HandlePauseClick"/>
</div>
</MudItem>
}
</MudGrid>
</MudContainer>
+2 -42
View File
@@ -6,28 +6,6 @@ Organised by **theme**, not by date. Themes are roughly ordered by current produ
--- ---
## In-flight — Embeddable iframe player
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 it stands in the working tree (`[in-progress]`, code is partial and does not yet compile):**
- `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 is meant to auto-select that track on load.
- `Services/ITrackDataService.cs` + `TrackClientDataService.cs` — a new track-metadata fetch seam (`GetPage` + a new `GetTrack(trackId)`) so a component can resolve a single track by key without the gallery VM. Intended to be 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.
**Open questions (unresolved — surface to Daniel before this lands):**
- **Is this a committed direction or an experiment?** The code is a partial spike. Confirm before scoping the rest.
- **Track addressing.** `FramePlayer` keys off `TrackEntryKey` (the FileDatabase entry key), but `ITrackDataService.GetTrack(string trackId)` is ambiguous about whether the argument is the SQL `Id` or the `EntryKey`. The two ID spaces (`CONTEXT.md §3.4`) need to be reconciled before the lookup is correct.
- **`AudioPlayerBar` reuse vs. a dedicated embed control.** The `FramePlayer` TODO comment proposes "an iframe-compatible player using the AudioPlayerControl." Decide whether the embed reuses the full dock bar (current approach) or a stripped single-track control.
- **Embedding security.** A public iframe surface implies decisions about `X-Frame-Options` / CSP `frame-ancestors`, and whether the unauthenticated `GET api/track/{id}` stream is acceptable to expose from arbitrary origins (it already is unauthenticated, but embedding makes the exposure explicit).
`[in-progress]` — capture the design decisions in a product note when Daniel confirms the direction.
---
## 0. Baseline — what just landed ## 0. Baseline — what just landed
A two-part audit (design + streaming) ran on 2026-05-17 and the fixes for Critical, Major, and Minor findings are now on `dev`. The remainder of this plan assumes that baseline. In summary the audit-pass fixed: A two-part audit (design + streaming) ran on 2026-05-17 and the fixes for Critical, Major, and Minor findings are now on `dev`. The remainder of this plan assumes that baseline. In summary the audit-pass fixed:
@@ -45,14 +23,6 @@ What this means for the roadmap: the streaming substrate is solid. Future work c
These were flagged during the audit but classified as feature work, not defect fixes. They are listed in rough order of user-visible impact. These were flagged during the audit but classified as feature work, not defect fixes. They are listed in rough order of user-visible impact.
### 1.1 Backward seek
- **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.
- **Prerequisite:** None — the substrate exists.
- **Constraint:** Backward seek must observe the same `blockAlign` rounding-down as forward seek (already enforced in `WavOffsetService.alignedOffset` and `StreamDecoder.calculateByteOffset`). The teardown/reinit must respect the generation-counter pattern introduced by the concurrent-seek fix.
### 1.2 Audio format diversity ### 1.2 Audio format diversity
- **What:** Today `AudioProcessor`, `WavOffsetService`, and the JS decoder are PCM/WAV-only. `MimeTypeExtensions` already maps MP3, FLAC, Ogg, AAC, M4A — none are wired. - **What:** Today `AudioProcessor`, `WavOffsetService`, and the JS decoder are PCM/WAV-only. `MimeTypeExtensions` already maps MP3, FLAC, Ogg, AAC, M4A — none are wired.
@@ -109,18 +79,6 @@ These were flagged during the audit but classified as feature work, not defect f
These follow from `CONTEXT.md §5`. Direction is strongly implied but no specific UI has been committed. These follow from `CONTEXT.md §5`. Direction is strongly implied but no specific UI has been committed.
### 2.1 Cover art / image vault wired through
- **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.
### 2.2 Album and genre views ### 2.2 Album and genre views
- **What:** `TrackCard` already renders album/genre/release date; the data is there. Missing are gallery groupings (album view, genre view), filters, and the API-side support for filter expressions in `TrackService.GetPaged`. - **What:** `TrackCard` already renders album/genre/release date; the data is there. Missing are gallery groupings (album view, genre view), filters, and the API-side support for filter expressions in `TrackService.GetPaged`.
@@ -131,6 +89,7 @@ These follow from `CONTEXT.md §5`. Direction is strongly implied but no specifi
- New routes (`/albums`, `/genres`) consume the same VM with different grouping / filter inputs. - New routes (`/albums`, `/genres`) consume the same VM with different grouping / filter inputs.
- **Prerequisite:** **2.1** for any view that prominently features cover art (album view especially is impoverished without it). - **Prerequisite:** **2.1** for any view that prominently features cover art (album view especially is impoverished without it).
### 2.3 Search and filter on the gallery ### 2.3 Search and filter on the gallery
- **What:** `TracksViewModel` exposes sort but no filter. `TrackService.GetPaged` accepts only sort. Simple text search across `TrackName` / `Artist` / `Album` is the obvious first cut. - **What:** `TracksViewModel` exposes sort but no filter. `TrackService.GetPaged` accepts only sort. Simple text search across `TrackName` / `Artist` / `Album` is the obvious first cut.
@@ -138,6 +97,7 @@ These follow from `CONTEXT.md §5`. Direction is strongly implied but no specifi
- **Shape:** Same extension to `GetPaged` as 2.2. UI is a debounced text input bound to the VM's filter property. EF Core translates `Contains` to SQLite `LIKE`. - **Shape:** Same extension to `GetPaged` as 2.2. UI is a debounced text input bound to the VM's filter property. EF Core translates `Contains` to SQLite `LIKE`.
- **Prerequisite:** Fold into 2.2 if both are being done — the same `GetPaged` extension serves both. Doing them separately doubles the API churn. - **Prerequisite:** Fold into 2.2 if both are being done — the same `GetPaged` extension serves both. Doing them separately doubles the API churn.
--- ---
## Phase 3 — New content kinds ## Phase 3 — New content kinds
+202
View File
@@ -0,0 +1,202 @@
# Design Brief: Player Minimize/Spacer Sync
**Status:** Proposed — awaiting direction
**Surface:** `DeepDrftPublic.Client` audio player dock + `MainLayout` spacer
**Author:** product-designer
---
## 1. Root cause
`AudioPlayerBar` owns `_isMinimized` as private component state. `MainLayout` renders a
sibling spacer `<div class="player-spacer @_audioPlayerClass">` whose class
(`minimized` / `expanded`) reserves vertical space for the fixed dock so page content
does not overlap it. The two are kept in sync by a single bridge: the
`EventCallback<bool> OnMinimized` parameter.
`_isMinimized` is mutated on **three** paths. Only one of them fires the bridge:
| Path | Method | Mutates `_isMinimized` | Fires `OnMinimized` | Spacer stays correct? |
|------|--------|:---:|:---:|:---:|
| Manual minimize/restore button | `ToggleMinimized()` (line 179) | yes | **yes** (line 182) | yes |
| Track selected → auto-expand | `Expand()` (line 118) via `PlayerService.OnTrackSelected` | yes (`true``false`) | **no** | **no** |
| Close (X) button | `Close()` (line 186) | yes (`false``true`) | **no** | **no** |
Resulting defects:
- **Select a track:** bar expands, spacer stays `minimized` → content overlaps the now-visible dock.
- **Close the player:** bar collapses to the FAB, spacer stays `expanded` → a dead gap is left below content.
The `ResizeObserver` hook in `OnAfterRenderAsync` (line 83) already reacts to
`_isMinimized` correctly on every path — it toggles `observe`/`unobserve` purely off
`!_isMinimized && !Fixed` and guards re-entry with `_spacerObserved`. So the CSS-var
height publishing is **not** part of this bug; only the spacer's *class* (which reserves
the box at all) drifts. Any fix must preserve the observer hook's correctness.
### Secondary observation (matters for Option B)
`Close()` is not a pure "minimize" — it also calls `PlayerService.Unload()` when a track
is loaded (line 188-190). The close path therefore couples *two* concerns: tearing down
playback and collapsing the chrome. A fix should keep "minimized state" as the thing the
spacer tracks, independent of the unload side effect.
---
## 2. Design options
### Option A — Fix at the call sites
Fire the existing callback from the two paths that currently skip it: add
`OnMinimized.InvokeAsync(false)` inside `Expand()` and `OnMinimized.InvokeAsync(true)`
inside `Close()`, guarded the same way `ToggleMinimized` already guards
(`if (OnMinimized.HasDelegate)`), and only when the state actually flips.
**Change:** Two added invocations inside `AudioPlayerBar.razor.cs`. No new types, no DI,
no signature changes. `MainLayout` and the `OnMinimized` contract are untouched.
**Scope:** Smallest possible. One file, ~4 lines.
**Edge cases / risks:**
- `Expand()` and `Close()` are `async` but currently never `await` — adding
`await OnMinimized.InvokeAsync(...)` makes them genuinely async; confirm callers
(`PlayerService.OnTrackSelected = new EventCallback(this, Expand)` at line 75, and the
`@onclick` on the X button) await/route the returned task. `EventCallback` handles this
correctly, so this is low risk but worth a glance.
- Must fire **only on an actual transition** (mirror the existing `if (_isMinimized)` /
`if (!_isMinimized)` guards) to avoid redundant `StateHasChanged` churn in `MainLayout`.
- Keeps the bug class alive structurally: any *future* fourth path that sets
`_isMinimized` will reintroduce the same drift. This is a "patch each leak" fix, not a
"remove the leak class" fix.
- The contract stays push-based and one-directional (child → layout), which matches the
current mental model and the existing `ToggleMinimized` precedent.
### Option B — Lift minimize state into a shared service / cascade
Make `_isMinimized` a derived view of authoritative state held outside the bar — e.g. a
small `PlayerChromeState` service (scoped, cascaded by `AudioPlayerProvider` alongside the
player service) exposing `IsMinimized` + a `StateChanged` event. `AudioPlayerBar` reads
and writes through it; `MainLayout` subscribes (or cascades it) and derives
`_audioPlayerClass` reactively. The `OnMinimized` parameter is retired.
**Change:** New service type + DI registration; `AudioPlayerBar` rewires its three
mutation points to the service; `MainLayout` subscribes instead of taking a callback.
Touches 3-4 files plus `Startup.ConfigureDomainServices`.
**Scope:** Medium. Structural — introduces a new piece of shared state.
**Edge cases / risks:**
- Eliminates the bug *class*: any path that flips minimize state flows through one
setter, so the spacer can never drift regardless of how many triggers exist later.
- Aligns with the project's stated direction — **one source, multiple views**: chrome
state becomes a single authority that both the bar and the layout render from, rather
than the bar owning private state and pushing notifications. (See memory:
one-source-multiple-views.)
- The `ResizeObserver` hook still keys off the bar's local read of `IsMinimized`, so it
stays correct — but the bar must `StateHasChanged` (and thus re-run
`OnAfterRenderAsync`) when the service fires, exactly as it already does for
`StateChanged` from the player service (line 76-81). The wiring pattern already exists;
this reuses it.
- More moving parts for a defect that is, today, a missed callback. Risk of over-building
if minimize state never grows beyond this one consumer.
- Must decide ownership lifetime: the cascade is `IsFixed` (line: provider stores player
with `IsFixed="true"`), so a cascaded chrome-state value would need the same
subscribe-to-event escape hatch the bar already uses for the player service. Reuses a
known pattern, but it is the fiddly part.
### Option C — Single internal mutator (middle path) — *recommended*
Keep the `OnMinimized` callback contract and `MainLayout` exactly as they are, but funnel
**all three** trigger paths through one private method inside `AudioPlayerBar` that is the
*only* place `_isMinimized` is assigned:
```
SetMinimized(bool value):
if (_isMinimized == value) return; // transition guard
_isMinimized = value;
if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(value);
StateHasChanged();
```
`ToggleMinimized` becomes `SetMinimized(!_isMinimized)`. `Expand()` becomes
`SetMinimized(false)`. `Close()` keeps its `Unload()` side effect, then calls
`SetMinimized(true)`. `OnParametersSet`'s `Fixed` branch (line 59-62) can also route
through it for consistency.
**Change:** One new private method; the three existing mutation points delegate to it.
Single file (`AudioPlayerBar.razor.cs`), no new types, no DI, no contract change.
**Scope:** As small as Option A, but **structurally closes the bug class** the way
Option B does — by guaranteeing there is exactly one assignment site that always fires the
bridge.
**Why this is the sweet spot:** It gets Option B's invariant ("every state change
propagates") without Option B's new shared-state machinery. The drift is impossible
because the callback firing is co-located with the only assignment, not duplicated across
call sites where the next contributor can forget it.
---
## 3. Recommendation
**Option C.** It satisfies all three evaluation criteria with the smallest durable footprint:
- **(a) Blast radius:** one file, no new types, no DI, no contract or `MainLayout` change —
identical reach to Option A.
- **(b) Correctness across all three paths:** by construction, every assignment of
`_isMinimized` goes through the single mutator that fires the bridge and guards the
transition. Option A fixes today's two missed paths but leaves the pattern that produced
them; Option C removes the pattern.
- **(c) ResizeObserver stays correct:** the mutator calls `StateHasChanged()`, which
triggers `OnAfterRenderAsync`, which re-evaluates `shouldObserve` off the now-updated
`_isMinimized` exactly as today. No change to the observer logic.
Option B is the right move *only if* minimize/chrome state grows additional consumers
(e.g. a second surface that needs to read or drive it, or deep-link/restore-on-load
behaviour). It is over-scoped for the current single-consumer reality. Note the path
forward is clean: if that need arrives, Option C's single mutator is the natural seam to
later back with a service — `SetMinimized` becomes the one call site to redirect. Choosing
C now does not foreclose B later.
**Trade-off being accepted:** C keeps minimize state private to the bar and the
sync push-based. If a future feature needs the layout (or anything else) to *drive*
minimize state rather than just react to it, that is the trigger to revisit Option B.
---
## 4. Acceptance criteria
A correct fix satisfies all of the following observable conditions:
1. **Track-select expand:** selecting a track while minimized expands the bar **and** the
spacer element's class changes from `minimized` to `expanded` (content reflows below the
dock, no overlap).
2. **Manual toggle sync:** clicking minimize collapses the bar to the FAB and the spacer
class returns to `minimized`; clicking restore expands the bar and the spacer class
becomes `expanded`. Bar and spacer never disagree.
3. **Close sync:** clicking the X minimizes the bar **and** the spacer class reverts to
`minimized` (no residual empty gap), and — unchanged — unloads the track when one is
loaded.
4. **No double-invocation:** the manual toggle path fires `OnMinimized` exactly once per
click (no regression from the existing single-fire behaviour). Re-triggering a path
that does not change state (e.g. `Expand()` when already expanded) fires nothing.
5. **ResizeObserver lifecycle:** across all three paths, the spacer `ResizeObserver` is
`observe`d when (and only when) the bar is expanded and not `Fixed`, and `unobserve`d
otherwise — i.e. `_spacerObserved` ends up consistent with `!_isMinimized && !Fixed`
after each transition.
6. **No spacer height strand:** after close/minimize, no stale `--player-height` reserves
phantom space (the existing `unobserve` path already clears this; the fix must not
bypass it).
---
## 5. Notes for the implementer (staff-engineer)
- The `Fixed` embed path (line 59-62) sets `_isMinimized = false` directly in
`OnParametersSet` and intentionally renders no `PlayerWindowControls` (line 42-45) and no
spacer observation (the `!Fixed` guard). Whatever mutator is introduced must not fire
`OnMinimized` for the `Fixed` embed in a way that makes a host page's layout reserve dock
space it does not have. Simplest: the `Fixed` branch may bypass the callback, or
`MainLayout` is simply not present on embed surfaces. Confirm before routing the `Fixed`
branch through the shared mutator.
- This brief specifies behaviour and structure only. No implementation is included by
design; dispatch staff-engineer to land it.