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)
**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})
**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.
@@ -476,7 +682,7 @@ A focused, editorial single-track view in `DeepDrftPublic.Client`. The track gal
**UI layout:**
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.
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.
@@ -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):**
- `deepdrft-track-detail-container` — centered single column, max-width, auto-margins, vertical padding.
- `deepdrft-track-detail-cover` — square aspect-ratio frame, rounded, subtle shadow/border (light/dark theme-aware), `overflow: hidden` for clean image crop.
- `deepdrft-track-detail-cover-art` — applied to `MudPaper` div; sets `background-size: cover`, `background-position: center` for responsive fill within the cover frame.
- `deepdrft-track-detail-masthead` — title/artist spacing, display-serif via existing `deepdrft-` font classes.
- `deepdrft-track-detail-meta` — metadata block rhythm, small-caps muted labels.
- `deepdrft-track-detail-back` — back-link affordance, muted color, hover treatment.
@@ -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.
- `TrackDetailViewModel` is scoped (per-instance), not singleton — navigating between `/track/A` and `/track/B` reuses the same scoped instance, so `Load` must fully reset state to prevent cross-navigation bleed.
- Play button implements the same `PlayerService.StateChanged` subscription pattern as `TracksView` — mandatory for label coherence when the dock bar drives state.
- Cover-art 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.
---
+24 -4
View File
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## One-line purpose
Dual-database authority for tracks (SQL metadata + FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. ApiKey middleware for track endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
Dual-database authority for tracks (SQL metadata + FileDatabase binary) and images (FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. Two image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for track/image endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
## What lives here now (only)
@@ -104,10 +104,29 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID.
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`.
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request clear optional fields), and persists the DTO via `ITrackService.Update`.
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?` (tri-state: null = no change, "" = clear, value = set).
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request for optional metadata clear those fields; `ImagePath` follows tri-state logic), and persists the DTO via `ITrackService.Update`.
- Returns 200 with the updated `TrackDto` on success. Returns 404 if track not found. Returns 500 on update error.
## The image endpoints (two endpoints)
### POST api/image/upload ([ApiKeyAuthorize])
**Authenticated endpoint.** Accepts an image file upload, stores it in the `images` vault, and returns the entry key.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Form field `image`** (`IFormFile`, required): the image bytes (PNG, JPEG, or other format supported by `ImageProcessor`). Maximum file size 50 MB.
- Calls `FileDatabase.RegisterResourceAsync("images", entryKey, imageBinary)` where `imageBinary` is produced by `ImageProcessor` (computes aspect ratio from headers, defaults 1.0 for unsupported formats).
- Returns 200 with JSON `{ entryKey }` on success. Returns 400 for missing file. Returns 500 if processing or vault operations fail.
### GET api/image/{entryKey} (unauthenticated)
Returns image bytes from the `images` vault.
- **Route parameter `entryKey`** (string): the entry id inside the `images` vault.
- Streams the image file directly from disk without buffering.
- Returns 404 if image not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
## ApiKey middleware behaviour
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
@@ -141,7 +160,8 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
2. Await `FileDatabase.FromAsync(VaultPath)` to load or create the database.
3. Register `FileDatabase` as singleton.
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
5. Register singletons: `WavOffsetService`, `AudioProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
6. Register singletons: `WavOffsetService`, `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
**In `Program.cs`** (SQL + AuthBlocks + wiring):
+125
View File
@@ -0,0 +1,125 @@
using DeepDrftAPI.Middleware;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.Processors;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ImageController : ControllerBase
{
// 50 MB ceiling — cover art is small, but this is generous headroom for high-res masters.
private const int MaxImageBytes = 50_000_000;
// FileDatabase is injected directly because image operations are vault-only: there is no
// SQL row for an image. The link to a track is TrackEntity.ImagePath (the entry key),
// written separately via PUT api/track/meta/{id}.
private readonly FileDatabase _fileDatabase;
private readonly ImageProcessor _imageProcessor;
private readonly ILogger<ImageController> _logger;
public ImageController(
FileDatabase fileDatabase,
ImageProcessor imageProcessor,
ILogger<ImageController> logger)
{
_fileDatabase = fileDatabase;
_imageProcessor = imageProcessor;
_logger = logger;
}
// POST api/image/upload ([ApiKeyAuthorize])
// Stores a cover-art image in the images vault and returns its generated entry key. Images
// are small enough to buffer whole in memory — no temp-file dance like the WAV upload path.
[ApiKeyAuthorize]
[HttpPost("upload")]
[RequestSizeLimit(MaxImageBytes)]
public async Task<ActionResult> UploadImage([FromForm] IFormFile? image, CancellationToken cancellationToken)
{
if (image is null || image.Length == 0)
{
return BadRequest("Image file is required");
}
if (image.Length > MaxImageBytes)
{
return BadRequest($"Image exceeds the {MaxImageBytes} byte limit");
}
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
{
_logger.LogWarning("UploadImage rejected: unsupported content type '{ContentType}'", image.ContentType);
return BadRequest($"Unsupported image content type: {image.ContentType}");
}
byte[] buffer;
await using (var stream = image.OpenReadStream())
using (var memory = new MemoryStream())
{
await stream.CopyToAsync(memory, cancellationToken);
buffer = memory.ToArray();
}
var imageBinary = _imageProcessor.Process(buffer, image.ContentType);
if (imageBinary is null)
{
// Process only returns null for an unsupported content type, already screened above —
// belt-and-suspenders in case ImageProcessor's validation diverges later.
_logger.LogWarning("UploadImage: ImageProcessor rejected content type '{ContentType}'", image.ContentType);
return BadRequest($"Unsupported image content type: {image.ContentType}");
}
var entryKey = Guid.NewGuid().ToString("N");
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
if (!stored)
{
_logger.LogError("UploadImage: vault write failed for entryKey={EntryKey}, contentType={ContentType}, size={Size}",
entryKey, image.ContentType, buffer.Length);
return StatusCode(500, "Failed to store image");
}
_logger.LogInformation("UploadImage succeeded: entryKey={EntryKey}, contentType={ContentType}, size={Size}",
entryKey, image.ContentType, buffer.Length);
return Ok(new { entryKey });
}
// GET api/image/{entryKey} (unauthenticated)
// Streams the image whole from disk. Same disk-streaming pattern as GET api/track/{trackId}
// offset-0 path: File() takes ownership of the inner stream on the success path; the wrapper
// is disposed only on the catch path.
[HttpGet("{entryKey}")]
public async Task<ActionResult> GetImage(string entryKey)
{
var vault = _fileDatabase.GetVault(VaultConstants.Images);
if (vault is null)
{
_logger.LogWarning("Images vault not found");
return NotFound();
}
var mediaStream = await vault.GetEntryStreamAsync(entryKey);
if (mediaStream is null)
{
_logger.LogWarning("Image not found: {EntryKey}", entryKey);
return NotFound();
}
string mimeType;
Stream innerStream;
try
{
mimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
innerStream = mediaStream.Stream;
}
catch
{
await mediaStream.DisposeAsync();
throw;
}
return File(innerStream, mimeType, enableRangeProcessing: false);
}
}
@@ -72,6 +72,30 @@ public class TrackController : ControllerBase
return Ok(result.Value);
}
// GET api/track/random (unauthenticated)
// Picks one track at random from the full library and returns its metadata. Public, same auth
// posture as GET api/track/page. Selection math lives in the SQL service/repository, not here.
// 404 when the library is empty (a valid state the client renders as "no tracks yet"), 200 +
// TrackDto otherwise. Literal segment, declared before "{trackId}" so it never routes there.
[HttpGet("random")]
public async Task<ActionResult> GetRandom(CancellationToken cancellationToken = default)
{
var result = await _sqlTrackService.GetRandom(cancellationToken);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetRandom failed: {Error}", error);
return StatusCode(500, "Failed to load track");
}
if (result.Value is null)
{
return NotFound();
}
return Ok(result.Value);
}
// GET api/track/waveform-status ([ApiKeyAuthorize])
// Admin backfill view: returns every track with a flag for whether a waveform profile is
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
@@ -285,6 +309,10 @@ public class TrackController : ControllerBase
track.Genre = request.Genre;
track.ReleaseDate = request.ReleaseDate;
// Only update ImagePath when the request explicitly provides a value (null = no change, "" = clear).
if (request.ImagePath is not null)
track.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
var update = await _sqlTrackService.Update(track);
if (!update.Success)
{
@@ -4,9 +4,15 @@ namespace DeepDrftAPI.Models;
/// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never
/// travels over this surface.
/// </summary>
/// <remarks>
/// <paramref name="ImagePath"/> follows tri-state semantics distinct from the other optional
/// fields: <c>null</c> leaves the existing value unchanged, an empty string clears it, and a
/// non-empty value is the images-vault entry key to link.
/// </remarks>
public record UpdateTrackMetadataRequest(
string TrackName,
string Artist,
string? Album,
string? Genre,
DateOnly? ReleaseDate);
DateOnly? ReleaseDate,
string? ImagePath = null);
+12
View File
@@ -19,6 +19,9 @@ namespace DeepDrftAPI
builder.Services.AddSingleton<AudioProcessor>();
builder.Services.AddSingleton<TrackContentService>();
// Image services
builder.Services.AddSingleton<ImageProcessor>();
// Waveform loudness profiling (upload-time, off the playback path)
builder.Services.Configure<WaveformProfileOptions>(
builder.Configuration.GetSection(nameof(WaveformProfileOptions)));
@@ -38,6 +41,7 @@ namespace DeepDrftAPI
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult();
InitializeImageVault(db).GetAwaiter().GetResult();
return db;
});
@@ -51,5 +55,13 @@ namespace DeepDrftAPI
await fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
}
private static async Task InitializeImageVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.Images))
{
await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image);
}
}
}
}
+11 -1
View File
@@ -101,6 +101,16 @@ Used by the content API to serve seek-beyond-buffer requests. The player asks fo
PCM-only today. Other formats (mp3, flac, aac, ogg, m4a) are listed in `MimeTypeExtensions` but not implemented. The processor validates RIFF/WAVE/PCM format — anything else is rejected.
## Image processor
`ImageProcessor.ProcessImageAsync(buffer, mimeType)`:
1. Accepts raw image bytes and MIME type (e.g., `image/png`, `image/jpeg`).
2. Parses PNG or JPEG headers to extract image dimensions.
3. Computes aspect ratio (width / height). Defaults to 1.0 if parsing fails or format is unsupported.
4. Returns `ImageBinary` with MIME type and aspect ratio metadata.
5. **No disk I/O**: operates on `byte[]` only — no file reading required.
## Content-side TrackService (orchestrator)
### AddTrackFromWavAsync(filePath)
@@ -124,7 +134,7 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
## Vault constants
`VaultConstants.Tracks = "tracks"` — the one vault name in production use. New vault names go here when adding new vault types (e.g., `VaultConstants.Images = "images"` if image uploads are added).
`VaultConstants.Tracks = "tracks"` and `VaultConstants.Images = "images"` — the vault names in production use. New vault names go here when adding new vault types.
## Service registration
@@ -14,4 +14,10 @@ public static class VaultConstants
/// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey.
/// </summary>
public const string WaveformProfiles = "waveform-profiles";
/// <summary>
/// Vault name for storing cover-art images, keyed by a generated entry key referenced
/// from <c>TrackEntity.ImagePath</c>.
/// </summary>
public const string Images = "images";
}
@@ -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?>> GetByEntryKey(string entryKey);
/// <summary>
/// Returns a single track chosen uniformly at random, or null when the library is empty
/// (a valid state, not a failure). Backs the public "Stream Now" instant-play feature.
/// </summary>
Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default);
Task<ResultContainer<List<TrackDto>>> GetAll();
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
@@ -25,6 +25,25 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
=> await _context.Tracks.FirstOrDefaultAsync(t => t.EntryKey == entryKey);
// Picks one track uniformly at random. Two round-trips (count, then a single offset row)
// rather than ORDER BY random() so the database never sorts the whole table — the catalogue
// is small today but this keeps the cost flat as it grows. Returns null when empty so the
// service surfaces a valid empty-library state, not an error. Queries the DbSet directly,
// mirroring GetByEntryKeyAsync, since the base Repository<> exposes only id-based reads.
public async Task<TrackEntity?> GetRandomAsync(CancellationToken cancellationToken = default)
{
var count = await _context.Tracks.CountAsync(cancellationToken);
if (count == 0)
return null;
var index = Random.Shared.Next(count);
return await _context.Tracks
.OrderBy(t => t.Id)
.Skip(index)
.Take(1)
.FirstOrDefaultAsync(cancellationToken);
}
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
{
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()
{
try
@@ -1,7 +1,9 @@
@page "/tracks/{Id:long}"
@using DeepDrftManager.Services
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject IHttpClientFactory HttpClientFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Nav
@@ -60,6 +62,48 @@
Label="Genre"
Variant="Variant.Outlined" />
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@if (ImagePreviewUrl is { } previewUrl)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudImage Src="@previewUrl"
Alt="Cover art preview"
Elevation="1"
Style="max-width: 120px; height: auto; border-radius: 4px;" />
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="_busy"
OnClick="ClearImage"
aria-label="Clear cover art" />
</MudStack>
}
else if (_selectedImageFile is not null)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">New image selected (not yet saved).</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="_busy"
OnClick="ClearImage"
aria-label="Cancel image selection" />
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">No cover art set.</MudText>
}
<InputFile OnChange="HandleImageFileSelected" accept="image/*" />
@if (_selectedImageFile is { } selected)
{
<MudText Typo="Typo.caption">Selected: @selected.Name (will upload on save)</MudText>
}
</MudStack>
</MudField>
<MudDatePicker @bind-Date="_form.ReleaseDate"
Label="Release Date"
DateFormat="yyyy-MM-dd"
@@ -94,11 +138,25 @@
private TrackEditForm _form = new();
private bool _loading = true;
private bool _busy;
private IBrowserFile? _selectedImageFile;
private bool CanSave =>
!string.IsNullOrWhiteSpace(_form.TrackName)
&& !string.IsNullOrWhiteSpace(_form.Artist);
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser can hit
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses for writes.
private string? ImagePreviewUrl
{
get
{
if (string.IsNullOrEmpty(_form.ImagePath)) return null;
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
if (baseAddress is null) return null;
return new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(_form.ImagePath)}").ToString();
}
}
protected override async Task OnInitializedAsync()
{
await LoadAsync();
@@ -123,14 +181,33 @@
_busy = true;
try
{
// Metadata-only update over HTTP — EntryKey is immutable and not sent. The Content
// API loads the authoritative row and applies these fields.
// Upload any newly picked cover art first; abort the save if it fails so we never
// persist metadata pointing at an image that was never stored.
if (_selectedImageFile is { } file)
{
await using var imageStream = file.OpenReadStream(maxAllowedSize: 50_000_000);
var uploadResult = await CmsTrackService.UploadImageAsync(
imageStream, file.Name, file.ContentType);
if (!uploadResult.Success)
{
var uploadError = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Image upload failed: {uploadError}", Severity.Error);
return;
}
_form.ImagePath = uploadResult.Value;
_selectedImageFile = null;
}
// Metadata update over HTTP — EntryKey is immutable and not sent. The Content API
// loads the authoritative row and applies these fields. imagePath is tri-state: an
// explicit empty string clears the link, a value sets it.
var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
var updated = await CmsTrackService.UpdateAsync(
Id, _form.TrackName, _form.Artist,
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
releaseDate);
releaseDate,
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath);
if (updated.Success)
{
Snackbar.Add("Track updated.", Severity.Success);
@@ -153,6 +230,17 @@
}
}
private void HandleImageFileSelected(InputFileChangeEventArgs e)
{
_selectedImageFile = e.File;
}
private void ClearImage()
{
_form.ImagePath = null;
_selectedImageFile = null;
}
private async Task ConfirmDelete()
{
if (_track is null) return;
@@ -197,6 +285,7 @@
public string Artist { get; set; } = string.Empty;
public string? Album { get; set; }
public string? Genre { get; set; }
public string? ImagePath { get; set; }
public DateTime? ReleaseDate { get; set; }
public static TrackEditForm From(TrackDto track) => new()
@@ -205,6 +294,7 @@
Artist = track.Artist,
Album = track.Album,
Genre = track.Genre,
ImagePath = track.ImagePath,
ReleaseDate = track.ReleaseDate is { } d
? d.ToDateTime(TimeOnly.MinValue)
: null
@@ -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(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -254,6 +332,7 @@ public class CmsTrackService : ICmsTrackService
album,
genre,
releaseDate,
imagePath,
};
HttpResponseMessage response;
+13 -1
View File
@@ -50,13 +50,25 @@ public interface ICmsTrackService
/// </summary>
Task<ResultContainer<TrackDto?>> GetByIdAsync(long id, CancellationToken ct = default);
/// <summary>
/// Upload a cover-art image to the images vault via <c>POST api/image/upload</c>.
/// Returns the generated entry key on success. Maps a 400 to a validation failure message.
/// </summary>
Task<ResultContainer<string>> UploadImageAsync(
Stream imageStream,
string fileName,
string contentType,
CancellationToken ct = default);
/// <summary>
/// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and
/// not part of the update.
/// not part of the update. <paramref name="imagePath"/> is tri-state: <c>null</c> leaves the
/// cover art unchanged, <c>""</c> clears it, and any other value sets it.
/// </summary>
Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
CancellationToken ct = default);
/// <summary>
+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/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`.
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0100%), with fixed three-zone gradient (green 060%, yellow 6085%, orange 85100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
- `Helpers/`: Utilities and mapper functions.
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
@@ -73,7 +74,7 @@ Both are configured with JSON serializer settings (case-insensitive property mat
### Component integration
- `AudioPlayerProvider.razor` is the cascading host. It injects `IStreamingPlayerService` (resolved to `StreamingAudioPlayerService` in DI), stores it in a cascade with `IsFixed="true"`, and keeps it alive across navigation.
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Minimize-state mutations (`Expand`, `ToggleMinimized`, `Close`) all route through a private `SetMinimized(bool value)` mutator, which guards no-ops, fires the `OnMinimized` callback, and calls `StateHasChanged()`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
- `SpectrumVisualizer.razor` calls `AudioInteropService.GetSpectrumData()` on a timer, receives bar heights, renders via MudBlazor `MudChart` or custom canvas.
- `TracksView.razor` injects `TracksViewModel` + cascaded `IStreamingPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrackStreaming(track)`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` and 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");
}
/// <summary>
/// Fetches a random track from the public library. A 404 means the library is empty — a valid
/// state, not an error — so it returns a pass result with a null value. Any other non-success
/// status is a genuine failure.
/// </summary>
public async Task<ApiResult<TrackDto?>> GetRandom()
{
var response = await _http.GetAsync("api/track/random");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<TrackDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<TrackDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var track = JsonSerializer.Deserialize<TrackDto>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return track is not null
? ApiResult<TrackDto?>.CreatePassResult(track)
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
{
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
@@ -1,10 +1,7 @@
@if (_isMinimized)
{
<div class="minimized-dock">
<MudFab Color="Color.Primary"
StartIcon="@Icons.Material.Filled.MusicNote"
Size="Size.Large"
OnClick="@ToggleMinimized"/>
<LevelMeterFab OnClick="@ToggleMinimized" />
</div>
}
else
@@ -115,14 +115,23 @@ 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)
{
_isMinimized = false;
if (_isMinimized == value) return;
_isMinimized = value;
if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(value);
StateHasChanged();
}
}
private async Task TogglePlayPause()
{
@@ -176,12 +185,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
PlayerService?.ClearError();
}
private async Task ToggleMinimized()
{
_isMinimized = !_isMinimized;
if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(_isMinimized);
StateHasChanged();
}
private async Task ToggleMinimized() => await SetMinimized(!_isMinimized);
private async Task Close()
{
@@ -190,11 +194,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
await PlayerService.Unload();
}
if (!_isMinimized)
{
_isMinimized = true;
StateHasChanged();
}
await SetMinimized(true);
}
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;
height: var(--bar-height, 2%);
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;
transition: height 0.05s ease-out;
}
@@ -44,5 +52,6 @@
.spectrum-bar {
max-width: 8px;
min-width: 3px;
background-size: 100% 32px;
}
}
@@ -183,12 +183,20 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable
{
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;
_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)
{
try
@@ -197,13 +205,9 @@ public partial class WaveformSeeker : ComponentBase, IAsyncDisposable
}
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)
@@ -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 Microsoft.AspNetCore.Components;
using MudBlazor;
namespace DeepDrftShared.Client.Components;
namespace DeepDrftPublic.Client.Controls;
public partial class TrackCard : ComponentBase
{
@@ -11,6 +11,7 @@ public partial class TrackCard : ComponentBase
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
[Parameter] public bool IsPlaying { get; set; } = false;
[Parameter] public bool IsPaused { get; set; } = false;
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
// Pause only when actively playing; every other state (idle, paused) reads as "press to play".
private bool IsActivelyPlaying => IsPlaying && !IsPaused;
@@ -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 Microsoft.AspNetCore.Components;
namespace DeepDrftShared.Client.Components;
namespace DeepDrftPublic.Client.Controls;
public partial class TracksGallery : ComponentBase
{
@@ -13,6 +13,7 @@ public partial class TracksGallery : ComponentBase
[Parameter] public TrackDto? ActiveTrack { get; set; }
[Parameter] public bool IsPlaying { get; set; }
[Parameter] public bool IsPaused { get; set; }
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
[Parameter] public EventCallback<TrackDto> OnPlay { get; set; }
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
@@ -6,3 +6,10 @@
display: flex;
justify-content: center;
}
.deepdrft-track-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
+144 -2
View File
@@ -16,7 +16,20 @@
</ul>
<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" *@
@* class="dd-nav-toggle" *@
@* aria-label="Toggle dark mode" *@
@@ -25,6 +38,11 @@
@* @((MarkupString)DarkLightModeIconSvg) *@
@* </button> *@
</div>
@if (_streamMessage is not null)
{
<p class="dd-nav-stream-message" role="status">@_streamMessage</p>
}
</nav>
</div>
@@ -60,15 +78,37 @@
</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>
</ul>
}
@if (_streamMessage is not null)
{
<p class="dd-nav-stream-message" role="status">@_streamMessage</p>
}
</nav>
</div>
@implements IDisposable
@code {
[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
// surface so MainLayout's <DeepDrftMenu Elevation="..."> call site stays intact.
@@ -77,6 +117,108 @@
[Parameter] public required EventCallback<bool> IsDarkModeChanged { get; set; }
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)
{
@@ -7,7 +7,7 @@
@inherits LayoutComponentBase
@implements IDisposable
<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />
<MudThemeProvider Theme="@DeepDrftPalettes.Embed" IsDarkMode="_isDarkMode" />
<div class="@ThemeWrapperClass">
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
<AudioPlayerProvider>
@@ -5,6 +5,7 @@
@page "/FramePlayer"
@layout EmbedLayout
@rendermode InteractiveWebAssembly
<AudioPlayerBar Fixed />
+11 -4
View File
@@ -57,19 +57,26 @@ else if (ViewModel.Track is not null)
<MudText Typo="Typo.h6" Color="Color.Primary">@track.Artist</MudText>
</div>
<div>
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<SharePopover EntryKey="@track.EntryKey" />
<PlayStateIcon Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack"/>
</div>
</MudStack>
</MudStack>
<div class="deepdrft-track-detail-cover">
@if (!string.IsNullOrEmpty(track.ImagePath))
{
<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>
@if (hasMeta)
{
<MudDivider />
@@ -1,4 +1,5 @@
@page "/tracks"
@using DeepDrftPublic.Client.Controls
<PageTitle>DeepDrft Track Gallery</PageTitle>
@@ -6,8 +7,19 @@
<div class="tracks-view-container">
@if (ViewModel.Page != null)
{
<div class="tracks-view-header">
<MudToggleGroup T="GalleryViewMode" @bind-Value="_viewMode" Disabled="@(!RendererInfo.IsInteractive)" Class="tracks-view-toggle">
<MudToggleItem Value="GalleryViewMode.Grid">
<MudIcon Icon="@Icons.Material.Filled.ViewModule"/>
</MudToggleItem>
<MudToggleItem Value="GalleryViewMode.List">
<MudIcon Icon="@Icons.Material.Filled.ViewList"/>
</MudToggleItem>
</MudToggleGroup>
</div>
<div class="tracks-content">
<TracksGallery Tracks="@ViewModel.Page.Items"
ViewMode="@_viewMode"
ActiveTrack="@PlayerService.CurrentTrack"
IsPlaying="@PlayerService.IsPlaying"
IsPaused="@PlayerService.IsPaused"
@@ -18,6 +30,7 @@
<MudPagination Count="@ViewModel.Page.TotalPages"
Selected="@ViewModel.Page.Page"
SelectedChanged="@SetPage"
Disabled="@(!RendererInfo.IsInteractive)"
BoundaryCount="2"
MiddleCount="3"/>
</div>
@@ -1,4 +1,5 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Controls;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
@@ -17,6 +18,9 @@ public partial class TracksView : ComponentBase, IDisposable
private IStreamingPlayerService? _subscribedService;
private PersistingComponentStateSubscription _persistingSubscription;
// Ephemeral view-mode selection — presentation-only, not persisted across navigation.
private GalleryViewMode _viewMode = GalleryViewMode.Grid;
protected override async Task OnInitializedAsync()
{
// Carry the prerendered page across the prerender -> interactive (WASM) seam.
@@ -18,3 +18,9 @@
align-items: center;
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);
}
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)
{
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 bool Success { get; set; }
@@ -63,6 +63,17 @@ public interface IStreamingPlayerService : IPlayerService
// Streaming control methods
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>
/// 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
@@ -5,11 +5,10 @@ using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <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
/// <see cref="Clients.TrackClient"/> over HTTP.
///
/// Components inject this single seam so they do not branch on render mode.
/// <see cref="Clients.TrackClient"/> over HTTP. Components inject this single seam
/// so they do not branch on render mode.
/// </summary>
public interface ITrackDataService
{
@@ -20,4 +19,11 @@ public interface ITrackDataService
bool sortDescending = false);
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);
}
/// <inheritdoc />
public async Task WarmAudioContext()
{
await EnsureInitializedAsync();
await _audioInterop.EnsureAudioContextReady(PlayerId);
}
public async Task SelectTrackStreaming(TrackDto track)
{
await EnsureInitializedAsync();
@@ -28,4 +28,7 @@ public class TrackClientDataService : ITrackDataService
public Task<ApiResult<TrackDto>> GetTrack(string 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>
/// 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
+29 -9
View File
@@ -140,9 +140,11 @@ export class AudioPlayer {
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();
if (estimatedDuration) {
if (estimatedDuration && this.duration === 0) {
this.duration = estimatedDuration;
}
@@ -225,8 +227,10 @@ export class AudioPlayer {
this.isPlaying = true;
this.isPaused = false;
// Resume from pause position
this.scheduler.playFromPosition(this.pausePosition);
// Resume from pause position. pausePosition is absolute track time;
// playFromPosition expects a buffer-relative position (excludes playbackOffset).
const bufferRelativePosition = this.pausePosition - this.scheduler.getPlaybackOffset();
this.scheduler.playFromPosition(Math.max(0, bufferRelativePosition));
this.startProgressTracking();
return { success: true };
@@ -274,14 +278,18 @@ export class AudioPlayer {
return { success: false, error: 'Invalid seek position' };
}
// Get buffered duration (accounting for playback offset)
const bufferedDuration = this.scheduler.getTotalDuration() + this.scheduler.getPlaybackOffset();
const bufferStart = this.scheduler.getPlaybackOffset();
const bufferEnd = this.scheduler.getTotalDuration() + bufferStart;
// Check if seeking within buffered content
if (position <= bufferedDuration) {
// Position must be within [bufferStart, bufferEnd] to use buffered content.
// 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);
} else {
// Seeking beyond buffer - signal C# to fetch new stream
// Seeking outside buffered window - signal C# to fetch new stream
return this.seekBeyondBuffer(position);
}
}
@@ -455,6 +463,18 @@ export class AudioPlayer {
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 resetState(): void {
@@ -235,7 +235,9 @@ export class PlaybackScheduler {
const position = this.getCurrentPosition();
this.isActive_ = false; // Prevent handleSourceEnded from scheduling more
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.nextScheduleTime = 0;
return position;
@@ -16,6 +16,7 @@ export class SpectrumAnalyzer {
private audioContext: AudioContext | null = null;
private fftSize: number = 2048;
private dataArray: Float32Array<ArrayBuffer> | null = null;
private timeDomainArray: Float32Array<ArrayBuffer> | null = null;
// Configuration
private bucketCount: number = 32;
@@ -26,6 +27,7 @@ export class SpectrumAnalyzer {
// Animation state - supports multiple callbacks per player
private animationId: number | null = null;
private callbacks = new Map<string, (data: number[]) => void>();
private levelCallbacks = new Map<string, (db: number) => void>();
private lastFrameTime: number = 0;
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.smoothingTimeConstant = 0.8;
this.dataArray = new Float32Array(this.analyser.frequencyBinCount);
this.timeDomainArray = new Float32Array(this.analyser.fftSize);
return this.analyser;
}
@@ -121,6 +124,22 @@ export class SpectrumAnalyzer {
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
*/
@@ -157,7 +176,7 @@ export class SpectrumAnalyzer {
* Add a callback for spectrum data. Starts animation loop on first subscriber.
*/
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);
if (wasEmpty) {
this.lastFrameTime = 0;
@@ -170,7 +189,30 @@ export class SpectrumAnalyzer {
*/
removeCallback(id: string): void {
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();
}
}
@@ -186,16 +228,21 @@ export class SpectrumAnalyzer {
}
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
const elapsed = timestamp - this.lastFrameTime;
if (elapsed >= this.targetFrameInterval) {
this.lastFrameTime = timestamp - (elapsed % this.targetFrameInterval);
if (this.callbacks.size > 0) {
const data = this.getFrequencyData();
// Broadcast to all callbacks
for (const cb of this.callbacks.values()) {
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 {
this.stopAnimation();
this.callbacks.clear();
this.levelCallbacks.clear();
this.analyser = null;
this.audioContext = null;
this.dataArray = null;
this.timeDomainArray = null;
}
}
+26
View File
@@ -198,6 +198,32 @@ const DeepDrftAudio = {
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 => {
const player = audioPlayers.get(playerId);
if (player) {
+28
View File
@@ -24,6 +24,11 @@ Startup.ConfigureDomainServices(builder);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddCors(options =>
{
options.AddPolicy("FramePlayerEmbedPolicy", policy =>
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
});
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.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
// middleware is intentionally absent — this host is fully anonymous.
app.UseAntiforgery();
@@ -317,6 +317,14 @@ h2, h3, h4, h5, h6,
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 {
display: flex;
flex-direction: column;
@@ -339,6 +347,23 @@ h2, h3, h4, h5, h6,
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
introducing its own box the container's positioning context and the
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
};
// 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.
// 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
@@ -109,6 +137,35 @@ public static class DeepDrftPalettes
// 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.
public static Typography Typography { get; } = new()
{
@@ -148,5 +205,16 @@ public static class DeepDrftPalettes
PaletteDark = Dark,
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
@@ -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
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.
### 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
- **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.
### 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
- **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.
- **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
- **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`.
- **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
+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.