Compare commits
147 Commits
05130aaed2
...
dc70be768a
| Author | SHA1 | Date | |
|---|---|---|---|
| dc70be768a | |||
| ad94354632 | |||
| 8331ccf6a3 | |||
| 372e006be1 | |||
| dcfb1fca9f | |||
| ea74aaaf2e | |||
| 54ef4c038e | |||
| 394b07f404 | |||
| d6df0de63a | |||
| 76060f60a8 | |||
| b9f06bb7cd | |||
| d105385006 | |||
| e48baa5b27 | |||
| cf47fee07e | |||
| a0b3255028 | |||
| d36aea212c | |||
| efef23753b | |||
| 4e34696719 | |||
| ba1a1cd8ec | |||
| bfdbf7568f | |||
| 4eba3b0bb3 | |||
| 39fabc8d0d | |||
| 371812b274 | |||
| a6d25344b4 | |||
| 81ea5909d2 | |||
| 9cf6bb4cf2 | |||
| 3e97e34aee | |||
| fc7c9e978f | |||
| daafae8af6 | |||
| 841822d8fe | |||
| 1730aa0166 | |||
| b7a60f24c5 | |||
| f7366b167c | |||
| c926937694 | |||
| d8d908d4a6 | |||
| b5fdb826b0 | |||
| dae8020a22 | |||
| 919a800f4b | |||
| 79de2503c4 | |||
| 5b3036ed83 | |||
| 946b1d7cf9 | |||
| 56d94b7424 | |||
| 41ac7a5a93 | |||
| f07d29cdcf | |||
| bb4e169d0a | |||
| fe28573b68 | |||
| d5ea5f52ee | |||
| 78df665480 | |||
| a64a5598ae | |||
| 5fb7d85019 | |||
| ca5fc5649a | |||
| 09309630cb | |||
| db7afe4ea7 | |||
| e6a80b6086 | |||
| f35cbc82fe | |||
| ed7304af1f | |||
| 0b2fee1520 | |||
| cff18df783 | |||
| 2c4bd3a394 | |||
| d899bc9456 | |||
| ce437521ee | |||
| ef6d21b94e | |||
| bef1e3adfb | |||
| 313551ac7c | |||
| f08b412772 | |||
| d98ead97c3 | |||
| ff37efea89 | |||
| 55515981a9 | |||
| 74b9c02722 | |||
| 96b13af95d | |||
| f8f9844ef4 | |||
| 6ac943ca09 | |||
| 364450885b | |||
| fbb397228e | |||
| c2a3e53991 | |||
| 23b34004ff | |||
| aedbe82d28 | |||
| 2bb7d86e63 | |||
| ff9c87c461 | |||
| e59271aa00 | |||
| b27ec1b7d0 | |||
| c1ed2a9ba3 | |||
| 294414d00a | |||
| 2b42e01cd0 | |||
| 26d7a05ba4 | |||
| cfacc9f79a | |||
| 07ddc69cee | |||
| 779e1f569c | |||
| 5011fb43f0 | |||
| a9d6445881 | |||
| 56e205082d | |||
| 31e00e6abd | |||
| e9f4411fdf | |||
| 22c2ae5ecb | |||
| b7bd6ba04f | |||
| 1e6129401b | |||
| bf00b7f22f | |||
| 913861860b | |||
| e0f371cda6 | |||
| 44a15bf67d | |||
| 65e5e09245 | |||
| d73e94a12f | |||
| df4381b4d8 | |||
| ad8cb7dbc0 | |||
| 652c90979d | |||
| 1ad501ff11 | |||
| c9b8dfcf3f | |||
| 2bacf58241 | |||
| 83c0425133 | |||
| 0758bfe7f1 | |||
| 79e7bb4799 | |||
| 45bf5e5d37 | |||
| 3c7f28b2eb | |||
| 61d53dacff | |||
| 06b58304c5 | |||
| b3283d0bd2 | |||
| cb6f75be5f | |||
| c1562dde03 | |||
| 8b0bd6d26e | |||
| 7d23c0654b | |||
| cab181db4b | |||
| f02f370ed9 | |||
| b451dda79e | |||
| 4f84216ab6 | |||
| bb50d8369b | |||
| ebdcc29f2e | |||
| ea8b97e47b | |||
| f1600023dc | |||
| 09f6dc88f7 | |||
| 31084b09a4 | |||
| 5941f1f23a | |||
| 7f1c6bdb66 | |||
| 37608aee28 | |||
| e0ab2f3d00 | |||
| 41e3ccc9fa | |||
| 709103ad71 | |||
| 9f074f7350 | |||
| 47082591ee | |||
| 4df2b8fb57 | |||
| a9965ad751 | |||
| c64455f2f2 | |||
| 2d0a565765 | |||
| c608fa345a | |||
| 09a980ba2a | |||
| da08ac4efb | |||
| 00d7215178 | |||
| 898fcfaa04 |
+157
-3
@@ -6,6 +6,140 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Mix Visualizer Reframe: Waves R1–R4 (Lava tuning + eight-knob controls)
|
||||
|
||||
**Landed:** 2026-06-17 on dev.
|
||||
|
||||
- **What:** A major reframe of the Mix visualizer's effects, controls, and color model, built on the landed WebGL2 Phase 10 renderer infrastructure. Four waves:
|
||||
- **Wave R1** — removed the static noise/frost texture (Daniel: "makes the screen look dirty"); implemented dynamic footer-height clip so the lava stops cleanly above the player bar; redrawn `DDIcons.LavaLamp` to the classic 1970s silhouette (wide truncated-cone base, bulbous→roundedly-pointed teardrop glass body, small cone cap — navy fluid + moss blobs, body `currentColor`).
|
||||
- **Wave R2** — CPU-side per-frame physics step (~16–32 Lagrangian wax blobs: position/velocity/temperature/radius), uploaded as uniforms each frame; `smin` SDF metaball render producing a **flat, coalescing fluid** (not blobs with radial hotspots); energy-coupled dynamics (high heat → many small turbulent bubbles; low heat → fewer large calm masses); 2D elastic collision on both blob↔waveform and blob↔blob pairs; collision strength knob sweeping from genuine soft mush to a high-elasticity upward-and-outward throw; waveform collision always on regardless of heat. Loudness profile smoothed with a **~15 ms envelope-follower at preprocessing only** (`RmsLoudnessAlgorithm.cs`); there is no decode-time smoothing (`smoothDatum` was removed). Existing vault mixes gain the smoothing only after server-side reprocessing — they do not benefit automatically. Ribbon rendered with smootherstep sinusoid reconstruction.
|
||||
- **Wave R3** — replaced HSL `mixHsl`/`vivify` color with OKLab interpolation (structural fix for the cyan excursion artifact); three combined gradient motions: (1) A/B anchor rotation among three theme colors at the gradient-rotation-speed rate; (2) per-segment sinusoidal variation keyed to mix-time so colors travel with the segment as it scrolls; (3) per-bar gradient curve shifts with scroll height (mostly A at bottom → mostly B at top). Static noise texture removed. One source of truth (`DeepDrftPalettes`), no hardcoded hexes.
|
||||
- **Wave R4** — `MixVisualizerControlState` widened from four properties to **eight**: `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, **`FluidAmount`** (replaces the former `BlobDensity`), **`FluidViscosity`** (new — cohesion / coalescence control, the second half of the bubbles split), `CollisionStrength`, `WaveformWidth`. `MixVisualizerControls` now renders **eight** `RadialKnob`s; the `Visible` parameter `@if`-gates the knob band while the container holds a reserved min-height so content below never pops when the lamp toggles. Scroll-speed knob range tuned to 60–110% band; gravity 0–75%; heat +20% at top; width default 50% / range 10–95%. The scaffold's `TopRowCenter` slot (added in the prior reframe) carries the controls in-flow between the back link and lava-lamp toggle.
|
||||
- **Why:** Daniel tested the Phase 10 effects end-to-end and rejected the visual result: lava read as "giant disconnected circles," colors drifted to cyan (HSL arc artifact), waveform and lava read as two unrelated layers. The diagnosis was that these were structural failures of the prior model (too few scripted blobs with no physics; HSL hue-arc through cyan), not tuning misses. The reframe replaced the model with CPU-physics wax blobs + OKLab gradients, fixing both root causes.
|
||||
- **Shape:** `DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs` — ~15 ms envelope-follower smoothing added at preprocessing (server-side only; no decode-time smoothing). `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — smootherstep sinusoid ribbon reconstruction; wax-blob physics loop; OKLab gradient; footer-clip; noise texture removed. `DeepDrftPublic.Client/Services/MixVisualizerControlState.cs` — widened to eight properties (`FluidAmount` + `FluidViscosity` replace `BlobDensity`; `WaveformWidth` range/default updated). `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor` — eight `RadialKnob`s, `Visible` parameter gates knob band via `@if` while container holds reserved height. `DeepDrftShared.Client/Common/DDIcons.cs` — `LavaLamp` glyph redrawn. Full design, acceptance criteria, and phasing: `product-notes/phase-10-mix-visualizer-lava-reframe.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — Public Site Enhancements (complete — all tracks 11.A–11.H landed 2026-06-16)
|
||||
|
||||
### 11.H — release `EntryKey` identifiers (terminal public-site wave)
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** Front the release `long` PK with an app-minted GUID-string `EntryKey` column — the same pattern `TrackEntity.EntryKey` uses. `ReleaseEntity.EntryKey` is `required string`, minted as `Guid.NewGuid().ToString()` at the `FindOrCreateRelease` path; `ReleaseDto.EntryKey` mirrors it; `TrackConverter` round-trips it. The public addressing surface was re-typed from `long` to the `EntryKey` string handle: detail routes (`/cuts`, `/sessions`, `/mixes`, and the `/tracks/{id}` redirect), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path (`IReleaseDataService.GetByEntryKey`), and the public release API (`GET api/release/{entryKey}`, the mix waveform endpoint). The `releaseId` track-page query is resolved client-side from the EntryKey-loaded release and stays `long` (never enters a navigable URL). The internal `long` PK and all internal FKs (`TrackEntity.ReleaseId`, `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId`) are unchanged — DB-only, unused by the app. ApiKey-gated CMS endpoints stay on the int PK. EF migration `20260616210143_AddReleaseEntryKey` authored; **not yet applied** (Daniel-gated; must follow 11.G's `20260616035252_AddReleaseDescription` in apply order). The migration adds the `entry_key` column, backfills a unique GUID string per existing release row at migration time, then sets NOT NULL + unique index.
|
||||
- **Why:** The release `long` PK was leaking into navigable public URLs (`/cuts/{long}`, `/sessions/{long}`, `/mixes/{long}`), exposing sequential internal IDs and making public addresses dependent on DB identity. An app-minted opaque GUID handle (the pattern already established by `TrackEntity.EntryKey`) decouples the public addressing surface from the storage PK, enables backfilling existing rows without a dev reset, and completes the commitment-9 scope of Phase 11.
|
||||
- **Shape:** `ReleaseEntity.EntryKey` (`required string`) in `DeepDrftModels/Entities/`; `ReleaseConfiguration` adds the `entry_key` column config + unique index. `ReleaseDto.EntryKey` in `DeepDrftModels/DTOs/`. `TrackConverter` maps EntryKey on both read and write paths. `FindOrCreateRelease` (`DeepDrftData/TrackManager.cs`) mints `Guid.NewGuid().ToString()` on new-release creates. Public API route params re-typed to string EntryKey: `GET api/release/{entryKey}` + mix waveform endpoint. `IReleaseDataService.GetByEntryKey` (public read path). Detail page routes (`/cuts/{entryKey}`, `/sessions/{entryKey}`, `/mixes/{entryKey}`), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, `TrackRedirect.razor`. Migration `20260616210143_AddReleaseEntryKey` authored but not applied.
|
||||
|
||||
---
|
||||
|
||||
### 11.D — Archive filters in the URL
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** `ArchiveView` filter state (`q`, `medium`, `genre`) is now URL-bound via `[SupplyParameterFromQuery]`, making every filtered archive view a shareable, bookmarkable address (`/archive?q=&medium=&genre=`). Filter handlers navigate only; the seed-and-fetch reaction moved to `OnParametersSetAsync` (history-driven, §5.3 Option A). A `_loadedFilterKey` idempotency guard composed from the three-axis filter triple makes same-route query changes (debounce/chip-nav races, back/forward history) a no-op when the filter set is unchanged. The `HasActiveFilter` prerender-persistence gate is preserved: a filtered direct-load fetches its own narrowed result; a plain `/archive` visit restores the bridged first page. `medium` is parsed leniently with `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined` so a stray token degrades to All. Folded-in cleanup: `GenresView` genre-tile click was repointed from the deleted `/tracks?genre=` route to `/archive?genre=`, closing the 11.C dead-link residual — no `/tracks?genre=` references remain in the codebase.
|
||||
- **Why:** Archive filters were held in component fields with no URL representation, so a filtered view had no shareable address and the browser's back button did not restore the previous filter state. URL-binding makes the filter model consistent with the TracksView `[SupplyParameterFromQuery]` pattern already in the codebase and is a prerequisite for 11.H (which re-types the addressing surface 11.D defines).
|
||||
- **Shape:** `ArchiveView.razor.cs` (`DeepDrftPublic.Client/Pages/`): added three `[SupplyParameterFromQuery]` properties (`QueryParam`, `MediumParam`, `GenreParam`); added `_loadedFilterKey` string field + `ComposeFilterKey()` method; moved the seed-and-fetch reaction from `OnInitializedAsync` to `OnParametersSetAsync` with the idempotency guard; filter handlers (`OnSearchInput`, `OnMediumSelected`, `OnGenreSelected`) rewritten to call `NavigateToFilter` (navigate-only). `SeedFromQuery()` private method maps query params onto the component's filter fields with lenient enum parsing. `GenresView.razor.cs` (`DeepDrftPublic.Client/Pages/`): genre-tile click repointed to `/archive?genre=` from the former `/tracks?genre=`.
|
||||
|
||||
---
|
||||
|
||||
### 11.E — release-level Share
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** `SharePopover` gained a release-keyed mode alongside the existing track-keyed mode. Two new parameters: `ReleaseId` (`long?`) and `ReleaseMedium` (`ReleaseMedium`). When `ReleaseId` is set, "Copy link" copies the absolute URL formed from `ReleaseRoutes.DetailHref(id, medium)` composed against `NavigationManager.BaseUri`; the "Embed player" affordance is hidden entirely — release pages are not single-track embeds. The existing track-keyed mode (`EntryKey`, copy link + embed) is unchanged. `IsReleaseMode` is a private derived bool (`ReleaseId is not null`) that drives the branch. `CutDetail.razor`'s header Share button now passes `ReleaseId` and `ReleaseMedium` from the loaded release — unconditional, no longer gated on a track being present. Session and Mix detail headers were not touched.
|
||||
- **Why:** Cuts had no shareable release-level URL — the Share button in `CutDetail` was wired to a track entry key. With the Cut detail page now the canonical address for an album, sharing should copy the album URL (`/cuts/{id}`), not a per-track URL. A single popover component serving both modes avoids duplicating clipboard/popover-chrome logic.
|
||||
- **Shape:** `SharePopover.razor.cs` (`DeepDrftPublic.Client/Controls/`): added `[Parameter] public long? ReleaseId { get; set; }`, `[Parameter] public ReleaseMedium ReleaseMedium { get; set; }`, `private bool IsReleaseMode => ReleaseId is not null`, and a `LinkUrl` computed property that branches on `IsReleaseMode`. `SharePopover.razor`: embed section wrapped in `@if (!IsReleaseMode)`. `CutDetail.razor`: Share button updated to `<SharePopover ReleaseId="@release.Id" ReleaseMedium="@release.Medium" />`.
|
||||
|
||||
---
|
||||
|
||||
### 11.C — retire track-cardinal stack + normalize release cards
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** Deleted the entire track-cardinal stack: `TracksView.razor` + `.razor.cs` + `.css`, `TrackDetail.razor` + `.razor.cs`, `TrackCard.razor` + `.css`, `TracksGallery.razor` + `.css`, `GalleryViewMode`, and the orphaned `TracksViewModel` + `TrackDetailViewModel`. Their DI registrations were removed from `Startup.cs`. `/tracks` was cleaned from the nav index (`Pages.cs`) and the `DeepDrftHero` + `Home` CTAs were repointed from `/tracks` to `/archive`. Routes `/tracks` and `/track/{EntryKey}` are gone; the `/albums` redirect and the `/tracks/{id}` release-id redirect (`TrackRedirect.razor`) both survive. On the normalize side: `ReleaseGallery` is now the single release-card grid across all browse surfaces, generalized with an optional `HrefResolver` parameter (per-card medium routing via `ReleaseRoutes.DetailHref`) and a `SubtitleResolver` parameter (Cuts show "N tracks", others show artist). `ArchiveView` and `AlbumsView` folded their inline card markup and CSS into `ReleaseGallery` via these new parameters; Sessions and Mixes continue on the back-compat `DetailRoute` path unchanged. Known residual (not fixed): `GenresView.razor.cs` still links to the deleted `/tracks?genre=` route (left intentionally — `/genres` is out of Phase 11 scope); one orphaned `.deepdrft-track-card-link` CSS rule remains in the `DeepDrftPublic` host stylesheet.
|
||||
- **Why:** 11.B removed every inbound link to the track-cardinal stack (Archive/AlbumsView cards and the player-bar title all route through `ReleaseRoutes` now), so the stack became dead code. Deleting it removes several files and two view-models from the interactive surface and prevents stale routes from being accidentally discoverable. The release-card normalization was the companion half of the commitment: Archive and Cuts had been reimplementing card markup inline rather than using the shared `ReleaseGallery`, so a new medium or a card-design tweak required edits in three places.
|
||||
- **Shape:** Deleted from `DeepDrftPublic.Client/Pages/`: `TracksView.razor`, `TracksView.razor.cs`. Deleted from `DeepDrftPublic.Client/Controls/`: `TrackCard.razor`, `TrackCard.razor.css`, `TracksGallery.razor`, `TracksGallery.razor.css`, `GalleryViewMode`. Deleted from `DeepDrftPublic.Client/ViewModels/`: `TracksViewModel.cs`, `TrackDetailViewModel.cs`. `Startup.cs`: DI registrations for deleted view-models removed. `Pages.cs` (`DeepDrftPublic.Client/Layout/`): `/tracks` removed from `MenuPages`. `DeepDrftHero.razor` and `Home.razor`: CTAs repointed to `/archive`. `ReleaseGallery.razor` (`DeepDrftPublic.Client/Controls/`): new `[Parameter] public Func<ReleaseDto, string>? HrefResolver { get; set; }` and `[Parameter] public Func<ReleaseDto, string>? SubtitleResolver { get; set; }` parameters; `CardHref` private method branches on `HrefResolver` presence. `ArchiveView.razor` and `AlbumsView.razor` (or `.razor.cs`): inline card markup removed, delegated to `ReleaseGallery` with `HrefResolver` and (for Cuts) `SubtitleResolver`.
|
||||
|
||||
---
|
||||
|
||||
### 11.B — `ReleaseRoutes` resolver + repoint
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** New shared `DeepDrftPublic.Client/Common/ReleaseRoutes.cs` — the single source of truth for resolving a release to its dedicated detail route. `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` returns `/cuts/{id}`, `/sessions/{id}`, or `/mixes/{id}`; a convenience overload `DetailHref(ReleaseDto)` delegates to the primary. `ArchiveView`'s former private `DetailHref` switch was removed and replaced by this shared resolver. The player-bar title (`TrackMetaLabel`), Archive cards, and `AlbumsView` Cut cards all route through the shared resolver. A thin `/tracks/{id}` redirect page (`Pages/TrackRedirect.razor`) handles bare-release-id deep links: it fetches the release to discover its medium, resolves through `ReleaseRoutes.DetailHref`, and performs a history-replacing redirect — one medium→route table, no second source. The track-cardinal stack (`TrackDetail`/`TracksView`/etc.) was deliberately not touched — that is 11.C.
|
||||
- **Why:** Multiple call sites (Archive, AlbumsView, player bar) each maintained their own medium→route mapping. A fourth medium or a route rename would require hunting all of them. Centralising into one static helper makes the medium→detail-page contract explicit in one place and removes the risk of call sites drifting.
|
||||
- **Shape:** `ReleaseRoutes.cs` (new, `DeepDrftPublic.Client/Common/`): static class, two `DetailHref` overloads. `ArchiveView.razor`: private `DetailHref` switch removed; calls delegate to `ReleaseRoutes.DetailHref`. `TrackMetaLabel.razor` and `AlbumsView.razor.cs`: updated to call `ReleaseRoutes.DetailHref`. `TrackRedirect.razor` (new, `DeepDrftPublic.Client/Pages/`, route `/tracks/{Id:long}`): fetches release via `IReleaseDataService.GetById`, resolves through `ReleaseRoutes.DetailHref`, navigates with `replace: true`; falls back to `/cuts` on unknown id.
|
||||
|
||||
---
|
||||
|
||||
### §3.4 PlayAlbum queue seam — wired (follow-up to 11.A + 11.F)
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** The §3.4 integration seam between 11.A (`/cuts/{id}`) and 11.F (`IQueueService`) is now closed. `CutDetail.razor` consumes the cascaded `IQueueService`: header Play calls `Queue.PlayRelease(ViewModel.Tracks, 0)` (loads the full album as an ordered queue starting at track 0); per-row play calls `Queue.PlayRelease(ViewModel.Tracks, index)` (album continues from the chosen track). The currently-playing row still toggles play/pause via `IPlayerService.TogglePlayPause`. Null-safe fallback to `PlayerService.SelectTrackStreaming` is retained for prerender/non-interactive contexts where the queue cascade is absent. Consumption-only — no changes to `IQueueService`, `QueueService`, the player, or `AudioPlayerProvider`.
|
||||
- **Why:** 11.A shipped with a documented one-line seam in `PlayAlbum()` noting the future swap to `IQueueService.PlayRelease`. 11.F landed the queue. This follow-up closes the seam so the Cut detail page actually plays the full album as an ordered queue rather than single-track only.
|
||||
- **Shape:** `CutDetail.razor` (`DeepDrftPublic.Client/Pages/`) adds `[CascadingParameter] public IQueueService? Queue { get; set; }` and rewrites `PlayAlbum()` and `PlayTrack()` to branch on `Queue is not null` before falling back to direct `SelectTrackStreaming`.
|
||||
|
||||
---
|
||||
|
||||
### 11.A — `/cuts/{id}` album-detail page
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** New public Cut album detail page at `/cuts/{id}`. Composes `ReleaseDetailScaffold` via a generalized `Header` slot (left meta: name, artist, genre, year, Play + Share) and a `BodyContent` slot (right theme-bordered cover image; `TrackNumber`-ordered track list with per-row play). `CutDetailBase` carries the multi-track prerender bridge across the prerender→WASM seam (following the `ReleaseDetailBase` pattern); `CutDetailViewModel` holds the loaded state. Header Play and per-row play wire into the existing single-slot `IPlayerService` (`SelectTrackStreaming` / toggle). A `PlayAlbum` method contains a documented one-line seam for a future swap to `IQueueService.PlayRelease` — queue integration is a deferred follow-up, not live in this wave. Reuses the existing `GetById` release endpoint and the `releaseId`-filtered track page; no new API surface. Track ordinal (`TrackNumber`) was verified already built and consumed correctly — no new schema.
|
||||
- **Why:** Cuts (Studio releases) had no single-release detail page — `/cuts` cards navigated to `/tracks?album={title}` (a track-cardinal view). This makes the album the primary navigable unit on the public site for Cut releases, completing the per-medium detail page set alongside `/sessions/{id}` and `/mixes/{id}`.
|
||||
- **Shape:** New `CutDetail.razor` + `CutDetailBase.cs` + `CutDetailViewModel.cs` in `DeepDrftPublic.Client`. Composes `ReleaseDetailScaffold` with `Header` and `BodyContent` render fragments. Track list ordered by `TrackNumber`; per-row play binds to `IPlayerService` (`SelectTrackStreaming` / toggle). `PersistentComponentState` bridge is owned by `CutDetailBase` (keyed `"cut-tracks"`).
|
||||
|
||||
---
|
||||
|
||||
### 11.F — play-queue `IQueueService`
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** A separate `IQueueService` orchestrating album (ordered multi-track) playback above the single-slot player. Holds an ordered track list, a current index, and `Next()`/`Previous()` skip navigation wired into the player-bar controls (skip-forward gated on `HasNext`, skip-back gated on `HasPrevious`). Auto-advance via a new `IPlayerService.TrackEnded` event (raised only on organic end-of-stream): `OnTrackEnded` advances the queue only when `player.CurrentTrack.Id == queue.Current.Id` — an `Id`-equality cross-advance guard that prevents a superseding direct-play call from accidentally advancing the queue. `Attach(IStreamingPlayerService)` binds the queue to the player (called once by `AudioPlayerProvider`); loading a track list into the queue is a separate concern via `PlayRelease`. No detach-on-direct-Play mechanism. Provider-owned and cascaded — not DI-registered, by design. Surface members: `Items`, `CurrentIndex`, `Current`, `HasNext`, `HasPrevious`, `QueueChanged` event; methods `Attach(IStreamingPlayerService)`, `PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)`, `Next()`, `Previous()`, `Enqueue`, `EnqueueRange`, `Clear`.
|
||||
- **Why:** The player was single-slot only. The Cut album detail page (11.A) needs "play album" — an ordered queue that advances through tracks end-to-end. Absorbs the queue half of Phase 1 §1.3 (the preload half remains deferred). Prerequisite for a future `PlayAlbum` integration in 11.A; also exposes skip controls in the player bar.
|
||||
- **Shape:** New `IQueueService` interface + `QueueService` implementation in `DeepDrftPublic.Client`. `IPlayerService` gains `TrackEnded` event. Player bar gains skip-forward and skip-back controls bound to `IQueueService.Next()`/`Previous()`, gated on `HasNext`/`HasPrevious`. `Attach(IStreamingPlayerService)` wires the queue to the player without constructor growth; `PlayRelease(IEnumerable<TrackDto>, int)` loads an ordered track list and starts playback.
|
||||
|
||||
---
|
||||
|
||||
### 11.G — release Description schema slice
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** New nullable `ReleaseEntity.Description` column (plain text, max 4000 characters) on the base release table, mirrored in `ReleaseDto.Description`. `TrackConverter` round-trip updated. Write-path plumbing threaded wherever `Genre` is: `UpdateTrackMetadataRequest` + upload form fields + `UnifiedTrackService` + `TrackManager` update path. CMS `AlbumHeaderFields` gains a multiline `MudTextField` for Description input. Detail-page rendering deliberately deferred — Description degrades cleanly (null renders nothing) so schema and render can land in either order. EF migration `20260616035252_AddReleaseDescription` authored; **not yet applied** (Daniel-gated).
|
||||
- **Why:** Commitment 8 from the Phase 11 spec. No `Description` member existed on `ReleaseEntity` or `ReleaseDto` prior to this wave. A base-release free-text field (uniform across all media) lets admins describe a release context, inspiration, or credits. Lives on the base release, not a per-medium satellite (consistent with Phase 9's open/closed spine).
|
||||
- **Shape:** `ReleaseEntity.Description` nullable string in `DeepDrftData`. EF `ReleaseConfiguration` adds max-length annotation (4000). `ReleaseDto.Description` nullable string. `TrackConverter` updated to map the field on both read and write paths. `UpdateTrackMetadataRequest` gains `Description` field. Upload form (multipart) gains `description` form field. `AlbumHeaderFields.razor` gains a multiline `MudTextField`. Migration `20260616035252_AddReleaseDescription` authored but not applied.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Mix detail Hero + MetaContent overlay (presentation only)
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** Extracted a shared **`ReleaseHeroOverlay`** presentational component (`DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` + `.razor.css`) that both Session detail and Mix detail now consume — one source of truth for the background-image hero with all metadata overlaid (genre/date + share top row; cover-thumb/title/artist + play bottom row). Mix detail's hero is now an overlaid ~600px square cover, replacing the stacked masthead + 220px cover + meta-divider block, freeing more canvas for the lava-lamp visualizer. The Phase 10 reframe top row (`TopRowCenter` controls + lava-lamp `TopRightAction`) is preserved unchanged. `ReleaseDetailScaffold` gained a `bool ShowHeader = true` gate (slot-consistent with `ShowMeta`/`ShowShareRow`) to suppress the duplicate masthead for Mix. The background-image surface is a plain `<div class="release-hero-img">` (no `MudPaper`).
|
||||
- **Why:** The Mix detail page carried a stacked masthead + 220px cover + meta-divider block that kept the overlay aesthetic of Sessions from applying and wasted vertical canvas the lava-lamp visualizer could use. Extracting `ReleaseHeroOverlay` delivered the DRY win (one overlay, no duplication) and brought Mix into the same design family as Sessions, while the `ShowHeader` gate gave the scaffold a clean suppression mechanism rather than an empty-fragment hack.
|
||||
- **Shape:** New `DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` (+ `.razor.css`) — the shared overlay, parameterized for `HeroImageKey`, `PlaceholderIcon`, `CoverThumbKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `ShareContent` slot, `PlayContent` slot, `Class`. `SessionDetail.razor` — inline hero-overlay replaced by `<ReleaseHeroOverlay ... />`; behavior-preserving lift. `SessionDetail.razor.css` — overlay cascade moved to the shared component; page-specific rules remain. `MixDetail.razor` — old `.mix-detail-cover` `Hero` slot replaced with `<ReleaseHeroOverlay Class="mix-hero" ... />` in the scaffold's `Hero` slot; `MetaContent` dropped (metadata now in the overlay); share row moved into the overlay's `ShareContent` slot; scaffold used with `ShowHeader="false"`. `MixDetail.razor.css` — `mix-hero` square/medium sizing override added; `.mix-detail-cover` removed. `ReleaseDetailScaffold.razor` — `bool ShowHeader = true` gate added around the default header region.
|
||||
|
||||
Full design, DRY trade-offs, acceptance criteria, and the open questions resolved during implementation: `product-notes/mix-detail-hero-overlay.md`.
|
||||
|
||||
---
|
||||
|
||||
## CMS Grid Refinements
|
||||
|
||||
### `CmsAlbumBrowser` special-action column promotion
|
||||
|
||||
**Landed:** 2026-06-15 on branch `cms-special-action-columns`.
|
||||
|
||||
Follow-on refinement of 8.C: the `RenderFragment<ReleaseDto>? RowActions` slot that 8.C introduced into `CmsAlbumBrowser` was replaced by a dedicated, header-labelled column model so that medium-specific actions (Mix waveform, Session hero) each appear in their own named grid column rather than being merged into the shared Actions cell.
|
||||
|
||||
- **What:** `CmsAlbumBrowser.razor` removed the `[Parameter] public RenderFragment<ReleaseDto>? RowActions { get; set; }` slot. In its place: `[Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; }` (defaulting to `Array.Empty<SpecialActionColumn>()`). `SpecialActionColumn` is a new `sealed record` (`string Header`, `RenderFragment<ReleaseDto> Cell`) in `DeepDrftManager/Components/Pages/Tracks/SpecialActionColumn.cs`. The grid renders one dedicated `<MudTh>` per declared column (between the Tracks header and the Actions header) and one `<MudTd>` per row per column. Child-row colspan moves from the hardcoded `9` to a computed `ColumnCount` property (`private const int BaseColumnCount = 9; private int ColumnCount => BaseColumnCount + SpecialColumns.Count`).
|
||||
- **Why:** Merging a per-medium affordance into the generic Actions cell forced the admin to parse mixed content in a single column. Promoting each to its own labelled column gives the grid a discoverable header for every action kind and makes it obvious at a glance which column is the Waveform column vs. the Actions column.
|
||||
- **Shape:** `CmsMixBrowser` declares one column: `new SpecialActionColumn("Waveform", WaveformCell)` — the Mix waveform generate/regenerate button with status icon. `CmsSessionBrowser` declares one column: `new SpecialActionColumn("Hero", HeroCell)` — the Session hero thumbnail preview plus set/replace upload button. `CmsCutBrowser` and the ALL-releases grid (`CmsAllReleasesGrid`) declare none; their column count and rendering are unchanged. Both callers allocate `_specialColumns` once in `OnInitialized` (field initializers cannot reference instance members; initialization is deferred to the first lifecycle hook). No change to `CmsMediumBrowserBase.cs`, `TrackList.razor`, or any other file.
|
||||
|
||||
**Completion note:** `CmsAlbumBrowser.razor` — `RowActions` parameter removed; `SpecialColumns` parameter added; `BaseColumnCount = 9` constant + `ColumnCount` computed property added; header and row loops updated to `foreach (var column in SpecialColumns)`. `SpecialActionColumn.cs` (new file, `DeepDrftManager/Components/Pages/Tracks/`): `public sealed record SpecialActionColumn(string Header, RenderFragment<ReleaseDto> Cell)`. `CmsMixBrowser.razor` — `RowActions` fragment replaced with `_specialColumns` field (allocated in `OnInitialized`) passed via `SpecialColumns="_specialColumns"`. `CmsSessionBrowser.razor` — same pattern. `CmsCutBrowser.razor` and `CmsAllReleasesGrid.razor` — no change (declare no special columns). No automated tests (no bUnit harness in DeepDrftTests; consistent with all prior Wave 8 / post-Phase-9 CMS tracks).
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Release Medium Types
|
||||
|
||||
### 9.7 Wave 7 — Domain Invariant Hardening: per-medium track cardinality
|
||||
@@ -34,11 +168,11 @@ The single-track-per-release rule for Session/Mix is enforced only in the CMS fo
|
||||
|
||||
---
|
||||
|
||||
### 9.8 Wave 8 — Remediation (Phase-9-completion gate met: 8.A–8.J + 8.L landed; 8.K post-Phase-9, 8.M follow-on pending)
|
||||
### 9.8 Wave 8 — Remediation (fully landed: 8.A–8.J + 8.L, 8.M, 8.K)
|
||||
|
||||
**Landed:** 2026-06-13 on dev (eleven tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L).
|
||||
**Landed:** 2026-06-13 on dev (eleven tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L); 8.M on 2026-06-14; 8.K on 2026-06-14.
|
||||
|
||||
Daniel tested the landed Phase 9 surface (Waves 1–7) and produced a punch-list. Wave 8 is remediation — the gap between what the specs *built* and what hands-on use *wants*. Full design, acceptance criteria, and dependencies: `product-notes/phase-9-wave-8-remediation.md`. The wave spans CMS, public site, and label polish. The Phase-9-completion gate (8.A–8.J + 8.L) is now fully met; 8.K (Mix Visualizer) is pulled out of Phase-9-completion scope (post-Phase-9 wave, design-complete); 8.M (legacy-form retirement) is a code-surface-reduction follow-on that trails.
|
||||
Daniel tested the landed Phase 9 surface (Waves 1–7) and produced a punch-list. Wave 8 is remediation — the gap between what the specs *built* and what hands-on use *wants*. Full design, acceptance criteria, and dependencies: `product-notes/phase-9-wave-8-remediation.md`. The wave spans CMS, public site, and label polish. The Phase-9-completion gate (8.A–8.J + 8.L) was met on 2026-06-13; 8.M (legacy-form retirement follow-on) landed 2026-06-14; 8.K (Mix Visualizer redesign, post-Phase-9 wave, designed-complete before Phase 9 closed) landed 2026-06-14. Wave 8 is fully complete.
|
||||
|
||||
**8.A — Release Archive as medium tabs, not cards**
|
||||
|
||||
@@ -156,6 +290,26 @@ Daniel tested the landed Phase 9 surface (Waves 1–7) and produced a punch-list
|
||||
|
||||
---
|
||||
|
||||
**8.M — Retire the legacy single-track CMS forms**
|
||||
|
||||
- **What:** Retire `TrackNew` (`/tracks/new`) and `TrackEdit` (`/tracks/{Id:long}`) as standalone authoring forms in `DeepDrftManager`. Their responsibility is absorbed by `BatchUpload` / `BatchEdit`'s single-track branch.
|
||||
- **Why:** The legacy forms were a duplicate code surface. Folding their function into the batch forms reduces form surface and removes the addressing-model gap that existed between `TrackEdit` (addressed by track id) and `BatchEdit` (addressed by release title). Daniel's decision: "consolidate the forms and reduce the code surface" (2026-06-13).
|
||||
- **Shape:** **Option 2 (Daniel's decision):** `BatchEdit` gained a track-addressed route `/tracks/{TrackId:long}/edit` that resolves the track to its parent release via `GetByIdAsync`, loads the release through the existing release-load path, and pre-selects the addressed track's row (`ResolveInitialSelection` matches by `Id`, falls back to row 0). The existing release-title route (`/tracks/album/{AlbumName}/edit`) is untouched. The two legacy components were reduced to thin redirect shims (not hard-deleted, to guard bookmarks): `/tracks/new` → `/tracks/upload`; `/tracks/{Id}` → `/tracks/{Id}/edit`. `CmsTrackGrid`'s per-row Edit now targets `/tracks/{id}/edit`. Files changed (6): `BatchEdit.razor`, `BatchUpload.razor`, `CmsTrackGrid.razor`, `SessionFields.razor`, `TrackEdit.razor`, `TrackNew.razor`. No new component, no public-site change, no constructor growth, no `IServiceProvider`.
|
||||
|
||||
**Completion note:** `BatchEdit.razor` gained a second `@page` route `/tracks/{TrackId:long}/edit`; `OnInitializedAsync` uses `GetByIdAsync` when `TrackId` is set, resolves the parent release, loads through the existing release-load path, and calls `ResolveInitialSelection` which matches by `Id` (falls back to row 0) to pre-select the addressed track's row. The existing `/tracks/album/{AlbumName}/edit` route and its load path are untouched. `TrackEdit.razor` and `TrackNew.razor` were each reduced to thin redirect shims — `TrackNew` redirects `/tracks/new` → `/tracks/upload`; `TrackEdit` redirects `/tracks/{Id}` → `/tracks/{Id}/edit` — preserving inbound bookmarks without keeping dead form logic. `CmsTrackGrid.razor` per-row Edit link updated from `/tracks/{id}` to `/tracks/{id}/edit`. `SessionFields.razor` and `BatchUpload.razor` received minor coordinating edits. Build clean. No automated tests (DeepDrftTests has no bUnit harness / no DeepDrftManager reference — consistent with prior Wave 8 tracks). All acceptance criteria met; legacy `TrackNew`/`TrackEdit` authoring forms retired; track-addressed edit route live on `BatchEdit`.
|
||||
|
||||
---
|
||||
|
||||
**8.K — Mix Visualizer redesign (post-Phase-9 wave)**
|
||||
|
||||
- **What:** Replace the static SVG waveform silhouette on the Mix detail page with a windowed, playback-coupled, bottom-to-top scrolling Canvas 2D animation; simultaneously switch Mix loudness datum capture from a fixed 2048-bucket count to a duration-derived constant-time-resolution scheme. Strictly read-only (no seek seam); theme-aware glassy gradient aesthetic (lava-lamp idiom, MudBlazor palette, live dark-mode responsive).
|
||||
- **Why:** The static SVG silhouette did not communicate playback progress or the shape of the material at any useful zoom level. Long mixes were under-sampled at fixed 2048 buckets — the visualizer design called for ~333 samples/sec so max-zoom detail is legible. The redesign gives Mix detail pages their signature dynamic visual and makes the waveform datum meaningfully dense.
|
||||
- **Shape:** Two waves. Wave 1 (datum §F): bucket count becomes `ceil(durationSeconds × 333)`, clamped `[2048, 2_000_000]`; pure helper `MixWaveformResolution.cs` (`BucketCountForDuration`, named constants `SamplesPerSecond`/`MinBucketCount`/`MaxBucketCount`); `UnifiedReleaseService.TriggerMixWaveformAsync` derives the count from `audio.Duration`; fixed `MixWaveformBucketCount = 2048` constant removed. Single high-density datum (not tiered/mipmap — Daniel's decision). Backward-compatible: existing 2048-bucket mixes still render coarsely; re-running the Generate trigger re-captures at new density. Wave 2 (renderer §A/B/C/D/E): `MixWaveformVisualizer` rewritten from static SVG to Canvas 2D scrolling animation driven by a `requestAnimationFrame` loop in new TS interop module `MixVisualizer.ts` (`DeepDrftPublic/Interop/visualizer/`). Guitar-Hero zoom coupling anchored at 0.333 s (1 quarter note @ 180 BPM max-zoom), range 0.333 s → 30 s, default-open 10 s. rAF loop gated on is-playing (idle on pause; one-shot redraws on zoom/theme/datum/resize while idle). Sample↔time mapping uses the DTO's `BucketCount` and the mix duration (sourced from the cascaded player, gated to the mix's `TrackId`) — no fixed-2048 assumption. New `MixZoomMapping.cs` (pure log-scaled zoom↔seconds) and `MixVisualizerZoomState.cs` (scoped, session-persistent, resets on fresh load, registered in `Startup.cs`); `MixDetail.razor` passes `TrackId`. Inert `OnSeek` + two-way `PlaybackPosition` seam dropped; `PlaybackPosition` is one-way input; `ReleaseId` self-fetches the datum. No `@rendermode` override, no constructor growth, no `IServiceProvider`; component CSS scoped.
|
||||
|
||||
**Completion note:** Wave 1 landed: `DeepDrftContent/Processors/MixWaveformResolution.cs` (new, pure helper with `BucketCountForDuration`, `SamplesPerSecond = 333`, `MinBucketCount = 2048`, `MaxBucketCount = 2_000_000`); `UnifiedReleaseService.TriggerMixWaveformAsync` derives bucket count from `audio.Duration` via the new helper; fixed `MixWaveformBucketCount = 2048` constant removed. `WaveformProfileDto.BucketCount` now varies per-mix. 8 unit tests in `MixWaveformResolutionTests.cs`. Wave 2 landed: `MixWaveformVisualizer` rewritten as a Canvas 2D scrolling component; `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` (new TS module) owns canvas, datum decode, rAF loop, scroll/zoom/compositing math, and dark-mode responsive theming. `MixZoomMapping.cs` and `MixVisualizerZoomState.cs` (new); zoom state registered as scoped in `Startup.cs`; `MixDetail.razor` passes `TrackId`. Two-way `PlaybackPosition` binding dropped; one-way input only. No automated tests for Wave 2 (DeepDrftTests references DeepDrftContent/DeepDrftData, not DeepDrftPublic.Client — consistent with prior public-site UI waves). Design spec: `product-notes/phase-9-mix-visualizer-redesign.md`. All acceptance criteria met; Wave 8 track 8.K completes the Mix Visualizer redesign and closes Wave 8 in full.
|
||||
|
||||
---
|
||||
|
||||
### 9.6 Wave 6 — Gap Closure
|
||||
|
||||
**Landed:** 2026-06-13 on dev.
|
||||
|
||||
@@ -150,7 +150,7 @@ Paged metadata list from SQL with optional filtering. Public browser data, same
|
||||
- **Query parameters**:
|
||||
- `page` (int, optional, default 1): 1-based page number.
|
||||
- `pageSize` (int, optional, default 20): tracks per page.
|
||||
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`.
|
||||
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`, `"TrackNumber"`. Defaults to `Id`.
|
||||
- `sortDescending` (bool, optional, default false): sort direction.
|
||||
- `q` (string, optional): search text filter (matches track name / artist).
|
||||
- `album` (string, optional): album title filter.
|
||||
@@ -220,19 +220,19 @@ Paged release list, optionally filtered to one medium. Public browse data, same
|
||||
- `sortDescending` (bool, optional, default false): sort direction.
|
||||
- Returns 200 with `PagedResult<ReleaseDto>` on success. Returns 400 if `medium` is unrecognized. Returns 500 on query error.
|
||||
|
||||
### GET api/release/{id:long} (unauthenticated)
|
||||
### GET api/release/{entryKey} (unauthenticated)
|
||||
|
||||
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`.
|
||||
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`. Addresses releases by their opaque public `EntryKey` (GUID string), never the int PK (Phase 11 §3e).
|
||||
|
||||
- **Route parameter `id`** (long): the SQL release ID.
|
||||
- **Response**: `ReleaseDto` with `Id`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
|
||||
- **Route parameter `entryKey`** (string): the release's `EntryKey` (the public handle).
|
||||
- **Response**: `ReleaseDto` with `Id`, `EntryKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
|
||||
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
|
||||
|
||||
### GET api/release/{id:long}/mix/waveform (unauthenticated)
|
||||
### GET api/release/{entryKey}/mix/waveform (unauthenticated)
|
||||
|
||||
Serves the high-res waveform datum for a Mix release as base64-encoded bytes. Mirrors `GET api/track/{id}/waveform` but reads from the `mix-waveforms` vault.
|
||||
Serves the high-res waveform datum for a Mix release as base64-encoded bytes. Mirrors `GET api/track/{id}/waveform` but reads from the `mix-waveforms` vault. Public read — addresses by the release `EntryKey` (§3e).
|
||||
|
||||
- **Route parameter `id`** (long): the SQL release ID.
|
||||
- **Route parameter `entryKey`** (string): the release's `EntryKey`.
|
||||
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
|
||||
- Returns 200 on success. Returns 404 if the release is not a Mix, carries no waveform key, or no datum is stored. Returns 500 on query/vault error.
|
||||
|
||||
|
||||
@@ -66,18 +66,20 @@ public class ReleaseController : ControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/release/{id}/mix/waveform (unauthenticated)
|
||||
// GET api/release/{entryKey}/mix/waveform (unauthenticated)
|
||||
// Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform
|
||||
// but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key,
|
||||
// or no datum is stored. Declared before the shorter "{id:long}" route for clarity.
|
||||
[HttpGet("{id:long}/mix/waveform")]
|
||||
public async Task<ActionResult> GetMixWaveform(long id, CancellationToken ct = default)
|
||||
// or no datum is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The
|
||||
// {entryKey} string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different
|
||||
// verb + constraint). Declared before the shorter "{entryKey}" route for clarity.
|
||||
[HttpGet("{entryKey}/mix/waveform")]
|
||||
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
var lookup = await _releaseService.GetByIdAsync(id, ct);
|
||||
var lookup = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetMixWaveform lookup failed for {ReleaseId}: {Error}", id, error);
|
||||
_logger.LogError("GetMixWaveform lookup failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return StatusCode(500, "Failed to load release");
|
||||
}
|
||||
|
||||
@@ -85,14 +87,14 @@ public class ReleaseController : ControllerBase
|
||||
var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey;
|
||||
if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey))
|
||||
{
|
||||
_logger.LogInformation("No mix waveform datum for release: {ReleaseId}", id);
|
||||
_logger.LogInformation("No mix waveform datum for release: {EntryKey}", entryKey);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
|
||||
if (bytes is null)
|
||||
{
|
||||
_logger.LogInformation("Mix waveform key set but no datum stored for release: {ReleaseId}", id);
|
||||
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
@@ -159,17 +161,18 @@ public class ReleaseController : ControllerBase
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// GET api/release/{id} (unauthenticated)
|
||||
// Single release with both metadata navs (nulls for non-matching media). Declared after the longer
|
||||
// "{id:long}/mix/waveform" routes so the segmented routes resolve first.
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult> GetReleaseById(long id, CancellationToken ct = default)
|
||||
// GET api/release/{entryKey} (unauthenticated)
|
||||
// Single release with both metadata navs (nulls for non-matching media). Public read — addresses by
|
||||
// the opaque EntryKey, not the int PK (§3e). Declared after the longer "{entryKey}/mix/waveform"
|
||||
// route so the segmented route resolves first.
|
||||
[HttpGet("{entryKey}")]
|
||||
public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _releaseService.GetByIdAsync(id, ct);
|
||||
var result = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetReleaseById failed for {ReleaseId}: {Error}", id, error);
|
||||
_logger.LogError("GetReleaseByEntryKey failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return StatusCode(500, "Failed to load release");
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@ public class TrackController : ControllerBase
|
||||
[FromForm] string? artist,
|
||||
[FromForm] string? album,
|
||||
[FromForm] string? genre,
|
||||
[FromForm] string? description,
|
||||
[FromForm] string? releaseDate,
|
||||
[FromForm] string? originalFileName,
|
||||
[FromForm] long createdByUserId,
|
||||
@@ -283,6 +284,7 @@ public class TrackController : ControllerBase
|
||||
artist,
|
||||
string.IsNullOrWhiteSpace(album) ? null : album,
|
||||
string.IsNullOrWhiteSpace(genre) ? null : genre,
|
||||
string.IsNullOrWhiteSpace(description) ? null : description,
|
||||
parsedReleaseDate,
|
||||
createdByUserId,
|
||||
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
|
||||
@@ -412,6 +414,7 @@ public class TrackController : ControllerBase
|
||||
release.Artist = request.Artist;
|
||||
release.Title = request.Album ?? string.Empty;
|
||||
release.Genre = request.Genre;
|
||||
release.Description = request.Description;
|
||||
release.ReleaseDate = request.ReleaseDate;
|
||||
|
||||
// ImagePath is tri-state: null = no change, "" = clear, value = set.
|
||||
|
||||
@@ -16,6 +16,7 @@ public record UpdateTrackMetadataRequest(
|
||||
string Artist,
|
||||
string? Album,
|
||||
string? Genre,
|
||||
string? Description,
|
||||
DateOnly? ReleaseDate,
|
||||
string? ImagePath = null,
|
||||
ReleaseType? ReleaseType = null,
|
||||
|
||||
@@ -17,10 +17,6 @@ namespace DeepDrftAPI.Services;
|
||||
/// </summary>
|
||||
public class UnifiedReleaseService
|
||||
{
|
||||
// High-res bucket count for Mix waveforms — 4x the player-bar default (512), feeding the
|
||||
// public-site MixWaveformVisualizer.
|
||||
private const int MixWaveformBucketCount = 2048;
|
||||
|
||||
/// <summary>Error message returned when the Mix release has no linked track.</summary>
|
||||
public const string MixHasNoTrackMessage = "Mix release has no track.";
|
||||
|
||||
@@ -104,9 +100,11 @@ public class UnifiedReleaseService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch the Mix's track audio from the vault, compute a high-res (2048-bucket) waveform datum,
|
||||
/// store it in the MixWaveforms vault under the track's EntryKey, then point the release's Mix
|
||||
/// satellite at that same key. The datum key equals the track's EntryKey — the Mix is single-track.
|
||||
/// Fetch the Mix's track audio from the vault, compute a high-res waveform datum at a constant time
|
||||
/// resolution (≈333 samples/sec derived from the track's duration; see
|
||||
/// <see cref="MixWaveformResolution"/>), store it in the MixWaveforms vault under the track's
|
||||
/// EntryKey, then point the release's Mix satellite at that same key. The datum key equals the
|
||||
/// track's EntryKey — the Mix is single-track.
|
||||
/// </summary>
|
||||
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
|
||||
{
|
||||
@@ -149,8 +147,11 @@ public class UnifiedReleaseService
|
||||
return Result.CreateFailResult(MixTrackNoAudioMessage);
|
||||
}
|
||||
|
||||
// Duration-derived, constant-time-resolution capture (≈333 samples/sec) so long mixes are not
|
||||
// under-sampled by a fixed bucket count — see MixWaveformResolution / spec §F.
|
||||
var bucketCount = MixWaveformResolution.BucketCountForDuration(audio.Duration);
|
||||
var computed = await _waveformProfileService.ComputeAndStoreAsync(
|
||||
audio.Buffer, entryKey, MixWaveformBucketCount, VaultConstants.MixWaveforms);
|
||||
audio.Buffer, entryKey, bucketCount, VaultConstants.MixWaveforms);
|
||||
if (!computed)
|
||||
{
|
||||
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
|
||||
|
||||
@@ -57,6 +57,7 @@ public class UnifiedTrackService
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
DateOnly? releaseDate,
|
||||
long createdByUserId,
|
||||
string? originalFileName,
|
||||
@@ -106,8 +107,8 @@ public class UnifiedTrackService
|
||||
|
||||
// Resolve the release FK before persisting the track. An upload with an album lands on the
|
||||
// shared release (created on first sighting); an upload without one stays a loose track with
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides
|
||||
// on the release, not the track.
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
|
||||
// rides on the release, not the track.
|
||||
long? releaseId = null;
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
@@ -116,6 +117,7 @@ public class UnifiedTrackService
|
||||
Title = album,
|
||||
Artist = artist,
|
||||
Genre = genre,
|
||||
Description = description,
|
||||
ReleaseDate = releaseDate,
|
||||
ReleaseType = releaseType,
|
||||
Medium = medium,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Derives the bucket count for a Mix loudness datum from the audio's duration, so the stored
|
||||
/// profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
|
||||
///
|
||||
/// Rationale (phase-9 Mix Visualizer redesign spec §F): the max-zoom window shows one quarter note
|
||||
/// at 180 BPM = 333 ms of audio, and a smooth glassy curve wants ~100+ sample points across that
|
||||
/// window. A fixed 2048-bucket datum gives fractions of a sample per 333 ms window on any real-length
|
||||
/// mix (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
|
||||
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of mix
|
||||
/// length — the direct expression of "high enough resolution regardless of content length."
|
||||
///
|
||||
/// This is the orchestration-side derivation (duration → bucket count); the actual compute/store stays
|
||||
/// in <see cref="WaveformProfileService"/>, which is content-agnostic and parameterized by bucket count.
|
||||
/// </summary>
|
||||
public static class MixWaveformResolution
|
||||
{
|
||||
/// <summary>≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples.</summary>
|
||||
public const int SamplesPerSecond = 333;
|
||||
|
||||
/// <summary>
|
||||
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute mix at 333/s). Past this length we
|
||||
/// accept slightly-below-target density rather than an unbounded datum (spec §F mitigation #1).
|
||||
/// </summary>
|
||||
public const int MaxBucketCount = 2_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// Floor on bucket count. Keeps the historical 2048-bucket density as the minimum so a degenerate
|
||||
/// near-zero or very-short mix still yields a usable profile rather than zero/handful of buckets.
|
||||
/// </summary>
|
||||
public const int MinBucketCount = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a track's duration (seconds) to a bucket count of <c>ceil(durationSeconds × 333)</c>,
|
||||
/// clamped to [<see cref="MinBucketCount"/>, <see cref="MaxBucketCount"/>]. Non-finite or negative
|
||||
/// durations fall to the floor. A 60-minute mix → ~1.2M buckets; a 3-minute mix → ~60k.
|
||||
/// </summary>
|
||||
public static int BucketCountForDuration(double durationSeconds)
|
||||
{
|
||||
if (double.IsNaN(durationSeconds) || durationSeconds <= 0)
|
||||
return MinBucketCount;
|
||||
|
||||
// Guard against overflow before the cast: anything at/above the cap clamps anyway.
|
||||
var raw = Math.Ceiling(durationSeconds * SamplesPerSecond);
|
||||
if (raw >= MaxBucketCount)
|
||||
return MaxBucketCount;
|
||||
|
||||
var buckets = (int)raw;
|
||||
return buckets < MinBucketCount ? MinBucketCount : buckets;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,21 @@ namespace DeepDrftContent.Processors;
|
||||
/// <summary>
|
||||
/// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned,
|
||||
/// 16/24/32-bit signed little-endian), averages channels to mono, partitions the frames into
|
||||
/// equal time slices, takes the RMS of each slice, then peak-normalizes so the loudest bucket is 1.
|
||||
/// No external audio dependency — operates directly on the WAV data-chunk bytes.
|
||||
/// equal time slices, takes the RMS of each slice, applies a ~15 ms envelope-follower smoothing
|
||||
/// so the contour reads as a smooth curve rather than a spikey polygon, then peak-normalizes so
|
||||
/// the loudest bucket is 1. No external audio dependency — operates directly on the WAV data-chunk bytes.
|
||||
/// </summary>
|
||||
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
|
||||
{
|
||||
/// <summary>
|
||||
/// Envelope-follower time constant, seconds. ~15 ms is the smoothing target (Phase 10
|
||||
/// tuning, reduced from 50 ms which was over-smoothed): long enough to round off the
|
||||
/// per-bucket RMS spikes into a smooth ribbon contour, short enough that real loudness
|
||||
/// transients (kicks, drops) still read. Applied as a symmetric (forward+backward) one-pole
|
||||
/// filter so the smoothing introduces no time lag.
|
||||
/// </summary>
|
||||
public const double SmoothingTimeConstantSeconds = 0.005;
|
||||
|
||||
public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount)
|
||||
{
|
||||
if (bucketCount <= 0)
|
||||
@@ -64,16 +74,28 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
|
||||
counts[bucket]++;
|
||||
}
|
||||
|
||||
var peak = 0.0;
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
if (counts[i] > 0)
|
||||
{
|
||||
result[i] = Math.Sqrt(sumSquares[i] / counts[i]);
|
||||
if (result[i] > peak)
|
||||
{
|
||||
peak = result[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Envelope smoothing (~15 ms): round the spikey per-bucket RMS into a smooth contour before
|
||||
// peak-normalization, so the rendered ribbon reads as a continuous curve, not faceted polygons.
|
||||
// Each bucket spans (totalSeconds / bucketCount) of audio; the filter coefficient is derived
|
||||
// from that against the time constant so the smoothing is duration-aware, not a fixed window.
|
||||
var totalSeconds = (double)frameCount / sampleRate;
|
||||
var bucketSeconds = totalSeconds / bucketCount;
|
||||
SmoothEnvelope(result, bucketSeconds);
|
||||
|
||||
var peak = 0.0;
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
if (result[i] > peak)
|
||||
{
|
||||
peak = result[i];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +114,42 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symmetric one-pole envelope smoothing over the per-bucket loudness, in place. A forward pass
|
||||
/// then a backward pass cancels the single-pole phase lag, so the smoothed contour stays aligned
|
||||
/// with the audio (no rightward time shift). The coefficient <c>a = exp(−bucketSeconds / τ)</c>
|
||||
/// gives a ~<paramref name="bucketSeconds"/>-relative response targeting the ~15 ms time constant:
|
||||
/// each bucket blends <c>(1 − a)</c> of itself with <c>a</c> of the running envelope. A near-zero
|
||||
/// or non-finite bucket duration leaves the data untouched (nothing to smooth meaningfully).
|
||||
/// </summary>
|
||||
private static void SmoothEnvelope(double[] data, double bucketSeconds)
|
||||
{
|
||||
if (data.Length < 2 || bucketSeconds <= 0 || !double.IsFinite(bucketSeconds))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var a = Math.Exp(-bucketSeconds / SmoothingTimeConstantSeconds);
|
||||
// a→1 means buckets are far finer than τ (heavy smoothing); a→0 means each bucket already
|
||||
// spans ≫ τ, so smoothing is a no-op. Either extreme is handled by the blend below.
|
||||
|
||||
// Forward pass.
|
||||
var env = data[0];
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
env = a * env + (1 - a) * data[i];
|
||||
data[i] = env;
|
||||
}
|
||||
|
||||
// Backward pass (zero-phase): smooth the forward result in reverse so the net lag is zero.
|
||||
env = data[^1];
|
||||
for (var i = data.Length - 1; i >= 0; i--)
|
||||
{
|
||||
env = a * env + (1 - a) * data[i];
|
||||
data[i] = env;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one PCM sample at <paramref name="offset"/> to a normalized amplitude in [-1, 1].
|
||||
/// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian.
|
||||
|
||||
@@ -42,7 +42,10 @@ public class WaveformProfileService
|
||||
/// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
|
||||
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
|
||||
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
|
||||
/// pass a higher value (e.g., 2048) for the Mix high-res datum. Returns false (and logs) on any
|
||||
/// callers pass an explicit count for higher-resolution data — e.g. the Mix datum derives its count
|
||||
/// from the audio duration (≈333 samples/sec, see <c>MixWaveformResolution</c>) so long mixes are not
|
||||
/// under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
|
||||
/// does not itself decide the count. Returns false (and logs) on any
|
||||
/// failure — a missing profile is handled gracefully downstream, so callers on the upload path
|
||||
/// log-and-continue rather than failing the upload. Does not throw for expected failure modes.
|
||||
/// </summary>
|
||||
|
||||
@@ -21,6 +21,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
|
||||
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
|
||||
|
||||
// App-minted GUID-string public handle, configured exactly like TrackConfiguration's
|
||||
// entry_key: required, max 100, snake_case column. The unique index guarantees a release
|
||||
// resolves to one row by its public key.
|
||||
builder.Property(e => e.EntryKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
builder.HasIndex(e => e.EntryKey)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
builder.Property(e => e.Title)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -35,6 +47,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("genre");
|
||||
|
||||
// Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration).
|
||||
builder.Property(e => e.Description)
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnName("description");
|
||||
|
||||
builder.Property(e => e.ReleaseDate)
|
||||
.HasColumnName("release_date");
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ public interface IReleaseService
|
||||
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
|
||||
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The public addressing read: single release resolved by its opaque EntryKey (Phase 11 §3e). Both metadata navs included (nulls for non-matching media).</summary>
|
||||
Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut.</summary>
|
||||
Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260616035252_AddReleaseDescription")]
|
||||
partial class AddReleaseDescription
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseDescription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "description",
|
||||
table: "release",
|
||||
type: "character varying(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "description",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260616210143_AddReleaseEntryKey")]
|
||||
partial class AddReleaseEntryKey
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EntryKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseEntryKey : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 11.H — front the int PK with an app-minted GUID-string EntryKey (Phase 11 §3e). The
|
||||
// scaffolded single non-null-with-"" add is hand-edited into the three-step backfill the
|
||||
// spec requires (§3e.5(2)): existing rows must each get a UNIQUE, non-null key, so a shared
|
||||
// "" default would collide on the unique index. Add nullable → backfill a GUID per row →
|
||||
// mark non-null. No DB default is set on the final column: new rows are app-populated by
|
||||
// FindOrCreateRelease (Guid.NewGuid().ToString()), exactly as tracks mint their EntryKey.
|
||||
|
||||
// 1) Add the column nullable so the backfill can run before the NOT NULL constraint.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "entry_key",
|
||||
table: "release",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
// 2) Backfill a unique GUID string per existing row. gen_random_uuid()::text yields the
|
||||
// lowercase 36-char hyphenated shape Guid.NewGuid().ToString() produces, so migrated and
|
||||
// app-minted keys are indistinguishable. Per-row evaluation → each row gets a distinct key.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE \"release\" SET \"entry_key\" = gen_random_uuid()::text WHERE \"entry_key\" IS NULL;");
|
||||
|
||||
// 3) Now every row is populated and unique — enforce NOT NULL.
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "entry_key",
|
||||
table: "release",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(100)",
|
||||
oldMaxLength: 100,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_entry_key",
|
||||
table: "release",
|
||||
column: "entry_key",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_release_entry_key",
|
||||
table: "release");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "entry_key",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,17 @@ namespace DeepDrftData.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
@@ -138,6 +149,10 @@ namespace DeepDrftData.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EntryKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
|
||||
@@ -94,6 +94,20 @@ public class ReleaseManager : IReleaseService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _repository.GetByEntryKeyWithMetadataAsync(entryKey, cancellationToken);
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(
|
||||
entity is null ? null : TrackConverter.Convert(entity));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -95,6 +95,16 @@ public class ReleaseRepository
|
||||
.Include(r => r.MixMetadata)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// The public addressing read: resolve a release by its opaque EntryKey (Phase 11 §3e). Mirrors
|
||||
// GetByIdWithMetadataAsync but keys on the unique entry_key column — the int PK never reaches the
|
||||
// public surface. The resolved entity still carries its int Id for internal joins (track page).
|
||||
public async Task<ReleaseEntity?> GetByEntryKeyWithMetadataAsync(string entryKey, CancellationToken ct)
|
||||
=> await _context.Releases
|
||||
.Where(r => r.EntryKey == entryKey && !r.IsDeleted)
|
||||
.Include(r => r.SessionMetadata)
|
||||
.Include(r => r.MixMetadata)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on
|
||||
// list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary.
|
||||
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
|
||||
|
||||
@@ -19,11 +19,13 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
public static ReleaseDto Convert(ReleaseEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
EntryKey = entity.EntryKey,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
Title = entity.Title,
|
||||
Artist = entity.Artist,
|
||||
Genre = entity.Genre,
|
||||
Description = entity.Description,
|
||||
ReleaseDate = entity.ReleaseDate,
|
||||
ImagePath = entity.ImagePath,
|
||||
Medium = entity.Medium,
|
||||
@@ -50,11 +52,16 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
public static ReleaseEntity Convert(ReleaseDto dto) => new()
|
||||
{
|
||||
Id = dto.Id,
|
||||
// Round-trips the public handle. On the create path (FindOrCreateRelease) the DTO carries no
|
||||
// EntryKey yet, so that path overrides this with a freshly minted GUID — the same shape as the
|
||||
// natural-key (Title/Artist) override there.
|
||||
EntryKey = dto.EntryKey,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
UpdatedAt = dto.UpdatedAt,
|
||||
Title = dto.Title,
|
||||
Artist = dto.Artist,
|
||||
Genre = dto.Genre,
|
||||
Description = dto.Description,
|
||||
ReleaseDate = dto.ReleaseDate,
|
||||
ImagePath = dto.ImagePath,
|
||||
Medium = dto.Medium,
|
||||
|
||||
@@ -177,6 +177,9 @@ public class TrackManager
|
||||
// in releaseData so a typo upstream cannot create a release that won't be found again.
|
||||
var entity = TrackConverter.Convert(releaseData);
|
||||
entity.Id = 0;
|
||||
// Mint the public EntryKey app-side at creation — the identical call tracks make in
|
||||
// TrackContentService (Phase 11 §3e.4). The incoming DTO carries no key on the create path.
|
||||
entity.EntryKey = Guid.NewGuid().ToString();
|
||||
entity.Title = title;
|
||||
entity.Artist = artist;
|
||||
|
||||
@@ -281,6 +284,7 @@ public class TrackManager
|
||||
releaseEntity.Title = release.Title;
|
||||
releaseEntity.Artist = release.Artist;
|
||||
releaseEntity.Genre = release.Genre;
|
||||
releaseEntity.Description = release.Description;
|
||||
releaseEntity.ReleaseDate = release.ReleaseDate;
|
||||
releaseEntity.ImagePath = release.ImagePath;
|
||||
releaseEntity.Medium = release.Medium;
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudTextField Value="Description" ValueChanged="@((string v) => DescriptionChanged.InvokeAsync(v))"
|
||||
T="string" Label="Description" Lines="4" MaxLength="4000"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@@ -79,6 +84,8 @@
|
||||
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
|
||||
[Parameter] public string Genre { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> GenreChanged { get; set; }
|
||||
[Parameter] public string Description { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> DescriptionChanged { get; set; }
|
||||
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/tracks/album/{AlbumName}/edit"
|
||||
@page "/tracks/{TrackId:long}/edit"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@@ -32,6 +33,7 @@
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-Description="_description"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
@@ -111,8 +113,15 @@
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
// Release-title addressing (Album-mode batch Edit): loads the whole release by title.
|
||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||
|
||||
// Track-id addressing (Track-mode per-row Edit, §8.M): loads the addressed track's parent
|
||||
// release and pre-selects that track's row, so editing a single Cut track lands the admin on
|
||||
// the track they clicked rather than on the release with no row context. Null for the
|
||||
// release-title route. The two routes are mutually exclusive — only one segment binds.
|
||||
[Parameter] public long? TrackId { get; set; }
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _loading = true;
|
||||
@@ -129,6 +138,7 @@
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
@@ -154,12 +164,35 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Track-addressed entry (§8.M): resolve the addressed track to its parent release title,
|
||||
// then fall through to the shared release-load path below. The clicked track's id is held
|
||||
// for row pre-selection once the list is built.
|
||||
var albumName = AlbumName;
|
||||
if (TrackId is { } trackId)
|
||||
{
|
||||
var trackResult = await CmsTrackService.GetByIdAsync(trackId);
|
||||
if (!trackResult.Success || trackResult.Value is not { } track)
|
||||
{
|
||||
_loadError = trackResult.Messages.FirstOrDefault()?.Message ?? "Track not found.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
albumName = track.Release?.Title;
|
||||
if (string.IsNullOrEmpty(albumName))
|
||||
{
|
||||
_loadError = "This track has no parent release to edit.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A single page of 100 covers the full release (albums are small — same assumption as
|
||||
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
page: 1, pageSize: 100,
|
||||
sortColumn: "TrackNumber", sortDescending: false,
|
||||
album: AlbumName);
|
||||
album: albumName);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
@@ -171,15 +204,16 @@
|
||||
var tracks = result.Value.Items.ToList();
|
||||
if (tracks.Count == 0)
|
||||
{
|
||||
_loadError = $"No tracks found for release '{AlbumName}'.";
|
||||
_loadError = $"No tracks found for release '{albumName}'.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var release = tracks[0].Release;
|
||||
_albumName = AlbumName;
|
||||
_albumName = albumName;
|
||||
_artist = release?.Artist ?? string.Empty;
|
||||
_genre = release?.Genre ?? string.Empty;
|
||||
_description = release?.Description ?? string.Empty;
|
||||
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
||||
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
|
||||
_medium = release?.Medium ?? ReleaseMedium.Cut;
|
||||
@@ -203,10 +237,24 @@
|
||||
_tracks.RemoveRange(1, _tracks.Count - 1);
|
||||
}
|
||||
|
||||
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
|
||||
// Track-addressed entry pre-selects the clicked row (§8.M Option 2). For a multi-track Cut
|
||||
// the addressed track may be any ordinal; for single-track media it is always row 0 (the
|
||||
// collapse above leaves one row). Fall back to row 0 if the id is absent or trimmed away.
|
||||
_selectedIndex = ResolveInitialSelection();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private int ResolveInitialSelection()
|
||||
{
|
||||
if (_tracks.Count == 0) return -1;
|
||||
if (TrackId is { } trackId)
|
||||
{
|
||||
var addressed = _tracks.FindIndex(t => t.Id == trackId);
|
||||
if (addressed >= 0) return addressed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||
{
|
||||
_errorMessage = null;
|
||||
@@ -337,6 +385,7 @@
|
||||
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
|
||||
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
|
||||
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
|
||||
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
|
||||
|
||||
// For single-track media (Session/Mix) the track name is derived from the Release Name —
|
||||
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
|
||||
@@ -401,6 +450,7 @@
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
imagePathForUpdate,
|
||||
_releaseType,
|
||||
@@ -435,6 +485,7 @@
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
@@ -463,6 +514,7 @@
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
linkPath,
|
||||
_releaseType,
|
||||
@@ -471,7 +523,7 @@
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track persisted; cover can be linked via TrackEdit.
|
||||
// Non-blocking: track persisted; cover can be re-linked by re-editing.
|
||||
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
|
||||
row.TrackName, uploadResult.Value.Id);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-Description="_description"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
@@ -126,6 +127,7 @@
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
@@ -342,6 +344,7 @@
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_description) ? null : _description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
@@ -360,7 +363,7 @@
|
||||
else
|
||||
{
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with
|
||||
// a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use.
|
||||
// a follow-up metadata update — same two-step pattern BatchEdit uses.
|
||||
if (_imagePath is { } imgPath)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
@@ -369,6 +372,7 @@
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_description) ? null : _description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath,
|
||||
_releaseType,
|
||||
@@ -377,7 +381,7 @@
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track is persisted; cover art can be linked via TrackEdit.
|
||||
// Non-blocking: track is persisted; cover art can be re-linked by editing.
|
||||
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
|
||||
row.TrackName, result.Value.Id);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ else
|
||||
<MudTh>Release Date</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
|
||||
@foreach (var column in SpecialColumns)
|
||||
{
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">@column.Header</MudTh>
|
||||
}
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@@ -64,11 +68,14 @@ else
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
|
||||
@foreach (var column in SpecialColumns)
|
||||
{
|
||||
@* One dedicated cell per host-declared special-action column (Mix waveform, Session hero).
|
||||
The Cell fragment recovers its typed row state via the host's RowFor lookup. Sits between
|
||||
Tracks and Actions so the universal Edit/Delete stay rightmost. *@
|
||||
<MudTd DataLabel="@column.Header">@column.Cell(context.Release)</MudTd>
|
||||
}
|
||||
<MudTd DataLabel="Actions">
|
||||
@* Medium-specific row action (Session hero, Mix waveform) when a host supplies one;
|
||||
the ALL tab supplies none. Rendered before the shared edit/delete so the medium
|
||||
affordance reads left-to-right ahead of the universal actions. *@
|
||||
@RowActions?.Invoke(context.Release)
|
||||
<MudTooltip Text="Batch Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
@@ -88,7 +95,7 @@ else
|
||||
@if (context.IsExpanded)
|
||||
{
|
||||
<MudTr>
|
||||
<MudTd colspan="9" Style="padding: 0;">
|
||||
<MudTd colspan="@ColumnCount" Style="padding: 0;">
|
||||
@if (context.IsLoading)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
|
||||
@@ -126,12 +133,19 @@ else
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
// Optional per-row, medium-specific action slot (Session hero upload, Mix waveform generate),
|
||||
// rendered in the Actions cell ahead of the shared edit/delete buttons. The ALL tab leaves it
|
||||
// unset and renders the grid exactly as before. A per-medium host (CmsCut/Session/MixBrowser)
|
||||
// supplies it so the rich grid filtered to one medium keeps that medium's bespoke affordance —
|
||||
// the rich expand/delete/Type-chip/edit logic stays here, single-sourced, rather than forked.
|
||||
[Parameter] public RenderFragment<ReleaseDto>? RowActions { get; set; }
|
||||
// Zero or more dedicated, header-labelled special-action columns (Session hero upload, Mix waveform
|
||||
// generate), each rendered as its own header cell + per-row cell between the Tracks and Actions
|
||||
// columns. The ALL and Cut tabs leave this empty and render exactly as before — only the standard
|
||||
// columns plus Edit/Delete. A per-medium host supplies its bespoke affordances here so the rich
|
||||
// expand/delete/Type-chip/edit logic stays single-sourced in this grid rather than forked.
|
||||
[Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; } = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
// Base columns: expand, Art, Album, Artist, Genre, Release Date, Type, Tracks, Actions = 9.
|
||||
private const int BaseColumnCount = 9;
|
||||
|
||||
// Total rendered columns, driving the expanded child-row colspan so it always spans the full table
|
||||
// regardless of how many special-action columns the host declared.
|
||||
private int ColumnCount => BaseColumnCount + SpecialColumns.Count;
|
||||
|
||||
private List<AlbumRow> _rows = new();
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
@* CUTS tab content (Phase 9 §8.A/§8.C): the rich CmsAlbumBrowser grid filtered to Cut releases, so the
|
||||
tab carries expand-tracks, delete, the Type chip, and per-row edit identically to the ALL tab — no
|
||||
forked grid. Cuts have no medium-specific row action, so no RowActions slot is supplied; the grid
|
||||
renders its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
|
||||
forked grid. Cuts have no medium-specific action, so no SpecialColumns are supplied; the grid renders
|
||||
its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
|
||||
<CmsAlbumBrowser Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync" />
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
/// it (§8.C parity — reuse, don't fork). This base owns the loading flag, the medium-filtered load, the
|
||||
/// per-release row projection, and a cover-thumbnail helper; subclasses supply the <see cref="Medium"/>,
|
||||
/// an error noun, and their bespoke per-row action (Session hero upload, Mix waveform generate) via the
|
||||
/// rich grid's <c>RowActions</c> slot, looking their action-state row up with <see cref="RowFor"/>.
|
||||
/// rich grid's <c>SpecialColumns</c> column model, looking their action-state row up with <see cref="RowFor"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRow">The subclass's row model wrapping a <see cref="ReleaseDto"/> plus its
|
||||
/// medium-specific action state (upload/generate flags). The rich grid renders from the bare
|
||||
@@ -42,8 +42,8 @@ public abstract class CmsMediumBrowserBase<TRow> : ComponentBase where TRow : cl
|
||||
// it never sees TRow. Rebuilt on every (re)load so the grid re-projects against a fresh reference.
|
||||
protected IReadOnlyList<ReleaseDto> Releases { get; private set; } = Array.Empty<ReleaseDto>();
|
||||
|
||||
// release.Id → action-state row, so a RowActions fragment (which the grid hands a ReleaseDto) can
|
||||
// recover its TRow. Rebuilt alongside Rows so a refresh never leaves a stale row behind.
|
||||
// release.Id → action-state row, so a SpecialColumns cell delegate (which the grid hands a ReleaseDto)
|
||||
// can recover its TRow. Rebuilt alongside Rows so a refresh never leaves a stale row behind.
|
||||
private Dictionary<long, TRow> _rowsById = new();
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
@* Embedded as the MIXES tab content of the Release Archive (Phase 9 §8.A), and still routable at
|
||||
/tracks/mixes for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Mixes (§8.C
|
||||
parity: expand-tracks, delete, Type chip, per-row edit), with the Mix waveform generate supplied as
|
||||
its medium-specific RowActions slot so that affordance survives the move off the thin table. When
|
||||
embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is
|
||||
suppressed; the standalone route keeps it. The waveform affordance (9.5.E) is preserved in both. *@
|
||||
its medium-specific special-action column so that affordance survives the move off the thin table.
|
||||
When embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive"
|
||||
button) is suppressed; the standalone route keeps it. The waveform affordance (9.5.E) is preserved
|
||||
in both. *@
|
||||
@if (Embedded)
|
||||
{
|
||||
@GridContent
|
||||
@@ -52,46 +53,59 @@ else
|
||||
protected override ReleaseDto ReleaseOf(MixRow row) => row.Release;
|
||||
|
||||
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
|
||||
// both branches above render the same markup without duplication. The waveform generate is the Mix's
|
||||
// medium-specific RowActions content; the grid hands it each release, and RowFor recovers the
|
||||
// matching MixRow's generate state.
|
||||
// both branches above render the same markup without duplication. The Mix declares one dedicated
|
||||
// "Waveform" special-action column; the grid renders it between Tracks and Actions, handing the cell
|
||||
// each release, and RowFor recovers the matching MixRow's generate state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync">
|
||||
<RowActions Context="release">
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
SpecialColumns="_specialColumns" />;
|
||||
|
||||
// Allocated once per component instance in OnInitialized (field initializers cannot reference
|
||||
// instance members, so initialization is deferred to the first lifecycle hook).
|
||||
private IReadOnlyList<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_specialColumns = new[] { new SpecialActionColumn("Waveform", WaveformCell) };
|
||||
base.OnInitialized();
|
||||
}
|
||||
|
||||
// Per-row cell for the dedicated "Waveform" column: status icon plus generate/regenerate button with
|
||||
// progress. Recovers the typed MixRow via RowFor; skips rendering for a release not on the page.
|
||||
private RenderFragment<ReleaseDto> WaveformCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
@if (row.HasWaveform)
|
||||
{
|
||||
@if (row.HasWaveform)
|
||||
<MudTooltip Text="Waveform generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="No waveform — incomplete">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||
Disabled="@row.IsGenerating"
|
||||
OnClick="@(() => GenerateWaveformAsync(row))">
|
||||
@if (row.IsGenerating)
|
||||
{
|
||||
<MudTooltip Text="Waveform generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="No waveform — incomplete">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
|
||||
}
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.GraphicEq"
|
||||
Disabled="@row.IsGenerating"
|
||||
OnClick="@(() => GenerateWaveformAsync(row))">
|
||||
@if (row.IsGenerating)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
|
||||
}
|
||||
</MudButton>
|
||||
}
|
||||
</RowActions>
|
||||
</CmsAlbumBrowser>;
|
||||
</MudButton>
|
||||
}
|
||||
</text>;
|
||||
|
||||
private async Task GenerateWaveformAsync(MixRow row)
|
||||
{
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
@* Embedded as the SESSIONS tab content of the Release Archive (Phase 9 §8.A), and still routable at
|
||||
/tracks/sessions for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Sessions
|
||||
(§8.C parity: expand-tracks, delete, Type chip, per-row edit), with the Session hero upload supplied
|
||||
as its medium-specific RowActions slot so that affordance survives the move off the thin table. When
|
||||
embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is
|
||||
suppressed; the standalone route keeps it. The hero affordance (9.5.E) is preserved in both contexts. *@
|
||||
as its medium-specific special-action column so that affordance survives the move off the thin table.
|
||||
When embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive"
|
||||
button) is suppressed; the standalone route keeps it. The hero affordance (9.5.E) is preserved in
|
||||
both contexts. *@
|
||||
@if (Embedded)
|
||||
{
|
||||
@GridContent
|
||||
@@ -53,48 +54,61 @@ else
|
||||
protected override ReleaseDto ReleaseOf(SessionRow row) => row.Release;
|
||||
|
||||
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
|
||||
// both branches above render the same markup without duplication. The hero upload is the Session's
|
||||
// medium-specific RowActions content; the grid hands it each release, and RowFor recovers the
|
||||
// matching SessionRow's upload state.
|
||||
// both branches above render the same markup without duplication. The Session declares one dedicated
|
||||
// "Hero" special-action column; the grid renders it between Tracks and Actions, handing the cell each
|
||||
// release, and RowFor recovers the matching SessionRow's upload state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync">
|
||||
<RowActions Context="release">
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
SpecialColumns="_specialColumns" />;
|
||||
|
||||
// Allocated once per component instance in OnInitialized (field initializers cannot reference
|
||||
// instance members, so initialization is deferred to the first lifecycle hook).
|
||||
private IReadOnlyList<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_specialColumns = new[] { new SpecialActionColumn("Hero", HeroCell) };
|
||||
base.OnInitialized();
|
||||
}
|
||||
|
||||
// Per-row cell for the dedicated "Hero" column: thumbnail preview plus set/replace upload button with
|
||||
// progress. Recovers the typed SessionRow via RowFor; skips rendering for a release not on the page.
|
||||
private RenderFragment<ReleaseDto> HeroCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
|
||||
{
|
||||
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
|
||||
{
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
Accept="image/*"
|
||||
FilesChanged="@(file => UploadHeroAsync(row, file))"
|
||||
Disabled="@row.IsUploading">
|
||||
<ActivatorContent>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Image"
|
||||
Disabled="@row.IsUploading">
|
||||
@if (row.IsUploading)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Uploading…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</ActivatorContent>
|
||||
</MudFileUpload>
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
|
||||
}
|
||||
</RowActions>
|
||||
</CmsAlbumBrowser>;
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
Accept="image/*"
|
||||
FilesChanged="@(file => UploadHeroAsync(row, file))"
|
||||
Disabled="@row.IsUploading">
|
||||
<ActivatorContent>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Image"
|
||||
Disabled="@row.IsUploading">
|
||||
@if (row.IsUploading)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Uploading…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</ActivatorContent>
|
||||
</MudFileUpload>
|
||||
}
|
||||
</text>;
|
||||
|
||||
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
|
||||
{
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/{context.Id}")" />
|
||||
Href="@($"/tracks/{context.Id}/edit")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@* Session-medium fields. When AllowHeroUpload is true (BatchUpload create path), the hero image
|
||||
file picker is shown — the file is held by the parent and POSTed after the release is created.
|
||||
When false (edit paths: BatchEdit, TrackEdit, TrackNew), the original guidance alert is rendered
|
||||
When false (edit path: BatchEdit), the original guidance alert is rendered
|
||||
instead, directing the admin to the Sessions browser per-row replace action. This gate prevents
|
||||
a dead/inert control on edit forms that do not wire the hero callbacks. *@
|
||||
@if (AllowHeroUpload)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
|
||||
/// <summary>
|
||||
/// A dedicated, header-labelled grid column for a medium-specific row affordance (e.g. Mix waveform
|
||||
/// generate, Session hero upload) in <see cref="CmsAlbumBrowser"/>. A per-medium host declares zero or
|
||||
/// more of these; the grid renders one extra header cell and one extra per-row cell for each, positioned
|
||||
/// between the Tracks column and the universal Actions (Edit/Delete) column. The <see cref="Cell"/>
|
||||
/// fragment is handed each release; the host recovers its typed row state via its own RowFor lookup.
|
||||
/// </summary>
|
||||
/// <param name="Header">Column header label (e.g. "Waveform", "Hero").</param>
|
||||
/// <param name="Cell">Per-row cell content for a given release.</param>
|
||||
public sealed record SpecialActionColumn(string Header, RenderFragment<ReleaseDto> Cell);
|
||||
@@ -1,322 +1,16 @@
|
||||
@page "/tracks/{Id:long}"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@inject ILogger<TrackEdit> Logger
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Edit Track — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/tracks"
|
||||
Class="mb-4">
|
||||
Back to tracks
|
||||
</MudButton>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" />
|
||||
}
|
||||
else if (_track is null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">
|
||||
Track not found.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Edit Track</MudText>
|
||||
|
||||
<MudPaper Class="pa-6" Elevation="2">
|
||||
<MudStack Spacing="4">
|
||||
<MudField Label="Entry Key" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudText Typo="Typo.body1" Style="font-family: monospace;">@_track.EntryKey</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Default">
|
||||
Vault reference — not editable.
|
||||
</MudText>
|
||||
</MudField>
|
||||
|
||||
<MudTextField @bind-Value="_form.TrackName"
|
||||
Label="Track Name"
|
||||
Required="true"
|
||||
RequiredError="Track name is required"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="_form.Artist"
|
||||
Label="Artist"
|
||||
Required="true"
|
||||
RequiredError="Artist is required"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="_form.Album"
|
||||
Label="Album"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="_form.Genre"
|
||||
Label="Genre"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MediumFields @bind-Medium="_form.Medium" @bind-ReleaseType="_form.ReleaseType" Disabled="_busy" />
|
||||
|
||||
<MudNumericField @bind-Value="_form.TrackNumber"
|
||||
Label="Track Number"
|
||||
Min="1"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (ImagePreviewUrl is { } previewUrl)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudImage Src="@previewUrl"
|
||||
Alt="Cover art preview"
|
||||
Elevation="1"
|
||||
Style="max-width: 120px; height: auto; border-radius: 4px;" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="_busy"
|
||||
OnClick="ClearImage"
|
||||
aria-label="Clear cover art" />
|
||||
</MudStack>
|
||||
}
|
||||
else if (_selectedImageFile is not null)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">New image selected (not yet saved).</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="_busy"
|
||||
OnClick="ClearImage"
|
||||
aria-label="Cancel image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art set.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" />
|
||||
@if (_selectedImageFile is { } selected)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Selected: @selected.Name (will upload on save)</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
|
||||
<MudDatePicker @bind-Date="_form.ReleaseDate"
|
||||
Label="Release Date"
|
||||
DateFormat="yyyy-MM-dd"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
Disabled="_busy"
|
||||
OnClick="ConfirmDelete">
|
||||
Delete
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
Disabled="_busy || !CanSave"
|
||||
OnClick="SaveAsync">
|
||||
Save Changes
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudContainer>
|
||||
@* §8.M: the legacy single-track edit form is retired. Its edit responsibility is absorbed by
|
||||
BatchEdit's track-addressed entry (/tracks/{id}/edit), which loads the parent release and
|
||||
pre-selects the addressed track's row. This route is kept only as a redirect so any bookmarked
|
||||
/tracks/{id} lands on the live edit surface; CmsTrackGrid's per-row Edit targets the new route
|
||||
directly. *@
|
||||
|
||||
@code {
|
||||
[Parameter] public long Id { get; set; }
|
||||
|
||||
private TrackDto? _track;
|
||||
private TrackEditForm _form = new();
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
|
||||
private bool CanSave =>
|
||||
!string.IsNullOrWhiteSpace(_form.TrackName)
|
||||
&& !string.IsNullOrWhiteSpace(_form.Artist);
|
||||
|
||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||
private string? ImagePreviewUrl =>
|
||||
string.IsNullOrEmpty(_form.ImagePath)
|
||||
? null
|
||||
: $"/api/image/{Uri.EscapeDataString(_form.ImagePath)}";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
var result = await CmsTrackService.GetByIdAsync(Id);
|
||||
_track = result.Success ? result.Value : null;
|
||||
if (_track is not null)
|
||||
{
|
||||
_form = TrackEditForm.From(_track);
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_track is null || !CanSave) return;
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
// Upload any newly picked cover art first; abort the save if it fails so we never
|
||||
// persist metadata pointing at an image that was never stored.
|
||||
if (_selectedImageFile is { } file)
|
||||
{
|
||||
await using var imageStream = file.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var uploadResult = await CmsTrackService.UploadImageAsync(
|
||||
imageStream, file.Name, file.ContentType);
|
||||
if (!uploadResult.Success)
|
||||
{
|
||||
var uploadError = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Image upload failed: {uploadError}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
_form.ImagePath = uploadResult.Value;
|
||||
_selectedImageFile = null;
|
||||
}
|
||||
|
||||
// Metadata update over HTTP — EntryKey is immutable and not sent. The Content API
|
||||
// loads the authoritative row and applies these fields. imagePath is tri-state: an
|
||||
// explicit empty string clears the link, a value sets it.
|
||||
var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
|
||||
var updated = await CmsTrackService.UpdateAsync(
|
||||
Id, _form.TrackName, _form.Artist,
|
||||
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
|
||||
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
|
||||
releaseDate,
|
||||
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
|
||||
_form.ReleaseType,
|
||||
_form.Medium,
|
||||
_form.TrackNumber);
|
||||
if (updated.Success)
|
||||
{
|
||||
// Album/genre browse lists derive from this track's metadata; drop their cache so
|
||||
// the /tracks browser re-fetches fresh data on next mode switch.
|
||||
VM.Invalidate();
|
||||
Snackbar.Add("Track updated.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = updated.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Save failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Save failed for track {TrackId}", Id);
|
||||
Snackbar.Add("Save failed — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleImageFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_selectedImageFile = e.File;
|
||||
}
|
||||
|
||||
private void ClearImage()
|
||||
{
|
||||
_form.ImagePath = null;
|
||||
_selectedImageFile = null;
|
||||
}
|
||||
|
||||
private async Task ConfirmDelete()
|
||||
{
|
||||
if (_track is null) return;
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Delete track",
|
||||
$"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.",
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(Id);
|
||||
if (result.Success)
|
||||
{
|
||||
// Deleting a track can empty or alter a release; drop the browse cache so the
|
||||
// /tracks album and genre lists re-fetch fresh counts on next mode switch.
|
||||
VM.Invalidate();
|
||||
Snackbar.Add("Track deleted.", Severity.Success);
|
||||
Nav.NavigateTo("/tracks");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackEditForm
|
||||
{
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Album { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public DateTime? ReleaseDate { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
// Drives ReleaseType visibility via MediumFields and is persisted on save (PUT api/track/meta
|
||||
// carries Medium). A non-Cut medium resets ReleaseType to its default server-side.
|
||||
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
|
||||
public static TrackEditForm From(TrackDto track) => new()
|
||||
{
|
||||
TrackName = track.TrackName,
|
||||
Artist = track.Release?.Artist ?? string.Empty,
|
||||
Album = track.Release?.Title,
|
||||
Genre = track.Release?.Genre,
|
||||
ImagePath = track.Release?.ImagePath,
|
||||
ReleaseDate = track.Release?.ReleaseDate is { } d
|
||||
? d.ToDateTime(TimeOnly.MinValue)
|
||||
: null,
|
||||
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
|
||||
Medium = track.Release?.Medium ?? ReleaseMedium.Cut,
|
||||
TrackNumber = track.TrackNumber
|
||||
};
|
||||
}
|
||||
protected override void OnInitialized() =>
|
||||
Navigation.NavigateTo($"/tracks/{Id}/edit", replace: true);
|
||||
}
|
||||
|
||||
@@ -1,287 +1,12 @@
|
||||
@page "/tracks/new"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackNew> Logger
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
|
||||
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Add Track</MudText>
|
||||
|
||||
<MudPaper Class="pa-6" Elevation="2">
|
||||
<MudStack Spacing="4">
|
||||
<MudText Typo="Typo.subtitle1">WAV file</MudText>
|
||||
<InputFile OnChange="OnFileSelected" accept=".wav,audio/wav,audio/x-wav" />
|
||||
@if (_selectedFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
Selected: @_selectedFile.Name (@FormatBytes(_selectedFile.Size))
|
||||
</MudText>
|
||||
}
|
||||
|
||||
<MudTextField @bind-Value="_trackName" Label="Track Name" Required="true" RequiredError="Track Name is required" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
|
||||
|
||||
<MediumFields @bind-Medium="_medium" @bind-ReleaseType="_releaseType" Disabled="_isUploading" />
|
||||
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (_selectedImageFile is { } selectedImage)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="_isUploading"
|
||||
OnClick="ClearImage"
|
||||
aria-label="Cancel image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@_isUploading" />
|
||||
@if (_selectedImageFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Will upload on save.</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
|
||||
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Spacing="2" Justify="Justify.FlexEnd">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="Cancel"
|
||||
Disabled="_isUploading">
|
||||
Cancel
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SubmitAsync"
|
||||
Disabled="_isUploading">
|
||||
@if (_isUploading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||
<text>Uploading…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Upload</text>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
@* §8.M: the legacy single-track add form is retired. Its add responsibility is absorbed by
|
||||
BatchUpload's single-track branch. This route is kept only as a redirect so any bookmarked
|
||||
/tracks/new lands on the live upload surface; there are no in-app callers. *@
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// streaming path means the limit caps the request, not in-memory buffering.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
private IBrowserFile? _selectedFile;
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
private string? _imagePath;
|
||||
private string _trackName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _album = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
private string? _errorMessage;
|
||||
private bool _isUploading;
|
||||
|
||||
private void OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_selectedFile = e.File;
|
||||
_errorMessage = null;
|
||||
}
|
||||
|
||||
private void HandleImageFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_selectedImageFile = e.File;
|
||||
_imagePath = null;
|
||||
}
|
||||
|
||||
private void ClearImage()
|
||||
{
|
||||
_selectedImageFile = null;
|
||||
_imagePath = null;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
if (_selectedFile is null)
|
||||
{
|
||||
_errorMessage = "Please select a WAV file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_selectedFile.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_errorMessage = "Selected file must be a .wav file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_trackName))
|
||||
{
|
||||
_errorMessage = "Track Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_artist))
|
||||
{
|
||||
_errorMessage = "Artist is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||
{
|
||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||
return;
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// The page is gated by [HierarchicalRoleAuthorize(Admin)], so a missing or
|
||||
// unparseable id here is a configuration bug, not normal client state.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
// Upload any selected cover art first; abort the submit if it fails so we never
|
||||
// create a track expecting an image that was never stored in the vault.
|
||||
if (_selectedImageFile is { } imgFile)
|
||||
{
|
||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
||||
if (!imgResult.Success)
|
||||
{
|
||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Image upload failed: {imgError}";
|
||||
return;
|
||||
}
|
||||
_imagePath = imgResult.Value;
|
||||
}
|
||||
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
||||
// service wraps it in StreamContent so the whole file is never materialised in
|
||||
// memory before DeepDrftAPI receives it.
|
||||
await using var fileStream = _selectedFile.OpenReadStream(MaxUploadBytes);
|
||||
|
||||
var result = await CmsTrackService.UploadTrackAsync(
|
||||
fileStream,
|
||||
_selectedFile.Name,
|
||||
_selectedFile.ContentType,
|
||||
_trackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
_selectedFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber: 1,
|
||||
_medium);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Mix uploads fire the server-side high-res waveform trigger (§3.4) — the CMS computes
|
||||
// nothing. Non-blocking: a failed trigger is recoverable from the Mixes browser.
|
||||
if (_medium == ReleaseMedium.Mix && result.Value?.ReleaseId is { } mixReleaseId)
|
||||
{
|
||||
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
|
||||
if (!waveformResult.Success)
|
||||
{
|
||||
Logger.LogWarning("TrackNew: mix waveform trigger failed for release {ReleaseId}", mixReleaseId);
|
||||
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with a
|
||||
// follow-up metadata update — same two-step pattern TrackEdit uses.
|
||||
if (_imagePath is { } imgPath && result.Value is { } created)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
created.Id,
|
||||
_trackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath);
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Track was created; image is in the vault but unlinked. Non-blocking —
|
||||
// the user can attach it via Edit.
|
||||
Snackbar.Add("Track uploaded, but cover art could not be linked. You can add it via Edit.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success);
|
||||
VM.Invalidate();
|
||||
Navigation.NavigateTo("/tracks");
|
||||
return;
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Upload failed: {error}";
|
||||
Logger.LogWarning("CMS upload rejected: {Error}", error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Upload failed in TrackNew");
|
||||
_errorMessage = "Upload failed. Please try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
Navigation.NavigateTo("/tracks");
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
const long KB = 1024;
|
||||
const long MB = KB * 1024;
|
||||
const long GB = MB * 1024;
|
||||
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
|
||||
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
|
||||
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
|
||||
return $"{bytes} bytes";
|
||||
}
|
||||
protected override void OnInitialized() =>
|
||||
Navigation.NavigateTo("/tracks/upload", replace: true);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
@@ -58,6 +59,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
multipart.Add(new StringContent(artist), "artist");
|
||||
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
|
||||
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
|
||||
if (!string.IsNullOrWhiteSpace(description)) multipart.Add(new StringContent(description), "description");
|
||||
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
|
||||
// Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename.
|
||||
if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
|
||||
@@ -371,7 +373,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
|
||||
public async Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? album, string? genre, string? description, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
ReleaseMedium? medium = null,
|
||||
@@ -385,6 +387,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
imagePath,
|
||||
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
|
||||
|
||||
@@ -30,6 +30,7 @@ public interface ICmsTrackService
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
@@ -85,7 +86,7 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? album, string? genre, string? description, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
ReleaseMedium? medium = null,
|
||||
|
||||
@@ -9,9 +9,14 @@ namespace DeepDrftModels.DTOs;
|
||||
// TrackConverter assigns every field on the round-trip, so an empty default is never observable.
|
||||
public class ReleaseDto : BaseModel
|
||||
{
|
||||
// The release's public handle — the GUID string the public site/API address it by (Phase 11 §3e).
|
||||
// Mirrors TrackDto's EntryKey. No `required` (BaseModel/Manager<> need new()); TrackConverter
|
||||
// assigns it on every round-trip, so an empty default is never observable.
|
||||
public string EntryKey { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Genre { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
|
||||
@@ -12,9 +12,19 @@ namespace DeepDrftModels.Entities;
|
||||
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
|
||||
public class ReleaseEntity : BaseEntity, IEntity
|
||||
{
|
||||
// App-minted GUID-string public handle, mirroring TrackEntity.EntryKey exactly: required string,
|
||||
// entry_key column, unique index, born app-side as Guid.NewGuid().ToString() at release creation
|
||||
// (the FindOrCreateRelease path). Fronts the int Id (the DB-only PK, unused by the app) so the
|
||||
// public site/API address releases by an opaque key, never the transparent sequential id
|
||||
// (Phase 11 §3e). Unlike a track's EntryKey it has no vault job — it is purely an identifier.
|
||||
public required string EntryKey { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string Artist { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
// Free-text prose blurb describing the release. Uniform across media (Cut/Session/Mix), so it
|
||||
// lives on the base table alongside Genre rather than in a per-medium satellite. Plain text,
|
||||
// max 4000 (configured in ReleaseConfiguration); nullable so existing rows migrate as NULL.
|
||||
public string? Description { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
@@ -10,7 +10,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
|
||||
## Actual structure
|
||||
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting), `TrackDetail.razor` (single-track detail view with cover, metadata, play affordance). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting), `TrackDetail.razor` (single-track detail view with cover, metadata, play affordance), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`** — `PlayTrack` is wired directly in its own `@code` block), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRowCenter` controls + `TopRightAction` lava-lamp; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
|
||||
- `Controls/`: Reusable components.
|
||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
|
||||
@@ -23,6 +23,8 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
|
||||
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0–100%), with fixed three-zone gradient (green 0–60%, yellow 60–85%, orange 85–100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
|
||||
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
|
||||
- `ReleaseHeroOverlay.razor`: Shared presentational overlay shell consumed by both `SessionDetail` and `MixDetail`. Renders a background-image hero region with genre/date/share overlaid at the top and title/artist/play at the bottom. Parameters: `HeroImageKey`, `PlaceholderIcon`, `CoverThumbKey` (optional cover thumb in bottom row), `Title`, `Artist`, `Genre`, `ReleaseDate`, `ShareContent` (slot), `PlayContent` (slot), `Class` (per-page aspect/sizing override). Owns no player logic or data fetch; each consuming page passes its own play and share slots. Overlay shell is plain `<div>`s; background-image surface is a `<div class="release-hero-img">` (no `MudPaper`).
|
||||
- `MixVisualizerControls.razor`: Eight-knob RadialKnob control bar for the Mix detail lava-lamp visualizer. Controls (in order): waveform scroll speed, color gradient rotation speed, lava gravity, lava heat, fluid amount, fluid viscosity (cohesion), collision strength, waveform width. `[Parameter] bool Visible` — the host always renders this component and feeds the lava-lamp toggle into `Visible`; the knobs are `@if`-gated on `Visible` while the container holds a reserved `min-height` so content below never pops when the lamp toggles. Owns no JS interop: mutates the injected `MixVisualizerControlState` and raises `Changed`; the backdrop bridge (`MixWaveformVisualizer`) subscribes and pushes each changed dial to the WebGL module. No control is a seek surface (read-only contract).
|
||||
- `Helpers/`: Utilities and mapper functions.
|
||||
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
|
||||
- `Services/`: Audio player + dark-mode services.
|
||||
@@ -31,6 +33,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 16–64 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
|
||||
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
|
||||
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
|
||||
- `MixVisualizerControlState`: Scoped session-persistent holder for the Mix visualizer's **eight** control positions: `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`. Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`MixWaveformVisualizer`) subscribes and pushes the affected uniform. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
|
||||
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
|
||||
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
|
||||
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)` → `Stream` with optional Range header support for seek-beyond-buffer.
|
||||
@@ -140,5 +143,6 @@ dotnet test DeepDrftTests/
|
||||
- **Result types**: Clients return `ApiResult<T>` from NetBlocks. UI checks `Success` before using `Value`.
|
||||
- **Async/await**: All operations are async.
|
||||
- **Stream consumption**: `TrackMediaClient.GetAudioStreamAsync` returns a `Stream` (not fully buffered). `StreamingAudioPlayerService` reads it in chunks to avoid memory pressure on large files.
|
||||
- **Detail pages under InteractiveAuto must load in `OnParametersSetAsync`, not `OnInitializedAsync`**: Blazor reuses a scoped component instance across same-template navigations (e.g. `/mixes/5` → `/mixes/8`), firing `OnParametersSet`/`OnParametersSetAsync` rather than re-running `OnInitialized`. If load logic is in `OnInitialized` only, the ViewModel retains the prior track and Play will stream the wrong item. Capture the route parameter (id/key) synchronously at the top of `OnParametersSetAsync` before any await — after an await the route state may have advanced. Guard `PersistentComponentState` restores on id/key equality to prevent cross-item bleed when the prerender and WASM-boot passes disagree on which item is current.
|
||||
|
||||
When working with this project, maintain the separation between presentation (Razor components) and logic (ViewModels/Clients), follow the established audio player architecture, and respect the dark-mode round-trip (cookie → DarkModeSettings → PersistentComponentState → client).
|
||||
|
||||
@@ -69,9 +69,9 @@ public class ReleaseClient
|
||||
: ApiResult<PagedResult<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<ReleaseDto>> GetById(long id)
|
||||
public async Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
|
||||
{
|
||||
var response = await _http.GetAsync($"api/release/{id}");
|
||||
var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ApiResult<ReleaseDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
@@ -85,13 +85,13 @@ public class ReleaseClient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the high-res waveform datum for a Mix release. A 404 means no datum is stored
|
||||
/// (not yet generated, or not a Mix) — a valid state, so it returns a pass result with a
|
||||
/// null value. Any other non-success status is a genuine failure.
|
||||
/// Fetches the high-res waveform datum for a Mix release, addressed by its public EntryKey. A 404
|
||||
/// means no datum is stored (not yet generated, or not a Mix) — a valid state, so it returns a pass
|
||||
/// result with a null value. Any other non-success status is a genuine failure.
|
||||
/// </summary>
|
||||
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id)
|
||||
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(string entryKey)
|
||||
{
|
||||
var response = await _http.GetAsync($"api/release/{id}/mix/waveform");
|
||||
var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}/mix/waveform");
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftPublic.Client.Common;
|
||||
|
||||
/// <summary>
|
||||
/// The single source of truth for a release's dedicated detail route (Phase 11 §2). A release
|
||||
/// resolves to its per-medium detail page from its <see cref="ReleaseDto.EntryKey"/> and
|
||||
/// <see cref="ReleaseMedium"/> — no round-trip needed at call sites that already hold the medium
|
||||
/// (Archive cards, AlbumsView cards, the player-bar title). The thin <c>/tracks/{entryKey}</c>
|
||||
/// redirect page fetches by EntryKey to discover the medium, then resolves through this same
|
||||
/// helper, so the medium→route table lives in exactly one place.
|
||||
/// </summary>
|
||||
public static class ReleaseRoutes
|
||||
{
|
||||
/// <summary>
|
||||
/// The dedicated detail route for a release: <c>/cuts/{entryKey}</c>, <c>/sessions/{entryKey}</c>,
|
||||
/// or <c>/mixes/{entryKey}</c>. The route carries the release's opaque public EntryKey (Phase 11
|
||||
/// §3e) — never the transparent int PK. Cut is the default arm so a new medium without an entry
|
||||
/// here surfaces a build-visible gap rather than a silent fallthrough — extend the switch when a
|
||||
/// fourth medium lands.
|
||||
/// </summary>
|
||||
public static string DetailHref(string entryKey, ReleaseMedium medium) => medium switch
|
||||
{
|
||||
ReleaseMedium.Session => $"/sessions/{entryKey}",
|
||||
ReleaseMedium.Mix => $"/mixes/{entryKey}",
|
||||
_ => $"/cuts/{entryKey}",
|
||||
};
|
||||
|
||||
/// <summary>Convenience overload for call sites holding a <see cref="ReleaseDto"/>.</summary>
|
||||
public static string DetailHref(ReleaseDto release) => DetailHref(release.EntryKey, release.Medium);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@if (_isMinimized)
|
||||
{
|
||||
<div class="minimized-dock">
|
||||
<div class="minimized-dock" @ref="_miniDock">
|
||||
<LevelMeterFab OnClick="@ToggleMinimized" />
|
||||
</div>
|
||||
}
|
||||
@@ -21,6 +21,10 @@ else
|
||||
Fixed="Fixed"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"
|
||||
HasNext="HasNext"
|
||||
HasPrevious="HasPrevious"
|
||||
SkipNext="@SkipNext"
|
||||
SkipPrevious="@SkipPrevious"
|
||||
Class="transport-zone"/>
|
||||
|
||||
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? QueueService { get; set; }
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
|
||||
[Parameter] public EventCallback<bool> OnMinimized { get; set; }
|
||||
@@ -19,13 +20,22 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private bool _isSeeking = false;
|
||||
private double _seekPosition = 0;
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private IQueueService? _subscribedQueue;
|
||||
|
||||
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
|
||||
// spacer reserves its space. We mirror this element's live height into a CSS
|
||||
// var via a ResizeObserver (see Interop/layout/spacer.ts) rather than a static
|
||||
// value, because the player reflows across breakpoints and grows with the
|
||||
// error banner.
|
||||
//
|
||||
// _miniDock is the minimized FAB container. We observe it in minimized state so
|
||||
// --player-height stays non-zero (the FAB's actual height) and the MixWaveformVisualizer
|
||||
// clips to the top of the FAB rather than extending to the viewport bottom (fix §1).
|
||||
// The player-spacer's .minimized class uses a hardcoded 60px and ignores the var,
|
||||
// so publishing the FAB height here does not regress the spacer.
|
||||
private ElementReference _playerRoot;
|
||||
private ElementReference _miniDock;
|
||||
private ElementReference _lastObservedElement;
|
||||
private IJSObjectReference? _spacerModule;
|
||||
private bool _spacerObserved;
|
||||
|
||||
@@ -48,6 +58,11 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private double LoadProgress => PlayerService?.LoadProgress ?? 0;
|
||||
private string? ErrorMessage => PlayerService?.ErrorMessage;
|
||||
|
||||
// Skip affordances reflect live queue state. With no queue (null) or an empty queue both are
|
||||
// false, so the buttons sit disabled and the bar behaves exactly as it did before the queue.
|
||||
private bool HasNext => QueueService?.HasNext ?? false;
|
||||
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||
/// </summary>
|
||||
@@ -76,28 +91,58 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||
_subscribedService = PlayerService;
|
||||
}
|
||||
|
||||
// The queue cascade is also IsFixed, so re-render the skip affordances off its own
|
||||
// change signal — same posture as the player StateChanged subscription above.
|
||||
if (QueueService != null && !ReferenceEquals(QueueService, _subscribedQueue))
|
||||
{
|
||||
if (_subscribedQueue != null)
|
||||
_subscribedQueue.QueueChanged -= OnQueueChanged;
|
||||
|
||||
QueueService.QueueChanged += OnQueueChanged;
|
||||
_subscribedQueue = QueueService;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnQueueChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private async Task SkipNext()
|
||||
{
|
||||
if (QueueService == null) return;
|
||||
await QueueService.Next();
|
||||
}
|
||||
|
||||
private async Task SkipPrevious()
|
||||
{
|
||||
if (QueueService == null) return;
|
||||
await QueueService.Previous();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
// Only the docked, expanded shape needs a spacer: the Fixed embed is
|
||||
// already in normal flow, and the minimized FAB floats clear of content.
|
||||
// Toggle the observer on the minimized/expanded transition only — the
|
||||
// ResizeObserver itself handles every size change in between.
|
||||
var shouldObserve = !_isMinimized && !Fixed;
|
||||
if (shouldObserve == _spacerObserved) return;
|
||||
// The Fixed embed is already in normal flow — no spacer/clip needed.
|
||||
// For the docked player: we observe in BOTH expanded and minimized states
|
||||
// so --player-height always reflects the live height of whichever element
|
||||
// is visible. This keeps the MixWaveformVisualizer clipped to the top of
|
||||
// the footer in both states (fix §1).
|
||||
// expanded → observe _playerRoot (full player bar, reflows across breakpoints)
|
||||
// minimized → observe _miniDock (floating FAB container, ~56–60px)
|
||||
// The player-spacer's .minimized class uses a hardcoded height and ignores
|
||||
// the var, so publishing the FAB height here does not regress the spacer.
|
||||
if (Fixed) return;
|
||||
|
||||
var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
|
||||
var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id;
|
||||
if (alreadyOnThisElement) return;
|
||||
|
||||
var module = await GetSpacerModuleAsync();
|
||||
if (module is null) return;
|
||||
|
||||
if (shouldObserve)
|
||||
await module.InvokeVoidAsync("observe", _playerRoot);
|
||||
else
|
||||
await module.InvokeVoidAsync("unobserve");
|
||||
|
||||
_spacerObserved = shouldObserve;
|
||||
await module.InvokeVoidAsync("observe", elementToObserve);
|
||||
_spacerObserved = true;
|
||||
_lastObservedElement = elementToObserve;
|
||||
}
|
||||
|
||||
private async Task<IJSObjectReference?> GetSpacerModuleAsync()
|
||||
|
||||
@@ -2,12 +2,25 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipPrevious"
|
||||
Disabled="!HasPrevious"/>
|
||||
}
|
||||
<PlayStateIcon Size="Size.Large"
|
||||
Color="Color.Primary"
|
||||
Disabled="!CanPlay"
|
||||
OnToggle="@TogglePlayPause"/>
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipNext"
|
||||
Disabled="!HasNext"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
|
||||
@@ -16,4 +16,13 @@ public partial class PlayerControls : ComponentBase
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
[Parameter] public required EventCallback TogglePlayPause { get; set; }
|
||||
[Parameter] public required EventCallback Stop { get; set; }
|
||||
|
||||
/// <summary>Whether the queue has a track to skip forward to. Drives the skip-next affordance.</summary>
|
||||
[Parameter] public bool HasNext { get; set; }
|
||||
|
||||
/// <summary>Whether the queue has a track to step back to. Drives the skip-previous affordance.</summary>
|
||||
[Parameter] public bool HasPrevious { get; set; }
|
||||
|
||||
[Parameter] public EventCallback SkipNext { get; set; }
|
||||
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
CanPlay="CanPlay"
|
||||
Fixed="Fixed"
|
||||
TogglePlayPause="TogglePlayPause"
|
||||
Stop="Stop"/>
|
||||
Stop="Stop"
|
||||
HasNext="HasNext"
|
||||
HasPrevious="HasPrevious"
|
||||
SkipNext="SkipNext"
|
||||
SkipPrevious="SkipPrevious"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
|
||||
@@ -14,5 +14,9 @@ public partial class PlayerTransportZone : ComponentBase
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
[Parameter] public EventCallback TogglePlayPause { get; set; }
|
||||
[Parameter] public EventCallback Stop { get; set; }
|
||||
[Parameter] public bool HasNext { get; set; }
|
||||
[Parameter] public bool HasPrevious { get; set; }
|
||||
[Parameter] public EventCallback SkipNext { get; set; }
|
||||
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||
[Parameter] public string? Class { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,11 +6,23 @@
|
||||
{
|
||||
<div class="track-meta-row">
|
||||
<div class="track-meta-identity">
|
||||
<a href="@($"/track/{Track.EntryKey}")" style="text-decoration: none;">
|
||||
@* Title links to the release's dedicated detail page via the shared resolver (§2): the
|
||||
TrackDto already carries Release { Id, Medium }, so no round-trip is needed. When no
|
||||
release is attached there is no medium to resolve, so the title renders unlinked. *@
|
||||
@if (Track.Release is not null)
|
||||
{
|
||||
<a href="@ReleaseRoutes.DetailHref(Track.Release)" style="text-decoration: none;">
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||
@Track.TrackName
|
||||
</MudText>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||
@Track.TrackName
|
||||
</MudText>
|
||||
</a>
|
||||
}
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-sep"> - </MudText>
|
||||
<MudText Typo="Typo.caption" Class="track-meta-artist text-truncate">
|
||||
@Track.Release?.Artist
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Single space-between row under the waveform: identity on the left, accents on the right.
|
||||
Colours come from the MudBlazor theme (the dock surface is theme-aware), so unlike the
|
||||
always-dark TrackCard glass we do not hard-code green-accent overrides here. */
|
||||
Colours come from the MudBlazor theme (the dock surface is theme-aware), so we do not
|
||||
hard-code green-accent overrides here. */
|
||||
.track-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
If instance swapping at runtime is ever needed, change IsFixed to false (adds subscription
|
||||
overhead on every parent re-render, but allows children to see the new reference). *@
|
||||
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
|
||||
@ChildContent
|
||||
<CascadingValue Value="@(_queueService)" IsFixed="true">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
||||
|
||||
private IStreamingPlayerService? _audioPlayerService;
|
||||
private QueueService? _queueService;
|
||||
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
@@ -29,6 +30,13 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
// Children must not wrap or replace this callback.
|
||||
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
|
||||
// OnTrackSelected will be set by individual child components that need it
|
||||
|
||||
// The queue orchestrates above the single-slot player. The player is not DI-registered
|
||||
// (constructed here), so the queue binds to it via Attach rather than constructor injection —
|
||||
// no construction cycle, no IServiceProvider. Cascaded alongside the player so the bar and a
|
||||
// future up-next panel both read it.
|
||||
_queueService = new QueueService();
|
||||
_queueService.Attach(_audioPlayerService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,6 +46,11 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Dispose the queue first so it unsubscribes from the player's TrackEnded before the
|
||||
// player tears down.
|
||||
_queueService?.Dispose();
|
||||
_queueService = null;
|
||||
|
||||
if (_audioPlayerService is IAsyncDisposable disposable)
|
||||
{
|
||||
await disposable.DisposeAsync();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
<div class="hero-actions @AnimClass">
|
||||
<StreamNowButton ButtonClass="btn-primary" ButtonLabel="Start Streaming" />
|
||||
<a class="btn-ghost" href="/tracks">Browse Tracks</a>
|
||||
<a class="btn-ghost" href="/archive">Browse Tracks</a>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
public enum GalleryViewMode
|
||||
{
|
||||
Grid,
|
||||
List
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inject MixVisualizerControlState ControlState
|
||||
|
||||
@* The Mix visualizer controls. EIGHT continuous RadialKnobs — scroll speed, gradient rotation speed,
|
||||
lava gravity, lava heat, fluid amount, fluid viscosity, collision strength, waveform width — each its
|
||||
own dedicated control with a Material-icon caption. The single "bubbles" knob is split into
|
||||
fluid-amount + fluid-viscosity (Phase 10 §5).
|
||||
|
||||
Visibility (Phase 10 §4): the host ALWAYS renders this component now and feeds the lava-lamp toggle
|
||||
into the @Visible parameter. THIS component decides knob visibility — it @if-gates the knobs but keeps
|
||||
the container's reserved size, so the content below the controls bar never pops when the lamp toggles.
|
||||
The gating is Blazor @if (matching the established "@if-gated knob band, no CSS hide/glass/animation"
|
||||
convention) — the knobs are simply not rendered when hidden, while a min-height container holds the
|
||||
layout. No collapse animation, no glass surface, no CSS visibility-hiding of populated knobs.
|
||||
|
||||
It owns NO JS interop: it mutates the shared, session-scoped MixVisualizerControlState and raises its
|
||||
Changed event. The backdrop bridge (MixWaveformVisualizer) subscribes to that event and pushes the
|
||||
affected dial to the WebGL module. That keeps the JS module handle single-owned by the bridge and
|
||||
this component purely presentational. None of these is a seek surface (read-only contract §D).
|
||||
|
||||
RadialKnob has no icon slot (its Label renders as SVG text) and no aria attribute-capture, so each
|
||||
control's Material icon rides beside its knob as an adjacent MudIcon caption and the accessible name
|
||||
rides on the wrapping group div (§7d). HoldValue stays false so the knobs are live — ValueChanged
|
||||
fires continuously during drag, preserving the Changed/NotifyChanged seam. *@
|
||||
|
||||
<div class="mix-visualizer-controls-bar">
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
|
||||
<RadialKnob Value="@ControlState.ScrollSpeed"
|
||||
ValueChanged="@OnScrollSpeedChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Class="mix-visualizer-control-icon" />
|
||||
</div>
|
||||
|
||||
<div class="mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
|
||||
<RadialKnob Value="@ControlState.GradientRotationSpeed"
|
||||
ValueChanged="@OnGradientRotationSpeedChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="mix-visualizer-control-icon" />
|
||||
</div>
|
||||
|
||||
<div class="mix-visualizer-control" role="group" aria-label="Lava gravity">
|
||||
<RadialKnob Value="@ControlState.LavaGravity"
|
||||
ValueChanged="@OnLavaGravityChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="mix-visualizer-control-icon" />
|
||||
</div>
|
||||
|
||||
<div class="mix-visualizer-control" role="group" aria-label="Lava heat">
|
||||
<RadialKnob Value="@ControlState.LavaHeat"
|
||||
ValueChanged="@OnLavaHeatChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="mix-visualizer-control-icon" />
|
||||
</div>
|
||||
|
||||
<div class="mix-visualizer-control" role="group" aria-label="Fluid amount">
|
||||
<RadialKnob Value="@ControlState.FluidAmount"
|
||||
ValueChanged="@OnFluidAmountChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="mix-visualizer-control-icon" />
|
||||
</div>
|
||||
|
||||
<div class="mix-visualizer-control" role="group" aria-label="Fluid viscosity">
|
||||
<RadialKnob Value="@ControlState.FluidViscosity"
|
||||
ValueChanged="@OnFluidViscosityChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="mix-visualizer-control-icon" />
|
||||
</div>
|
||||
|
||||
<div class="mix-visualizer-control" role="group" aria-label="Collision strength">
|
||||
<RadialKnob Value="@ControlState.CollisionStrength"
|
||||
ValueChanged="@OnCollisionStrengthChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="mix-visualizer-control-icon" />
|
||||
</div>
|
||||
|
||||
<div class="mix-visualizer-control" role="group" aria-label="Waveform width">
|
||||
<RadialKnob Value="@ControlState.WaveformWidth"
|
||||
ValueChanged="@OnWaveformWidthChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="mix-visualizer-control-icon" />
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Whether the knob band is shown. The host wires its lava-lamp toggle straight into this — the host
|
||||
/// always renders this component, and THIS component decides knob visibility (Phase 10 §4). When
|
||||
/// false the knobs are @if-gated out but the container holds its reserved height (CSS min-height), so
|
||||
/// content below the bar never pops as the lamp toggles.
|
||||
/// </summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
// Each handler mutates its own dedicated property then raises Changed — the bridge re-reads and
|
||||
// pushes the affected dial. All values are already normalized [0,1]; the bridge maps scroll speed
|
||||
// to a visible time-span and routes the rest straight to the lava/colour dials.
|
||||
|
||||
private void OnScrollSpeedChanged(double value)
|
||||
{
|
||||
ControlState.ScrollSpeed = value;
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
|
||||
private void OnGradientRotationSpeedChanged(double value)
|
||||
{
|
||||
ControlState.GradientRotationSpeed = value;
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
|
||||
private void OnLavaGravityChanged(double value)
|
||||
{
|
||||
ControlState.LavaGravity = value;
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
|
||||
private void OnLavaHeatChanged(double value)
|
||||
{
|
||||
ControlState.LavaHeat = value;
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
|
||||
private void OnFluidAmountChanged(double value)
|
||||
{
|
||||
ControlState.FluidAmount = value;
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
|
||||
private void OnFluidViscosityChanged(double value)
|
||||
{
|
||||
ControlState.FluidViscosity = value;
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
|
||||
private void OnCollisionStrengthChanged(double value)
|
||||
{
|
||||
ControlState.CollisionStrength = value;
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
|
||||
private void OnWaveformWidthChanged(double value)
|
||||
{
|
||||
ControlState.WaveformWidth = value;
|
||||
ControlState.NotifyChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* The eight-knob band. Phase 10 §4: the host ALWAYS renders this component and the component @if-gates
|
||||
the knobs on its Visible parameter. So the container is permanent and reserves its height whether or
|
||||
not the knobs are present — content below the bar never pops on toggle. No collapse machinery, no
|
||||
transitions, no glass surface. A plain transparent horizontal flex row of the eight knobs that wraps
|
||||
to a second line only if the band is genuinely too narrow.
|
||||
|
||||
min-height reserves one knob-row's worth of space (knob Size=64 + icon caption + gaps + margins) so
|
||||
the empty (hidden) state occupies the same vertical box the populated single-row state does. On very
|
||||
narrow viewports a populated band may wrap to a second row and exceed this floor — the no-pop
|
||||
guarantee is exact for the common single-row (desktop) layout. */
|
||||
.mix-visualizer-controls-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 0.85rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
/* One control: a RadialKnob with its Material icon caption underneath. RadialKnob has no icon slot, so
|
||||
the icon rides adjacent (§7d). Center the pair so the seven read as a tidy bar. */
|
||||
.mix-visualizer-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* The caption icon is a MudIcon (a Razor component), so Blazor CSS isolation does not stamp the scope
|
||||
attribute onto its element — reach it with ::deep. Tinted to the secondary accent and the
|
||||
overlay-label opacity so it matches the session-hero NowPlaying captions (§7e). */
|
||||
.mix-visualizer-control ::deep .mix-visualizer-control-icon {
|
||||
color: var(--mud-palette-primary);
|
||||
opacity: 0.78;
|
||||
}
|
||||
@@ -1,35 +1,16 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
|
||||
@* Full-page background waveform for a Mix release. Deliberately NOT the player-bar peak-bar idiom
|
||||
(SpectrumVisualizer / LevelMeterFab own that): this renders a single continuous mirrored
|
||||
silhouette filling the viewport behind the detail content. Fetches its own datum from
|
||||
api/release/{id}/mix/waveform. The played portion is washed with the progress overlay driven by
|
||||
PlaybackPosition; the click-to-seek seam (OnSeek + bindable PlaybackPosition) is wired here for a
|
||||
future wave even though click handling does not ship yet. *@
|
||||
@* Full-page scrolling Mix waveform background (Phase 9, 8.K). A windowed slice of the mix's loudness
|
||||
datum scrolls bottom-to-top, coupled to playback; a zoom slider controls the visible time-span (and
|
||||
so the apparent scroll speed, Guitar-Hero style). Strictly read-only: it self-fetches its datum from
|
||||
ReleaseEntryKey, takes playback as one-way input only, and never seeks or writes back. The rAF loop and all
|
||||
scroll/zoom/compositing math live in the MixVisualizer.ts interop module; this component is a thin
|
||||
bridge that feeds it datum + playback + zoom + theme. Deliberately NOT the player-bar peak-bar idiom. *@
|
||||
|
||||
<div class="mix-waveform-bg @(_profile is null ? "mix-waveform-bg--empty" : null)">
|
||||
@if (_profile is not null)
|
||||
{
|
||||
<svg class="mix-waveform-svg"
|
||||
viewBox="0 0 @ViewBoxWidth 100"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Waveform">
|
||||
<defs>
|
||||
<clipPath id="@_clipId">
|
||||
<path d="@_silhouettePath" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
@* Base silhouette. *@
|
||||
<path d="@_silhouettePath" class="mix-waveform-fill" />
|
||||
|
||||
@* Played-portion wash: a full-height rect clipped to the silhouette, width tracking
|
||||
PlaybackPosition. Clamped to [0, 1]. *@
|
||||
<rect x="0" y="0"
|
||||
width="@(ClampedPosition * ViewBoxWidth)" height="100"
|
||||
class="mix-waveform-played"
|
||||
clip-path="url(#@_clipId)" />
|
||||
</svg>
|
||||
}
|
||||
<div class="mix-waveform-bg">
|
||||
<canvas @ref="_canvas" class="mix-waveform-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
@* The viewing controls (resolution + the three Wave 2 controls) live in MixVisualizerControls,
|
||||
rendered in the mix-detail foreground row below the back button — NOT here. This component is now a
|
||||
pure backdrop bridge; it pushes uniforms in response to the shared MixVisualizerControlState. *@
|
||||
|
||||
@@ -1,132 +1,355 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a Mix release's stored loudness profile as a full-page background silhouette. Standalone
|
||||
/// and reusable: give it a <see cref="ReleaseId"/> and it fetches its own datum. Visually distinct
|
||||
/// from the player-bar spectrum/level idiom by design — this is a single continuous mirrored wave,
|
||||
/// not discrete peak bars.
|
||||
/// Full-page scrolling Mix waveform background. Standalone and reusable: give it a
|
||||
/// <see cref="ReleaseEntryKey"/> and it fetches its own loudness datum. The rendering itself — a windowed,
|
||||
/// bottom-to-top, playback-coupled scroll with a glassy theme-aware gradient — lives in the
|
||||
/// MixVisualizer.ts interop module; this component is the bridge that feeds it datum, playback
|
||||
/// position, zoom, and theme, and owns the module lifecycle.
|
||||
///
|
||||
/// Strictly read-only (spec §D): no seek, no two-way write-back. <see cref="PlaybackPosition"/> is a
|
||||
/// one-way input. The live playback signal on the Mix detail page comes from the cascaded player
|
||||
/// service (which also supplies the mix duration needed for the time↔sample mapping); the
|
||||
/// <see cref="PlaybackPosition"/> parameter is the composability fallback for hosts that have no
|
||||
/// player cascade (e.g. an embed) and want to drive position themselves.
|
||||
/// </summary>
|
||||
public partial class MixWaveformVisualizer : ComponentBase
|
||||
public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[Inject] public required IReleaseDataService ReleaseData { get; set; }
|
||||
[Inject] public required IJSRuntime JS { get; set; }
|
||||
[Inject] public required MixVisualizerControlState ControlState { get; set; }
|
||||
[Inject] public required ILogger<MixWaveformVisualizer> Logger { get; set; }
|
||||
|
||||
/// <summary>The Mix release whose waveform datum to fetch and render.</summary>
|
||||
[Parameter] public required long ReleaseId { get; set; }
|
||||
// Live playback + the mix duration come from the cascaded streaming player when present. The
|
||||
// cascade is IsFixed, so we subscribe to its multicast StateChanged side-channel to learn about
|
||||
// position/play-state ticks (same pattern as WaveformSeeker / SpectrumVisualizer).
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
// Live dark-mode state. Toggling re-themes the gradient without a reload: the cascade re-renders
|
||||
// us, and OnAfterRender pushes fresh palette colours into the module.
|
||||
[CascadingParameter] public DarkModeSettings? DarkMode { get; set; }
|
||||
|
||||
/// <summary>The opaque public EntryKey of the Mix release whose waveform datum to fetch and render.</summary>
|
||||
[Parameter] public required string ReleaseEntryKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized playback head in [0, 1]. Two-way bindable so a future click-to-seek can write back
|
||||
/// through it; today it is read-only input that drives the played-portion wash. The seam exists
|
||||
/// now so wiring click-to-seek later is a pure addition, not a signature change.
|
||||
/// The id of this mix's playable track. Used to gate the cascaded player as the live source: we
|
||||
/// only couple to playback when the player is on THIS track, so a different track playing
|
||||
/// elsewhere leaves this backdrop at its at-rest slice instead of scrolling to the wrong audio.
|
||||
/// Null leaves the visualizer in the at-rest state (no player coupling).
|
||||
/// </summary>
|
||||
[Parameter] public long? TrackId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized playback head in [0, 1]. One-way input only — the component never writes back.
|
||||
/// Used as the position source for hosts with no cascaded player (composability fallback);
|
||||
/// when a matching player is cascaded, its live position takes precedence.
|
||||
/// </summary>
|
||||
[Parameter] public double PlaybackPosition { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<double> PlaybackPositionChanged { get; set; }
|
||||
// Bridge-level diagnostics. Mirrors the JS-side DEBUG flag in MixVisualizer.ts: when true the
|
||||
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
|
||||
// `[MixVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
|
||||
// which upstream link is broken when the ribbon stays blank — set true temporarily to diagnose.
|
||||
private static readonly bool Debug = false;
|
||||
private const string Tag = "[MixVisualizer]";
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user seeks by interacting with the waveform. Unused until click-to-seek ships;
|
||||
/// present now to lock the seek seam into the public contract.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<double> OnSeek { get; set; }
|
||||
private static void DebugLog(string message)
|
||||
{
|
||||
if (Debug) Console.WriteLine($"{Tag} {message}");
|
||||
}
|
||||
|
||||
// Fixed SVG coordinate width. The path is computed in this space, then stretched to the
|
||||
// viewport via preserveAspectRatio="none".
|
||||
private const int ViewBoxWidth = 1000;
|
||||
|
||||
private readonly string _clipId = $"mix-wf-clip-{Guid.NewGuid():N}";
|
||||
private ElementReference _canvas;
|
||||
private IJSObjectReference? _module;
|
||||
private IJSObjectReference? _handle;
|
||||
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private WaveformProfileDto? _profile;
|
||||
private string _silhouettePath = string.Empty;
|
||||
private long? _loadedReleaseId;
|
||||
private string? _loadedReleaseKey;
|
||||
|
||||
private double ClampedPosition => Math.Clamp(PlaybackPosition, 0d, 1d);
|
||||
// Whether we are subscribed to the shared control state's Changed event. The controls row (a
|
||||
// sibling component) mutates ControlState and raises Changed; we push the affected uniforms.
|
||||
private bool _subscribedToControls;
|
||||
|
||||
// The profile reference last sent to the module, plus whether it went with a real duration.
|
||||
// Tracked so a per-tick playback push never re-decodes the (up to ~1.2 MB) datum in JS — we only
|
||||
// push the datum when its identity or duration-availability actually changes.
|
||||
private WaveformProfileDto? _pushedProfile;
|
||||
private bool _pushedWithDuration;
|
||||
|
||||
// Theme last pushed to the module, so we only re-push on an actual change.
|
||||
private bool? _lastIsDark;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Subscribe once to the shared control state. The controls row mutates it and raises Changed;
|
||||
// we are the sole owner of the JS module handle, so we do the uniform pushes here. This keeps
|
||||
// the handle single-owned (no handle sharing, no service-locator) — the scoped state object is
|
||||
// the decoupling seam between the foreground controls and this backdrop bridge.
|
||||
if (!_subscribedToControls)
|
||||
{
|
||||
ControlState.Changed += OnControlStateChanged;
|
||||
_subscribedToControls = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// ReleaseId is the only fetch input; fetch once per id. A PlaybackPosition update re-renders
|
||||
// but must not refetch — and a release with no datum must not refetch either, so the guard
|
||||
// keys on the fetched id, not on whether a profile came back.
|
||||
if (_loadedReleaseId == ReleaseId)
|
||||
return;
|
||||
// Subscribe to the player's multicast side-channel once, to re-render on position/play ticks.
|
||||
// Log whether the cascade is even present: a null PlayerService here means the visualizer
|
||||
// never couples to playback (no StateChanged events ever reach OnPlayerStateChanged).
|
||||
if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedService))
|
||||
{
|
||||
if (_subscribedService is not null)
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||
_subscribedService = PlayerService;
|
||||
DebugLog($"subscribed to player StateChanged. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
|
||||
}
|
||||
else if (PlayerService is null)
|
||||
{
|
||||
DebugLog($"NO player cascade — playback will never couple. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
|
||||
}
|
||||
|
||||
_loadedReleaseId = ReleaseId;
|
||||
// ReleaseEntryKey is the only fetch input; fetch once per key. Position/zoom/theme changes
|
||||
// re-render but must not refetch, and a release with no datum must not refetch either — so the
|
||||
// guard keys on the fetched key, not on whether a profile came back.
|
||||
if (_loadedReleaseKey == ReleaseEntryKey) return;
|
||||
_loadedReleaseKey = ReleaseEntryKey;
|
||||
|
||||
var result = await ReleaseData.GetMixWaveform(ReleaseId);
|
||||
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0)
|
||||
DebugLog($"fetching mix waveform datum for ReleaseEntryKey={ReleaseEntryKey}…");
|
||||
var result = await ReleaseData.GetMixWaveform(ReleaseEntryKey);
|
||||
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
|
||||
{
|
||||
_profile = profile;
|
||||
try
|
||||
{
|
||||
_silhouettePath = BuildSilhouettePath(profile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "MixWaveformVisualizer: failed to decode waveform profile for release {ReleaseId}; rendering empty backdrop.", ReleaseId);
|
||||
_profile = null;
|
||||
_silhouettePath = string.Empty;
|
||||
}
|
||||
DebugLog($"datum fetch OK — {profile.BucketCount} buckets, base64 length {profile.Data.Length}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// No datum (not generated yet, or not a Mix) — leave the background empty; the detail
|
||||
// page still renders its content over a plain backdrop.
|
||||
// No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still
|
||||
// renders its content over a plain background.
|
||||
_profile = null;
|
||||
_silhouettePath = string.Empty;
|
||||
DebugLog(result.Success
|
||||
? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseEntryKey={ReleaseEntryKey}) — backdrop stays blank."
|
||||
: $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank.");
|
||||
}
|
||||
|
||||
// Push the (possibly new) datum to the module if it is already created.
|
||||
await PushDatumAsync();
|
||||
}
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(async () =>
|
||||
{
|
||||
// Position/play-state changed: push it to the module (cheap; no re-fetch, no full re-render
|
||||
// needed for the canvas itself, but StateHasChanged keeps the slider/visibility in sync).
|
||||
// Log the gating inputs so a "ribbon never couples" failure shows exactly why: whether the
|
||||
// player is on THIS track (IsActivePlayer), and what duration/position/play-state it reports.
|
||||
var currentTrackId = PlayerService?.CurrentTrack is { } ct ? ct.Id.ToString() : "null";
|
||||
DebugLog($"player StateChanged — IsActivePlayer={IsActivePlayer} (player.CurrentTrack.Id={currentTrackId}, TrackId={TrackId?.ToString() ?? "null"}), player.IsPlaying={PlayerService?.IsPlaying}, player.Duration={PlayerService?.Duration?.ToString("F2") ?? "null"}.");
|
||||
await PushPlaybackAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_module = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./js/visualizer/MixVisualizer.js");
|
||||
_handle = await _module.InvokeAsync<IJSObjectReference>("create", _canvas);
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "MixWaveformVisualizer: failed to load the visualizer module; rendering a plain backdrop.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed the module with the current state now that it exists. All seven control values
|
||||
// come from the shared (session-persisted) state, so a mix opened mid-session seeds the
|
||||
// module with the knob positions the listener left them at.
|
||||
await PushControlsAsync();
|
||||
await PushDatumAsync();
|
||||
await PushPlaybackAsync();
|
||||
await PushThemeIfChangedAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// On every subsequent render (e.g. dark-mode toggle), re-theme if it changed.
|
||||
await PushThemeIfChangedAsync();
|
||||
}
|
||||
|
||||
// The controls bar mutated a knob on the shared state and raised Changed. Push all seven control
|
||||
// values (cheap scalar interop). Each control now drives its own dedicated dial in the JS handle
|
||||
// (lava reframe Wave R4) — scroll speed → visible-time-span, plus the six lava/colour dials; see
|
||||
// PushControlsAsync. The bridge stays the sole owner of the JS module handle.
|
||||
private void OnControlStateChanged() => InvokeAsync(async () =>
|
||||
{
|
||||
await PushControlsAsync();
|
||||
});
|
||||
|
||||
// ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Push the eight control values to the module from the shared state. Used to seed on first render
|
||||
/// and to re-push when the controls bar signals a change. Each value is its own dedicated dial:
|
||||
/// <list type="bullet">
|
||||
/// <item>scroll speed [0,1] is mapped onto the useful zoom band via
|
||||
/// <see cref="MixZoomMapping.ScrollKnobToSeconds"/> and pushed through <c>setScrollSpeed</c>
|
||||
/// (higher speed → tighter window → faster scroll);</item>
|
||||
/// <item>gradient rotation speed → <c>setGradientRotationSpeed</c> (live OKLab anchor rotation);</item>
|
||||
/// <item>gravity / heat / collision strength → their dedicated lava-physics dials;</item>
|
||||
/// <item>fluid amount → <c>setFluidAmount</c> (blob count + volume); fluid viscosity →
|
||||
/// <c>setFluidViscosity</c> (cohesion / sphere-restoration) — the Phase 10 split of the
|
||||
/// former single density knob;</item>
|
||||
/// <item>waveform width → the ribbon-extent uniform.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private async Task PushControlsAsync()
|
||||
{
|
||||
if (_handle is null) return;
|
||||
// Scroll speed is a normalized [0,1] axis; map it onto the useful zoom band (Phase 10 retune —
|
||||
// the knob's full travel now covers the 60%–100% zoom range, dropping the dead slow/wide end).
|
||||
var visibleSeconds = MixZoomMapping.ScrollKnobToSeconds(ControlState.ScrollSpeed);
|
||||
await _handle.InvokeVoidAsync("setScrollSpeed", visibleSeconds);
|
||||
await _handle.InvokeVoidAsync("setGradientRotationSpeed", ControlState.GradientRotationSpeed);
|
||||
await _handle.InvokeVoidAsync("setLavaGravity", ControlState.LavaGravity);
|
||||
await _handle.InvokeVoidAsync("setLavaHeat", ControlState.LavaHeat);
|
||||
await _handle.InvokeVoidAsync("setFluidAmount", ControlState.FluidAmount);
|
||||
await _handle.InvokeVoidAsync("setFluidViscosity", ControlState.FluidViscosity);
|
||||
await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength);
|
||||
await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push the datum to the module, but only when it actually changed — a different profile, or the
|
||||
/// mix duration becoming available for the first time. Idempotent so the per-tick playback path
|
||||
/// can call it without re-decoding the (large) base64 datum in JS every frame.
|
||||
/// </summary>
|
||||
private async Task PushDatumAsync()
|
||||
{
|
||||
if (_handle is null) return;
|
||||
|
||||
var haveDuration = _profile is not null && PlayerDurationSeconds is > 0;
|
||||
|
||||
// No change since the last push? Nothing to do.
|
||||
if (ReferenceEquals(_profile, _pushedProfile) && haveDuration == _pushedWithDuration)
|
||||
return;
|
||||
|
||||
if (!haveDuration)
|
||||
{
|
||||
// The most common stuck state: a datum is loaded but no positive player duration has
|
||||
// arrived, so we cannot map samples↔time and push an empty datum. Spell out which half
|
||||
// is missing so the broken link is unambiguous in the console.
|
||||
DebugLog($"datum push deferred (empty) — profile={(_profile is null ? "null" : "loaded")}, playerDuration={PlayerDurationSeconds?.ToString("F2") ?? "null"} (needs IsActivePlayer + duration>0).");
|
||||
}
|
||||
|
||||
if (haveDuration)
|
||||
{
|
||||
// The mix duration must come from the player (no DTO field carries it); without a
|
||||
// positive duration we cannot map samples↔time, so we hold off until it arrives.
|
||||
DebugLog($"datum push (REAL) — base64 length {_profile!.Data.Length}, duration {PlayerDurationSeconds!.Value:F2}s.");
|
||||
await _handle.InvokeVoidAsync("setDatum", _profile.Data, PlayerDurationSeconds.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _handle.InvokeVoidAsync("setDatum", string.Empty, 0d);
|
||||
}
|
||||
|
||||
_pushedProfile = _profile;
|
||||
_pushedWithDuration = haveDuration;
|
||||
}
|
||||
|
||||
private async Task PushPlaybackAsync()
|
||||
{
|
||||
if (_handle is null)
|
||||
{
|
||||
DebugLog("PushPlayback skipped — module handle not created yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Duration arrives via the player after the initial (duration-less) datum push; the
|
||||
// idempotent PushDatumAsync re-pushes exactly once when it first becomes available.
|
||||
await PushDatumAsync();
|
||||
|
||||
DebugLog($"setPlayback → position={CurrentPositionSeconds:F2}s, isPlaying={IsPlaying}.");
|
||||
await _handle.InvokeVoidAsync("setPlayback", CurrentPositionSeconds, IsPlaying);
|
||||
}
|
||||
|
||||
private async Task PushThemeIfChangedAsync()
|
||||
{
|
||||
if (_handle is null) return;
|
||||
var isDark = DarkMode?.IsDarkMode ?? false;
|
||||
if (_lastIsDark == isDark) return;
|
||||
_lastIsDark = isDark;
|
||||
|
||||
// The module reads the gradient stops directly from the canvas's computed --mud-palette-*
|
||||
// vars (canvas gradients can't resolve var(), so resolution must happen in JS). The bespoke
|
||||
// light/dark themes swap those vars on toggle; we just tell the module to re-read.
|
||||
await _handle.InvokeVoidAsync("refreshTheme");
|
||||
}
|
||||
|
||||
// ── Live signal sources. The matching player wins; PlaybackPosition is the no-player fallback. ─
|
||||
|
||||
/// <summary>True only when the cascaded player is loaded with THIS mix's track.</summary>
|
||||
private bool IsActivePlayer =>
|
||||
PlayerService is { CurrentTrack: not null }
|
||||
&& TrackId is { } id
|
||||
&& PlayerService.CurrentTrack.Id == id;
|
||||
|
||||
private double? PlayerDurationSeconds =>
|
||||
IsActivePlayer && PlayerService!.Duration is > 0 ? PlayerService.Duration : null;
|
||||
|
||||
private bool IsPlaying => IsActivePlayer && (PlayerService?.IsPlaying ?? false);
|
||||
|
||||
private double CurrentPositionSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
// Prefer the matching player's absolute time. Otherwise fall back to the one-way
|
||||
// PlaybackPosition ([0,1]) scaled by whatever duration we have; with no duration the
|
||||
// position is unusable, so show the at-rest slice (0).
|
||||
if (IsActivePlayer)
|
||||
return PlayerService!.CurrentTime;
|
||||
if (PlayerDurationSeconds is { } dur)
|
||||
return Math.Clamp(PlaybackPosition, 0, 1) * dur;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a closed, vertically mirrored silhouette path across the buckets. Loudness bytes are
|
||||
// [0, 255]; mapped to a half-height amplitude around the vertical midline (y=50). The top edge
|
||||
// runs left-to-right, the bottom edge mirrors right-to-left, and the path closes — yielding a
|
||||
// filled continuous wave shape rather than separate bars.
|
||||
private static string BuildSilhouettePath(WaveformProfileDto profile)
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
var data = Convert.FromBase64String(profile.Data);
|
||||
int n = data.Length;
|
||||
if (n == 0) return string.Empty;
|
||||
|
||||
const double midline = 50d;
|
||||
const double maxAmplitude = 48d; // leave a 2-unit margin top and bottom
|
||||
double step = n > 1 ? (double)ViewBoxWidth / (n - 1) : ViewBoxWidth;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Top edge, left to right.
|
||||
for (int i = 0; i < n; i++)
|
||||
if (_subscribedService is not null)
|
||||
{
|
||||
double x = i * step;
|
||||
double amp = data[i] / 255d * maxAmplitude;
|
||||
double y = midline - amp;
|
||||
sb.Append(i == 0 ? 'M' : 'L');
|
||||
AppendPoint(sb, x, y);
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
_subscribedService = null;
|
||||
}
|
||||
|
||||
// Bottom edge, right to left (mirror).
|
||||
for (int i = n - 1; i >= 0; i--)
|
||||
if (_subscribedToControls)
|
||||
{
|
||||
double x = i * step;
|
||||
double amp = data[i] / 255d * maxAmplitude;
|
||||
double y = midline + amp;
|
||||
sb.Append('L');
|
||||
AppendPoint(sb, x, y);
|
||||
ControlState.Changed -= OnControlStateChanged;
|
||||
_subscribedToControls = false;
|
||||
}
|
||||
|
||||
sb.Append('Z');
|
||||
return sb.ToString();
|
||||
}
|
||||
if (_handle is not null)
|
||||
{
|
||||
try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { }
|
||||
try { await _handle.DisposeAsync(); } catch (JSDisconnectedException) { }
|
||||
_handle = null;
|
||||
}
|
||||
|
||||
private static void AppendPoint(StringBuilder sb, double x, double y)
|
||||
{
|
||||
sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture));
|
||||
sb.Append(' ');
|
||||
sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture));
|
||||
sb.Append(' ');
|
||||
if (_module is not null)
|
||||
{
|
||||
try { await _module.DisposeAsync(); } catch (JSDisconnectedException) { }
|
||||
_module = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
/* Full-viewport fixed backdrop. Sits behind page content (negative-ish z-index within the
|
||||
detail layout) and never intercepts pointer events until click-to-seek ships. */
|
||||
/* Full-viewport fixed backdrop. Sits behind the detail content (.mix-detail-foreground is z-index:1)
|
||||
and never intercepts pointer events — except the zoom slider, which re-enables them on itself.
|
||||
|
||||
Footer clip (Phase 10 W1, spec §2c): the backdrop must stop cleanly ABOVE the audio player bar so
|
||||
no lava/waveform pixel paints over or under it. `overflow: hidden` clips the canvas to this box, and
|
||||
`bottom` is inset by `--player-height`, which AudioPlayerBar publishes on :root via its ResizeObserver
|
||||
(Interop/layout/spacer.ts). The observer now points at whichever element is live:
|
||||
expanded → the full player dock (tracks breakpoint reflow + error-banner growth)
|
||||
minimized → the minimized-dock FAB container (~56–60 px)
|
||||
so --player-height is always non-zero while the player is mounted and the clip line follows the bar in
|
||||
BOTH states (fix §1 / p10-reframe-w1-fix). The 0px fallback keeps the backdrop full-height on any
|
||||
page that does not host the player. */
|
||||
.mix-waveform-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
bottom: var(--player-height, 0px);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mix-waveform-bg--empty {
|
||||
/* No datum: nothing to draw. Kept as a hook for a future flat-line fallback. */
|
||||
}
|
||||
|
||||
.mix-waveform-svg {
|
||||
/* The canvas fills the viewport. All ribbon shading (luminous depth, soft edges) is drawn inside the
|
||||
canvas by the WebGL2 fragment shader. NO CSS backdrop-filter: it was a confirmed per-frame perf killer
|
||||
on the Canvas predecessor and is exactly the cost the GPU move exists to eliminate (spec §2, §5.2);
|
||||
the glass treatment returns in-shader in Wave 3. */
|
||||
.mix-waveform-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 60vh;
|
||||
margin: auto 0;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
/* Native SVG elements — scoped CSS stamps these directly, no ::deep needed. */
|
||||
.mix-waveform-fill {
|
||||
fill: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.mix-waveform-played {
|
||||
fill: var(--mud-palette-primary);
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapping between the Mix visualizer's zoom slider position [0, 1] and the visible time-span in
|
||||
/// seconds. The span range is wide (0.333 s … 30 s, ~90×), so the mapping is logarithmic — equal
|
||||
/// slider travel changes the span by an equal *ratio*, which feels even to the hand. Slider
|
||||
/// orientation: fraction 0 = most zoomed-out (longest span), fraction 1 = most zoomed-in (the
|
||||
/// 0.333 s quarter-note-@-180-BPM anchor). Extracted from the component so the math is unit-testable.
|
||||
/// </summary>
|
||||
public static class MixZoomMapping
|
||||
{
|
||||
/// <summary>Shortest span (max zoom): one quarter note at 180 BPM = 60/180 s. Hard anchor.</summary>
|
||||
public const double MinVisibleSeconds = 60.0 / 180.0;
|
||||
|
||||
/// <summary>Longest span (min zoom). Tunable.</summary>
|
||||
public const double MaxVisibleSeconds = 30.0;
|
||||
|
||||
/// <summary>
|
||||
/// Lower edge of the useful zoom band on the underlying fraction axis. Phase 10 retune: the bottom
|
||||
/// 60% of the old knob travel (fraction 0…0.6) was a useless slow/wide window, so the scroll knob's
|
||||
/// full [0,1] travel now maps onto the upper 0.6…1.0 band where every position reads as a useful
|
||||
/// zoom (Daniel: "range below 60% is useless; optimize for the current 60%–110% zoom values" — 110%
|
||||
/// caps at the hard 0.333 s max-zoom anchor, fraction 1.0).
|
||||
/// </summary>
|
||||
public const double ScrollKnobZoomFloor = 0.60;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the scroll-speed knob [0,1] onto the useful zoom band [<see cref="ScrollKnobZoomFloor"/>, 1.0]
|
||||
/// of the underlying fraction axis, then to visible seconds. So knob 0 sits at the slow edge of the
|
||||
/// *useful* range (not the dead slow end), and knob 1 reaches max zoom. Phase 10 scroll retune.
|
||||
/// </summary>
|
||||
public static double ScrollKnobToSeconds(double knob)
|
||||
{
|
||||
knob = Math.Clamp(knob, 0, 1);
|
||||
var fraction = ScrollKnobZoomFloor + (1.0 - ScrollKnobZoomFloor) * knob;
|
||||
return FractionToSeconds(fraction);
|
||||
}
|
||||
|
||||
/// <summary>Slider position [0, 1] -> visible seconds. 0 = zoomed out, 1 = zoomed in.</summary>
|
||||
public static double FractionToSeconds(double fraction)
|
||||
{
|
||||
fraction = Math.Clamp(fraction, 0, 1);
|
||||
var logMax = Math.Log(MaxVisibleSeconds);
|
||||
var logMin = Math.Log(MinVisibleSeconds);
|
||||
// Interpolate in log space from out (frac 0 -> logMax) to in (frac 1 -> logMin).
|
||||
return Math.Exp(logMax + (logMin - logMax) * fraction);
|
||||
}
|
||||
|
||||
/// <summary>Visible seconds -> slider position [0, 1]. Inverse of <see cref="FractionToSeconds"/>.</summary>
|
||||
public static double SecondsToFraction(double seconds)
|
||||
{
|
||||
seconds = Math.Clamp(seconds, MinVisibleSeconds, MaxVisibleSeconds);
|
||||
var logMax = Math.Log(MaxVisibleSeconds);
|
||||
var logMin = Math.Log(MinVisibleSeconds);
|
||||
return (logMax - Math.Log(seconds)) / (logMax - logMin);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ public partial class PlayStateIcon : ComponentBase, IDisposable
|
||||
{
|
||||
// The cascade is IsFixed, so the provider's re-render never reaches us; subscribe to the
|
||||
// multicast side-channel to re-render on every player state change. Reference-guarded so
|
||||
// re-parametering is idempotent. Mirrors AudioPlayerBar / TracksView.
|
||||
// re-parametering is idempotent. Mirrors AudioPlayerBar.
|
||||
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
|
||||
{
|
||||
if (_subscribedService != null)
|
||||
|
||||
@@ -2,30 +2,56 @@
|
||||
|
||||
@* Invariant trio shared by every medium's detail page: a back link, a masthead (title + artist),
|
||||
a play/share affordance row wired to the streaming player, and slots for the medium-specific
|
||||
hero visual and metadata block. TrackDetail and the Session/Mix detail pages all compose this;
|
||||
hero visual and metadata block. The Cut/Session/Mix detail pages all compose this;
|
||||
per-medium variance rides the Hero and MetaContent render fragments. *@
|
||||
|
||||
<div class="deepdrft-track-detail-container">
|
||||
|
||||
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
|
||||
← @BackLabel
|
||||
</MudLink>
|
||||
@* Two-end top row: back link (left) | optional action (right), on one SpaceBetween row. The action
|
||||
slot stays null for media that don't supply it (Track/Cut/Session), so SpaceBetween collapses to
|
||||
the back link alone at the left edge. The Mix detail page fills the action with its lava-lamp
|
||||
toggle and renders its knob band below via TopContent. *@
|
||||
<div class="deepdrft-track-detail-top-row">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
|
||||
← @BackLabel
|
||||
</MudLink>
|
||||
|
||||
@TopContent
|
||||
|
||||
@TopRightAction
|
||||
</MudStack>
|
||||
</div>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h3">@Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
|
||||
</div>
|
||||
|
||||
@* Play + share only make sense once a playable track is resolved. *@
|
||||
@if (Track is not null)
|
||||
@* The header region. A composer that wants the default masthead+play row supplies nothing; one
|
||||
that needs a different arrangement (e.g. the Cut album's left-meta / right-cover split) supplies
|
||||
its own Header fragment. Layout variance rides this slot, never a boolean flag (Phase 9 §5.3).
|
||||
ShowHeader (a gate, not a layout flag) suppresses the region entirely for composers that carry
|
||||
title/artist/play elsewhere — Mix overlays them on its hero. *@
|
||||
@if (ShowHeader)
|
||||
{
|
||||
@if (Header is not null)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<SharePopover EntryKey="@Track.EntryKey" />
|
||||
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
@Header
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h3">@Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
|
||||
</div>
|
||||
|
||||
@* Play only makes sense once a playable track is resolved. *@
|
||||
@if (Track is not null)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@Hero
|
||||
|
||||
@@ -37,4 +63,16 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Multi-track body region (the Cut album's track list). Single-track media leave it null. *@
|
||||
@BodyContent
|
||||
|
||||
@* The default share row is bound to the single resolved track. A composer that owns its own share
|
||||
affordance (the Cut header carries Play + Share inline) suppresses it via ShowShareRow. *@
|
||||
@if (Track is not null && ShowShareRow)
|
||||
{
|
||||
<div class="deepdrft-share-row">
|
||||
<SharePopover EntryKey="@Track.EntryKey" />
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace DeepDrftPublic.Client.Controls;
|
||||
/// <summary>
|
||||
/// Shared detail-page chrome for any release medium: back link, masthead, play/share affordance,
|
||||
/// and hero/meta slots. Owns the play-toggle wiring against the cascaded streaming player so each
|
||||
/// detail page supplies only its data and medium-specific visuals. Extracted from the original
|
||||
/// TrackDetail page, which is now a thin consumer of this scaffold.
|
||||
/// detail page supplies only its data and medium-specific visuals. Each medium's detail page is a
|
||||
/// thin consumer of this scaffold.
|
||||
/// </summary>
|
||||
public partial class ReleaseDetailScaffold : ComponentBase
|
||||
{
|
||||
@@ -24,12 +24,50 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
||||
[Parameter] public string BackHref { get; set; } = "/archive";
|
||||
[Parameter] public string BackLabel { get; set; } = "Archive";
|
||||
|
||||
/// <summary>
|
||||
/// Optional medium-specific content rendered as its own full-width band between the back/action top
|
||||
/// row and the masthead — the "below the back button, above the details" band. The Mix detail page
|
||||
/// uses it for the visualizer knob row (gated by Blazor on the lava-lamp toggle); other media leave
|
||||
/// it null.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? TopContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional action rendered at the top-right of the container, on the same SpaceBetween row as the
|
||||
/// back link (back link left, action right). The Mix detail page uses it for the lava-lamp
|
||||
/// visualizer-settings button; other media leave it null and render the back link alone.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? TopRightAction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional replacement for the header region (masthead + play affordance). When null, the
|
||||
/// scaffold renders its default masthead+play row wired to <see cref="PlayTrack"/>. A composer
|
||||
/// that needs a different header arrangement (e.g. the Cut album's left-meta / right-cover split
|
||||
/// with its own Play/Share buttons) supplies this — layout variance rides the slot, never a
|
||||
/// boolean flag (Phase 9 §5.3).
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? Header { get; set; }
|
||||
|
||||
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
|
||||
[Parameter] public RenderFragment? Hero { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional body region rendered below the meta block — the Cut album's multi-track listing.
|
||||
/// Single-track media leave it null.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? BodyContent { get; set; }
|
||||
|
||||
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
|
||||
[Parameter] public RenderFragment? MetaContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate for the header region (masthead + play, or a custom <see cref="Header"/>). A composer that
|
||||
/// carries title/artist/play elsewhere — e.g. Mix overlays them on its hero — sets this false to
|
||||
/// suppress the duplicate. A gate, not a layout flag, so it is slot-consistent with
|
||||
/// <see cref="ShowMeta"/> / <see cref="ShowShareRow"/> (Phase 9 §5.3). Defaults to shown.
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowHeader { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gate for the metadata block. Lets a consumer supply a <see cref="MetaContent"/> fragment but
|
||||
/// suppress the divider + block when its data is empty (slot fragments cannot be conditionally
|
||||
@@ -37,6 +75,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowMeta { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gate for the default track-keyed share row at the foot of the scaffold. A composer that owns
|
||||
/// its own share affordance (the Cut header carries Play + Share inline) sets this false to
|
||||
/// suppress the duplicate. Defaults to shown.
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowShareRow { get; set; } = true;
|
||||
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
if (Track is null || PlayerService is null) return;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.deepdrft-share-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
|
||||
@* Card grid of releases that open their own detail page (/{DetailRoute}/{id}). Shared by the
|
||||
Sessions and Mixes browse pages. Cuts intentionally do not use this — they open the track
|
||||
gallery filtered by album, a different navigation target. Fully controlled by the parent:
|
||||
loading and item state are passed in. *@
|
||||
@* The single release-card grid for every browse surface (Sessions, Mixes, Cuts, Archive). Cards
|
||||
open a detail page; how a card computes its href is the only real divergence across surfaces, so
|
||||
the parent supplies it one of two ways:
|
||||
- DetailRoute (the simple default): every card links /{DetailRoute}/{entryKey} (Sessions, Mixes).
|
||||
- HrefResolver (per-card): each card links HrefResolver(release), so Archive routes each card by
|
||||
its own medium through the one ReleaseRoutes table, and Cuts routes to /cuts/{entryKey}.
|
||||
HrefResolver wins when both are supplied. The card subtitle defaults to the artist; SubtitleResolver
|
||||
overrides it (Cuts shows a track count instead). Fully controlled by the parent: loading and item
|
||||
state are passed in. *@
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="release-gallery-container">
|
||||
@@ -33,7 +38,7 @@
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="release-card-center">
|
||||
<a href="@($"/{DetailRoute}/{release.Id}")" class="release-card-link">
|
||||
<a href="@CardHref(release)" class="release-card-link">
|
||||
<div class="release-card">
|
||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||
{
|
||||
@@ -51,7 +56,7 @@
|
||||
@release.Title
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="release-card-artist text-truncate">
|
||||
@release.Artist
|
||||
@(SubtitleResolver?.Invoke(release) ?? release.Artist)
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,8 +73,27 @@
|
||||
[Parameter] public required IReadOnlyList<DeepDrftModels.DTOs.ReleaseDto> Releases { get; set; }
|
||||
[Parameter] public bool Loading { get; set; }
|
||||
|
||||
/// <summary>Route segment for a card's detail page; a card links to /{DetailRoute}/{id}.</summary>
|
||||
[Parameter] public required string DetailRoute { get; set; }
|
||||
/// <summary>
|
||||
/// Route segment for a card's detail page; a card links to /{DetailRoute}/{entryKey}. The simple
|
||||
/// fixed-route default used by Sessions/Mixes. Ignored when <see cref="HrefResolver"/> is supplied.
|
||||
/// </summary>
|
||||
[Parameter] public string? DetailRoute { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-card href resolver. When supplied, a card links to its result instead of the
|
||||
/// <see cref="DetailRoute"/>-based href, letting Archive route each card by its own medium and
|
||||
/// Cuts route to /cuts/{entryKey} (both via <c>ReleaseRoutes.DetailHref</c>).
|
||||
/// </summary>
|
||||
[Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? HrefResolver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for a card's subtitle line (defaults to the release artist). Cuts pass a
|
||||
/// track-count label here.
|
||||
/// </summary>
|
||||
[Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? SubtitleResolver { get; set; }
|
||||
|
||||
[Parameter] public string EmptyMessage { get; set; } = "Nothing here yet";
|
||||
|
||||
private string CardHref(DeepDrftModels.DTOs.ReleaseDto release)
|
||||
=> HrefResolver?.Invoke(release) ?? $"/{DetailRoute}/{release.EntryKey}";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.release-gallery-container {
|
||||
::deep .release-gallery-container {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
@@ -30,6 +29,7 @@
|
||||
.release-card-cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 8px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
|
||||
@* Shared background-image hero with all release metadata overlaid: top row (genre/date + share),
|
||||
bottom row (optional cover thumb + title/artist + play). Single source of truth for the overlay
|
||||
composition consumed by both Session and Mix detail. Purely presentational — owns no data fetch
|
||||
and no player wiring; play/share ride in as slots so each page keeps its own toggle. Per-page
|
||||
aspect/sizing variance rides the Class parameter (e.g. Mix's square `mix-hero`), never a fork. *@
|
||||
|
||||
@{
|
||||
var hasGenre = !string.IsNullOrEmpty(Genre);
|
||||
var hasDate = ReleaseDate is not null;
|
||||
// Show the cover thumbnail only when it differs from the hero background — otherwise it would
|
||||
// duplicate the same image. Mix passes CoverThumbKey=null, so this is false there for free.
|
||||
var showCover = !string.IsNullOrEmpty(CoverThumbKey) && CoverThumbKey != HeroImageKey;
|
||||
}
|
||||
|
||||
@* The hero is the positioning context for every overlay row; the gradient shim and the
|
||||
top/bottom overlays are absolutely positioned children of this wrapper. *@
|
||||
<div class="release-hero @Class">
|
||||
@if (!string.IsNullOrEmpty(HeroImageKey))
|
||||
{
|
||||
<div class="release-hero-img"
|
||||
style="@($"background-image: url('api/image/{Uri.EscapeDataString(HeroImageKey)}');")"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="release-hero-placeholder deepdrft-gradient-soft-secondary">
|
||||
<MudIcon Icon="@PlaceholderIcon" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Darkening shim so overlaid text/controls stay legible over any image. *@
|
||||
<div class="release-hero-shim"></div>
|
||||
|
||||
@* Top overlay: secondary details (genre, release date) and the share affordance. *@
|
||||
<div class="release-hero-top">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="3" Class="release-hero-meta">
|
||||
@if (hasGenre)
|
||||
{
|
||||
<MudChip T="string" Variant="Variant.Outlined" Class="release-overlay-chip">
|
||||
@Genre
|
||||
</MudChip>
|
||||
}
|
||||
@if (hasDate)
|
||||
{
|
||||
<div class="release-overlay-date">
|
||||
<span class="release-overlay-label">Released</span>
|
||||
<span class="release-overlay-value">@ReleaseDate!.Value.ToString("MMMM yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
</MudStack>
|
||||
@if (ShareContent is not null)
|
||||
{
|
||||
<div class="release-hero-share">
|
||||
@ShareContent
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Bottom overlay: cover thumbnail, title/artist, and the play affordance in one row. *@
|
||||
<div class="release-hero-bottom">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="4" Class="release-hero-bottom-row">
|
||||
@if (showCover)
|
||||
{
|
||||
<div class="release-cover-thumb">
|
||||
<div class="deepdrft-track-detail-cover-art"
|
||||
style="@($"background-image: url('api/image/{Uri.EscapeDataString(CoverThumbKey!)}');")"></div>
|
||||
</div>
|
||||
}
|
||||
<div class="release-hero-titles">
|
||||
<div class="release-overlay-title">@Title</div>
|
||||
<div class="release-overlay-artist">@Artist</div>
|
||||
</div>
|
||||
@if (PlayContent is not null)
|
||||
{
|
||||
<div class="release-hero-play">
|
||||
@PlayContent
|
||||
</div>
|
||||
}
|
||||
</MudStack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Background image entry key. Null renders the placeholder treatment.</summary>
|
||||
[Parameter] public string? HeroImageKey { get; set; }
|
||||
|
||||
/// <summary>Material icon for the no-image placeholder (Session: Piano; Mix: Album).</summary>
|
||||
[Parameter] public required string PlaceholderIcon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional small cover thumbnail in the bottom row. Shown only when it differs from
|
||||
/// <see cref="HeroImageKey"/> (otherwise it would duplicate the background). Mix passes null.
|
||||
/// </summary>
|
||||
[Parameter] public string? CoverThumbKey { get; set; }
|
||||
|
||||
[Parameter] public required string Title { get; set; }
|
||||
[Parameter] public string? Artist { get; set; }
|
||||
[Parameter] public string? Genre { get; set; }
|
||||
[Parameter] public DateOnly? ReleaseDate { get; set; }
|
||||
|
||||
/// <summary>Share affordance slot — each page passes its own SharePopover with the right params.</summary>
|
||||
[Parameter] public RenderFragment? ShareContent { get; set; }
|
||||
|
||||
/// <summary>Play affordance slot — each page passes its PlayStateIcon wired to its own toggle.</summary>
|
||||
[Parameter] public RenderFragment? PlayContent { get; set; }
|
||||
|
||||
/// <summary>Extra class for per-page aspect/sizing variance (e.g. Mix's square `mix-hero`).</summary>
|
||||
[Parameter] public string? Class { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/* Background-image hero with the release detail overlaid directly on top, themed to match the
|
||||
NowPlaying glassmorphic family. The overlay shell (.release-hero, shim, top/bottom rows) is
|
||||
plain <div>s; per-page aspect/sizing variance rides an extra class (e.g. .mix-hero) layered on
|
||||
.release-hero. The default aspect here is Sessions' wide hero. */
|
||||
|
||||
/* Positioning context for every overlay. A tall, dominant frame rather than the 16:9 strip. */
|
||||
.release-hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
max-height: 70vh;
|
||||
min-height: 420px;
|
||||
margin-top: 1rem;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 40px color-mix(in srgb, var(--mud-palette-text-secondary) 22%, transparent);
|
||||
}
|
||||
|
||||
/* The background-image surface and placeholder are plain <div>s, so no ::deep is needed here. */
|
||||
.release-hero-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.release-hero-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--mud-palette-surface);
|
||||
}
|
||||
|
||||
::deep .release-hero-placeholder .mud-icon-root {
|
||||
font-size: 120px;
|
||||
}
|
||||
|
||||
/* Darkening gradient shim: stronger at the bottom (under the title/play row) and lighter toward
|
||||
the middle, with a top darken so the genre/share overlay stays legible too. */
|
||||
.release-hero-shim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to bottom,
|
||||
rgba(0, 0, 0, 0.55) 0%,
|
||||
rgba(0, 0, 0, 0.15) 28%,
|
||||
rgba(0, 0, 0, 0.15) 55%,
|
||||
rgba(0, 0, 0, 0.75) 100%);
|
||||
}
|
||||
|
||||
/* --- Top overlay: genre / date / share --- */
|
||||
.release-hero-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.release-overlay-date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.release-overlay-label {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.release-overlay-value {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.8rem;
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
/* Genre chip themed to the glassmorphic NowPlaying surface. The class lands on MudChip's native
|
||||
.mud-chip output, so ::deep is required to reach it. */
|
||||
::deep .release-overlay-chip.mud-chip {
|
||||
background: rgba(250, 250, 248, 0.06);
|
||||
border: 1px solid rgba(250, 250, 248, 0.12);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--deepdrft-white);
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
/* --- Bottom overlay: cover thumb / title / play --- */
|
||||
.release-hero-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.release-cover-thumb {
|
||||
flex: 0 0 auto;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(250, 250, 248, 0.12);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* The cover-thumb art surface is a plain <div>, so no ::deep is needed. */
|
||||
.release-cover-thumb .deepdrft-track-detail-cover-art {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.release-hero-titles {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.release-overlay-title {
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: clamp(1.75rem, 4vw, 2.75rem);
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
color: var(--deepdrft-white);
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.release-overlay-artist {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(250, 250, 248, 0.7);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.release-hero-play {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* The play affordance and share button sit over a dark image — force their icon glyphs to the
|
||||
light theme color regardless of MudBlazor's Secondary palette. Both PlayStateIcon and
|
||||
SharePopover render MudIconButton / MudProgressCircular internals, so ::deep is required. */
|
||||
::deep .release-hero-play .mud-icon-button,
|
||||
::deep .release-hero-play .mud-progress-circular,
|
||||
::deep .release-hero-share .mud-icon-button {
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
@media (max-width: 599.98px) {
|
||||
.release-hero {
|
||||
aspect-ratio: 3 / 4;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
/* release-hero-bottom-row rides on MudStack's native output div, so ::deep is required. */
|
||||
::deep .release-hero-bottom-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.release-cover-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,78 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
|
||||
<MudTooltip Text="Share">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Share"
|
||||
Size="Size.Large"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@Toggle"
|
||||
aria-label="Share this track" />
|
||||
</MudTooltip>
|
||||
@* Overlay is viewport-level/fixed regardless of DOM nesting; placing it outside
|
||||
the wrapper is harmless/cleaner but doesn't change its behavior.
|
||||
AutoClose is deliberately OFF: its modeless pointer-down tracking closes the
|
||||
popover on any click outside the overlay's DOM subtree — including the portaled
|
||||
embed checkbox/copy-button — which kills the embed affordance. Backdrop
|
||||
dismissal is handled by OnClick alone. *@
|
||||
<MudOverlay Visible="@_open" OnClick="@Close" />
|
||||
|
||||
<MudOverlay Visible="@_open" OnClick="@Close" AutoClose="true" />
|
||||
@* Activator and popover share this wrapper so MudPopover's anchoring reads the
|
||||
button's bounding rect. No shrink-wrap sizing — width/min-width come from the
|
||||
documented RelativeWidth="Adaptive" on the popover, not from container CSS. *@
|
||||
<div>
|
||||
<MudTooltip Text="Share">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Share"
|
||||
Size="Size.Large"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@Toggle"
|
||||
aria-label="@(IsReleaseMode ? "Share this release" : "Share this track")" />
|
||||
</MudTooltip>
|
||||
|
||||
<MudPopover Open="@_open"
|
||||
Fixed="false"
|
||||
AnchorOrigin="Origin.BottomRight"
|
||||
TransformOrigin="Origin.TopRight"
|
||||
Class="deepdrft-share-popover">
|
||||
<MudStack Spacing="2" Class="deepdrft-share-popover-body">
|
||||
<MudPopover Open="@_open"
|
||||
Fixed="true"
|
||||
RelativeWidth="DropdownWidth.Adaptive"
|
||||
AnchorOrigin="Origin.BottomLeft"
|
||||
TransformOrigin="Origin.TopLeft"
|
||||
Class="deepdrft-share-popover">
|
||||
<MudStack Spacing="2" Class="deepdrft-share-popover-body">
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Link"
|
||||
Variant="Variant.Text"
|
||||
Color="Color.Primary"
|
||||
OnClick="@CopyLink">
|
||||
Copy link
|
||||
</MudButton>
|
||||
@if (_linkCopied)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
<MudDivider />
|
||||
|
||||
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" />
|
||||
|
||||
@if (_embed)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudTextField Value="@EmbedSnippet"
|
||||
T="string"
|
||||
ReadOnly="true"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
Margin="Margin.Dense"
|
||||
Class="deepdrft-share-embed-field" />
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="0">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
Color="Color.Primary"
|
||||
OnClick="@CopyEmbed"
|
||||
aria-label="Copy embed snippet" />
|
||||
@if (_embedCopied)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Link"
|
||||
Variant="Variant.Text"
|
||||
Color="Color.Primary"
|
||||
OnClick="@CopyLink">
|
||||
Copy link
|
||||
</MudButton>
|
||||
@if (_linkCopied)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
</MudStack>
|
||||
</MudPopover>
|
||||
@* Embed is a single-track affordance only; a release page is not a single-track embed (§3b.3). *@
|
||||
@if (!IsReleaseMode)
|
||||
{
|
||||
<MudDivider />
|
||||
|
||||
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" />
|
||||
|
||||
@if (_embed)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudTextField Value="@EmbedSnippet"
|
||||
T="string"
|
||||
ReadOnly="true"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
Margin="Margin.Dense"
|
||||
Class="deepdrft-share-embed-field" />
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="0">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
Color="Color.Primary"
|
||||
OnClick="@CopyEmbed"
|
||||
aria-label="Copy embed snippet" />
|
||||
@if (_embedCopied)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
|
||||
</MudStack>
|
||||
</MudPopover>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Share affordance for the track detail page: a popover offering a canonical-link copy
|
||||
/// and an optional iframe embed snippet. Clipboard writes go through navigator.clipboard;
|
||||
/// each copy shows a transient "Copied!" confirmation that resets after a short delay.
|
||||
/// Share affordance with two modes from one source of clipboard/popover-chrome logic
|
||||
/// (Phase 11 §3b). Track mode (<see cref="EntryKey"/> set) offers a canonical-link copy plus an
|
||||
/// optional iframe embed snippet. Release mode (<see cref="ReleaseEntryKey"/> set) is copy-link-only —
|
||||
/// it copies the absolute form of the release's canonical detail URL and hides the embed
|
||||
/// affordance, since a release page is not a single-track embed. Clipboard writes go through
|
||||
/// navigator.clipboard; each copy shows a transient "Copied!" confirmation that resets after a
|
||||
/// short delay.
|
||||
/// </summary>
|
||||
public partial class SharePopover : ComponentBase, IDisposable
|
||||
{
|
||||
/// <summary>Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.</summary>
|
||||
[Parameter] public string? EntryKey { get; set; }
|
||||
|
||||
/// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and omits the embed option.</summary>
|
||||
[Parameter] public string? ReleaseEntryKey { get; set; }
|
||||
|
||||
/// <summary>Release mode: the medium of the release, used to resolve its canonical detail route.</summary>
|
||||
[Parameter] public ReleaseMedium ReleaseMedium { get; set; }
|
||||
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
[Inject] public required IJSRuntime JS { get; set; }
|
||||
|
||||
private bool IsReleaseMode => ReleaseEntryKey is not null;
|
||||
|
||||
private bool _open;
|
||||
private bool _embed;
|
||||
private bool _linkCopied;
|
||||
@@ -32,7 +47,14 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private string TrackUrl => $"{Navigation.BaseUri}track/{EntryKey}";
|
||||
// The URL "Copy link" places on the clipboard. Release mode resolves the canonical detail
|
||||
// route (which carries a leading slash) and composes it against BaseUri (which carries a
|
||||
// trailing slash) — trim one to avoid a doubled separator.
|
||||
private string LinkUrl => IsReleaseMode
|
||||
? $"{Navigation.BaseUri.TrimEnd('/')}{ReleaseRoutes.DetailHref(ReleaseEntryKey!, ReleaseMedium)}"
|
||||
: TrackUrl;
|
||||
|
||||
private string TrackUrl => $"{Navigation.BaseUri}tracks/{EntryKey}";
|
||||
|
||||
private string EmbedSnippet =>
|
||||
$"""<iframe src="{Navigation.BaseUri}FramePlayer?TrackEntryKey={EntryKey}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
|
||||
@@ -43,7 +65,7 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
|
||||
private async Task CopyLink()
|
||||
{
|
||||
if (await CopyToClipboard(TrackUrl))
|
||||
if (await CopyToClipboard(LinkUrl))
|
||||
{
|
||||
_linkCopied = true;
|
||||
await ResetAfterDelay(() => _linkCopied = false);
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
@{
|
||||
var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey);
|
||||
var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null;
|
||||
var hasArt = !string.IsNullOrEmpty(TrackModel?.Release?.ImagePath);
|
||||
}
|
||||
|
||||
@if (ViewMode == GalleryViewMode.Grid)
|
||||
{
|
||||
<div class="deepdrft-track-card-container @(hasArt ? "deepdrft-track-card-container--art" : "")">
|
||||
|
||||
@* Cover and title/artist link to the detail page; the play button (below, outside any
|
||||
anchor) stays the sole playback entry point. display:contents keeps the grid intact. *@
|
||||
@if (hasLink)
|
||||
{
|
||||
<a href="@trackHref" class="deepdrft-track-card-link">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-card-fallback"></div>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-card-fallback"></div>
|
||||
}
|
||||
|
||||
<div class="deepdrft-track-card-content">
|
||||
|
||||
@if (hasLink)
|
||||
{
|
||||
<a href="@trackHref" class="deepdrft-track-card-link">
|
||||
<div class="deepdrft-track-info-top">
|
||||
<MudText Typo="Typo.subtitle1"
|
||||
Class="deepdrft-track-title text-truncate mb-1">
|
||||
@TrackModel?.TrackName
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-artist text-truncate mb-2">
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-info-top">
|
||||
<MudText Typo="Typo.subtitle1"
|
||||
Class="deepdrft-track-title text-truncate mb-1">
|
||||
@TrackModel?.TrackName
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-artist text-truncate mb-2">
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="deepdrft-track-info-middle">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Title))
|
||||
{
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-meta text-truncate">
|
||||
@TrackModel.Release!.Title
|
||||
</MudText>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@TrackModel.Release!.Genre
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="deepdrft-track-info-bottom">
|
||||
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-meta">
|
||||
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div></div>
|
||||
}
|
||||
|
||||
<MudFab Color="Color.Tertiary"
|
||||
Size="Size.Medium"
|
||||
StartIcon="@PlayPauseIcon"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@PlayClick"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-row @(IsPlaying ? "deepdrft-track-row--playing" : "")">
|
||||
|
||||
<MudFab Color="Color.Tertiary"
|
||||
Size="Size.Medium"
|
||||
StartIcon="@PlayPauseIcon"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@PlayClick"
|
||||
Class="deepdrft-track-row-fab"/>
|
||||
|
||||
@if (hasLink)
|
||||
{
|
||||
<a href="@trackHref" class="deepdrft-track-row-link">
|
||||
@* art thumb *@
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb deepdrft-track-row-thumb--fallback"></div>
|
||||
}
|
||||
|
||||
@* text block *@
|
||||
<div class="deepdrft-track-row-text">
|
||||
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
||||
@TrackModel?.TrackName
|
||||
</MudText>
|
||||
</div>
|
||||
|
||||
@* right metadata *@
|
||||
<div class="deepdrft-track-row-meta">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@TrackModel.Release!.Genre
|
||||
</MudChip>
|
||||
}
|
||||
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
||||
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* same structure without anchor *@
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb deepdrft-track-row-thumb--fallback"></div>
|
||||
}
|
||||
<div class="deepdrft-track-row-text">
|
||||
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
||||
@TrackModel?.TrackName
|
||||
</MudText>
|
||||
</div>
|
||||
<div class="deepdrft-track-row-meta">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@TrackModel.Release!.Genre
|
||||
</MudChip>
|
||||
}
|
||||
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
||||
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
public partial class TrackCard : ComponentBase
|
||||
{
|
||||
[Parameter] public required TrackDto TrackModel { get; set; }
|
||||
[Parameter] public EventCallback<TrackDto> OnPlay { get; set; }
|
||||
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
|
||||
[Parameter] public bool IsPlaying { get; set; } = false;
|
||||
[Parameter] public bool IsPaused { get; set; } = false;
|
||||
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
|
||||
|
||||
// Pause only when actively playing; every other state (idle, paused) reads as "press to play".
|
||||
private bool IsActivelyPlaying => IsPlaying && !IsPaused;
|
||||
|
||||
private string PlayPauseIcon =>
|
||||
IsActivelyPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
|
||||
|
||||
private async Task PlayClick()
|
||||
{
|
||||
if (IsActivelyPlaying)
|
||||
{
|
||||
if (OnPause.HasDelegate)
|
||||
await OnPause.InvokeAsync(TrackModel);
|
||||
}
|
||||
else if (OnPlay.HasDelegate)
|
||||
{
|
||||
await OnPlay.InvokeAsync(TrackModel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/* Container — transparent so the absolute-positioned fallback panel or album art
|
||||
controls the card's background. Glass edge matches NowPlayingCard vocabulary. */
|
||||
.deepdrft-track-card-container {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
min-width: 250px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border: 2px solid var(--mud-palette-secondary);
|
||||
}
|
||||
|
||||
.deepdrft-track-card-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: brightness(0.7);
|
||||
}
|
||||
|
||||
.deepdrft-track-card-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: linear-gradient(to top,
|
||||
rgba(13, 27, 42, 0.75) 0%,
|
||||
rgba(13, 27, 42, 0.35) 45%,
|
||||
rgba(13, 27, 42, 0.00) 100%);
|
||||
}
|
||||
|
||||
/* Fallback panel — solid navy, opaque so the card reads correctly on both
|
||||
light and dark page backgrounds. Semi-transparent + blur washes out on white. */
|
||||
.deepdrft-track-card-fallback {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: var(--deepdrft-navy-mid, #162437);
|
||||
border: 1px solid rgba(250, 250, 248, 0.12);
|
||||
}
|
||||
|
||||
/* Title: off-white — matches .np-title.
|
||||
::deep required: MudText renders its own element, so Blazor isolation
|
||||
won't stamp b-{hash} on it; ::deep pierces into child component output. */
|
||||
::deep .deepdrft-track-title { color: var(--deepdrft-white, #FAFAF8); }
|
||||
|
||||
/* Artist: muted off-white — green reserved for interactive elements (FAB, chip). ::deep for same reason. */
|
||||
::deep .deepdrft-track-artist { color: rgba(250, 250, 248, 0.55); }
|
||||
|
||||
/* Meta: muted off-white — matches .np-sub. ::deep for same reason. */
|
||||
::deep .deepdrft-track-meta { color: rgba(250, 250, 248, 0.45); }
|
||||
|
||||
/* FAB always green-interactive — card is always dark glass regardless of page theme.
|
||||
.mud-button-filled-tertiary specificity (0,1,0) in MudBlazor; our (0,1,1) wins. */
|
||||
::deep .mud-button-filled-tertiary {
|
||||
background-color: var(--deepdrft-green-interactive, #3aa163);
|
||||
color: var(--deepdrft-white, #FAFAF8);
|
||||
}
|
||||
|
||||
/* Genre chip always green-accent outline/text on the dark glass card. */
|
||||
::deep .deepdrft-genre-chip.mud-chip-outlined {
|
||||
border-color: var(--deepdrft-green-accent, #3D7A68);
|
||||
color: var(--deepdrft-green-accent, #3D7A68);
|
||||
}
|
||||
::deep .deepdrft-genre-chip.mud-chip-color-tertiary {
|
||||
color: var(--deepdrft-green-accent, #3D7A68);
|
||||
}
|
||||
|
||||
.deepdrft-track-info-middle { margin: 8px 0; }
|
||||
|
||||
.deepdrft-track-info-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.deepdrft-track-card-container {
|
||||
min-width: 200px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mode A: hover-reveal overlay (art cards only) ──────────────────────── */
|
||||
|
||||
/* Gate the hidden-at-rest rule on (a) art present and (b) a hover-capable pointer.
|
||||
Fallback cards (no --art modifier) and touch devices always show the overlay. */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.deepdrft-track-card-container--art .deepdrft-track-card-content {
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
transition: opacity 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
.deepdrft-track-card-container--art:hover .deepdrft-track-card-content {
|
||||
opacity: 1;
|
||||
background: rgba(22, 36, 55, 0.82);
|
||||
transition: opacity 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mode B: list row ───────────────────────────────────────────────────── */
|
||||
|
||||
.deepdrft-track-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 80px;
|
||||
padding: 8px 16px;
|
||||
background: var(--mud-palette-surface);
|
||||
border: 1px solid var(--mud-palette-divider);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.deepdrft-track-row-link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::deep .deepdrft-track-row-fab {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.deepdrft-track-row-thumb {
|
||||
flex: 0 0 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.deepdrft-track-row-thumb--fallback {
|
||||
background: var(--deepdrft-navy-mid);
|
||||
border: 1px solid var(--mud-palette-divider);
|
||||
}
|
||||
|
||||
.deepdrft-track-row-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.deepdrft-track-row-meta {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.deepdrft-track-row {
|
||||
height: auto;
|
||||
min-height: 72px;
|
||||
padding: 8px 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.deepdrft-track-row-thumb {
|
||||
flex: 0 0 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.deepdrft-track-row--playing {
|
||||
border-left: 3px solid var(--deepdrft-green-interactive, #3aa163);
|
||||
}
|
||||
|
||||
/* ── Mode B text: theme-aware overrides (navy on light / off-white on dark) ─ */
|
||||
|
||||
/* The global ::deep rules above hard-code off-white for the dark glass grid cards.
|
||||
List rows use --mud-palette-surface as their background, so text must follow
|
||||
the theme. These selectors have higher specificity (.deepdrft-track-row[b-hash]
|
||||
vs plain [b-hash]) and win in the cascade. */
|
||||
.deepdrft-track-row ::deep .deepdrft-track-title,
|
||||
.deepdrft-track-row ::deep .deepdrft-track-artist,
|
||||
.deepdrft-track-row ::deep .deepdrft-track-meta {
|
||||
color: var(--mud-palette-text-primary);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
@if (ViewMode == GalleryViewMode.Grid)
|
||||
{
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="tracks-gallery-container">
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var track in Tracks)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="deepdrft-track-gallery-item-center">
|
||||
<TrackCard TrackModel="@track"
|
||||
ViewMode="@ViewMode"
|
||||
IsPlaying="@(IsPlaying && ActiveTrack?.Id == track.Id)"
|
||||
IsPaused="@(IsPaused && ActiveTrack?.Id == track.Id)"
|
||||
OnPlay="@HandlePlayClick"
|
||||
OnPause="@HandlePauseClick"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudContainer MaxWidth="MaxWidth.Large">
|
||||
<div class="deepdrft-track-list">
|
||||
@foreach (var track in Tracks)
|
||||
{
|
||||
<TrackCard TrackModel="@track"
|
||||
ViewMode="@ViewMode"
|
||||
IsPlaying="@(IsPlaying && ActiveTrack?.Id == track.Id)"
|
||||
IsPaused="@(IsPaused && ActiveTrack?.Id == track.Id)"
|
||||
OnPlay="@HandlePlayClick"
|
||||
OnPause="@HandlePauseClick"/>
|
||||
}
|
||||
</div>
|
||||
</MudContainer>
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
public partial class TracksGallery : ComponentBase
|
||||
{
|
||||
[Parameter] public IEnumerable<TrackDto> Tracks { get; set; } = [];
|
||||
|
||||
// Controlled play-state inputs: the parent owns playback truth (the player service)
|
||||
// and drives these. The gallery is presentational — it only matches by id to decide
|
||||
// which card reflects the active state.
|
||||
[Parameter] public TrackDto? ActiveTrack { get; set; }
|
||||
[Parameter] public bool IsPlaying { get; set; }
|
||||
[Parameter] public bool IsPaused { get; set; }
|
||||
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
|
||||
|
||||
[Parameter] public EventCallback<TrackDto> OnPlay { get; set; }
|
||||
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
|
||||
|
||||
private Task HandlePlayClick(TrackDto track) =>
|
||||
OnPlay.HasDelegate ? OnPlay.InvokeAsync(track) : Task.CompletedTask;
|
||||
|
||||
private Task HandlePauseClick(TrackDto track) =>
|
||||
OnPause.HasDelegate ? OnPause.InvokeAsync(track) : Task.CompletedTask;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.tracks-gallery-container {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.deepdrft-track-gallery-item-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.deepdrft-track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
.deepdrft-footer {
|
||||
/* position:relative + z-index:1 creates a stacking context that paints above the
|
||||
MixWaveformVisualizer backdrop (z-index:0), keeping footer text fully legible. */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--deepdrft-white);
|
||||
border-top: 1px solid var(--deepdrft-border);
|
||||
padding: 3rem;
|
||||
|
||||
@@ -22,8 +22,8 @@ public static class Pages
|
||||
// ARCHIVE (→ the release-cardinal /archive browser) carries the three medium modes as Children.
|
||||
// Above the medium breakpoint the desktop nav flattens them into inline appbar links beside
|
||||
// ARCHIVE (no popover); below the breakpoint the mobile hamburger renders them as an indented
|
||||
// sub-list under ARCHIVE. /tracks and /genres are intentionally absent from the nav (8.I) —
|
||||
// their routes (TracksView, GenresView) remain reachable by direct URL.
|
||||
// sub-list under ARCHIVE. /genres is intentionally absent from the nav (8.I) — its route
|
||||
// (GenresView) remains reachable by direct URL.
|
||||
public static readonly List<PageRoute> MenuPages =
|
||||
[
|
||||
new()
|
||||
|
||||
@@ -1,63 +1,12 @@
|
||||
@page "/cuts"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<PageTitle>DeepDrft Cuts</PageTitle>
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var _ in Enumerable.Range(0, 8))
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="album-card-center">
|
||||
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
else if (_albums.Count == 0)
|
||||
{
|
||||
<div class="albums-empty">
|
||||
<MudText Typo="Typo.h6">No albums yet</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var album in _albums)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="album-card-center">
|
||||
<div class="album-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@onclick="@(() => OpenAlbum(album.Title))">
|
||||
@if (!string.IsNullOrEmpty(album.ImagePath))
|
||||
{
|
||||
<div class="album-card-cover"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(album.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="album-card-cover album-card-cover--fallback"></div>
|
||||
}
|
||||
|
||||
<div class="album-card-body">
|
||||
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
|
||||
@album.Title
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="album-card-count">
|
||||
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
@* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table.
|
||||
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
|
||||
<ReleaseGallery Releases="@_albums"
|
||||
Loading="@_loading"
|
||||
HrefResolver="@ReleaseRoutes.DetailHref"
|
||||
SubtitleResolver="@TrackCountLabel"
|
||||
EmptyMessage="No albums yet" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Models.Common;
|
||||
@@ -9,8 +10,8 @@ namespace DeepDrftPublic.Client.Pages;
|
||||
/// <summary>
|
||||
/// Medium-filtered release gallery. Routed at <c>/cuts</c> (Cut releases) and parameterized by
|
||||
/// <see cref="Medium"/> so the same component can back any medium's card grid without a fork.
|
||||
/// Cards open the track gallery filtered to that release's album title, preserving the original
|
||||
/// /albums ergonomics.
|
||||
/// Cards open the release's dedicated detail page via <see cref="ReleaseRoutes.DetailHref(ReleaseDto)"/>
|
||||
/// (a Cut routes to <c>/cuts/{entryKey}</c>), the single source for medium→route resolution (Phase 11 §2).
|
||||
/// </summary>
|
||||
public partial class AlbumsView : ComponentBase, IDisposable
|
||||
{
|
||||
@@ -18,7 +19,6 @@ public partial class AlbumsView : ComponentBase, IDisposable
|
||||
|
||||
[Inject] public required IReleaseDataService ReleaseData { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
|
||||
// The medium whose releases this grid shows. Defaults to Cut for the /cuts route; other media
|
||||
// can reuse this component by passing a different value. Drives both the fetch filter and the
|
||||
@@ -33,8 +33,8 @@ public partial class AlbumsView : ComponentBase, IDisposable
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Bridge the prerendered fetch across the prerender -> WASM seam (see TracksView). Without
|
||||
// this, the WASM pass re-fetches and replays the card entrance animations.
|
||||
// Bridge the prerendered fetch across the prerender -> WASM seam (see MediumBrowseBase).
|
||||
// Without this, the WASM pass re-fetches and replays the card entrance animations.
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistAlbums);
|
||||
|
||||
if (PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored) && restored is not null)
|
||||
@@ -58,8 +58,9 @@ public partial class AlbumsView : ComponentBase, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OpenAlbum(string album)
|
||||
=> Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}");
|
||||
// Cut cards show track count where the shared card otherwise shows the artist.
|
||||
private static string TrackCountLabel(ReleaseDto album)
|
||||
=> $"{album.TrackCount} {(album.TrackCount == 1 ? "track" : "tracks")}";
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
.albums-view-container {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.album-card-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.album-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.album-card-cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.album-card-cover--fallback {
|
||||
background-color: var(--mud-palette-dark, #1a2238);
|
||||
}
|
||||
|
||||
.album-card-body {
|
||||
padding: 8px 4px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* album-card-title / album-card-count ride on MudText, a child Razor component whose
|
||||
root Blazor isolation does not scope-stamp; ::deep pierces into its output. */
|
||||
::deep .album-card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::deep .album-card-count {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.albums-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
@@ -1,116 +1,71 @@
|
||||
@page "/archive"
|
||||
@using DeepDrftModels.Enums
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<PageTitle>DeepDrft Archive</PageTitle>
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
||||
@* Search + filter affordances are interactive-only: the debounce timer and chip selection
|
||||
need WASM. During prerender/non-interactive they are hidden, matching TracksView's gate.
|
||||
need WASM. During prerender/non-interactive they are hidden behind the interactive gate.
|
||||
The release grid still prerenders so the archive is meaningful before hydration. *@
|
||||
@if (RendererInfo.IsInteractive)
|
||||
{
|
||||
<div class="archive-search-row">
|
||||
<MudTextField T="string"
|
||||
Value="@SearchText"
|
||||
ValueChanged="@OnSearchInput"
|
||||
Immediate="true"
|
||||
DebounceInterval="400"
|
||||
Placeholder="Search releases or artists"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Clearable="true"
|
||||
Class="archive-search-field"/>
|
||||
</div>
|
||||
|
||||
<div class="archive-filter-row">
|
||||
<MudToggleGroup T="ReleaseMedium?"
|
||||
Value="@_selectedMedium"
|
||||
ValueChanged="@OnMediumSelected"
|
||||
SelectionMode="SelectionMode.SingleSelection"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
Class="archive-medium-toggle">
|
||||
<MudToggleItem T="ReleaseMedium?" Value="@(null)">All</MudToggleItem>
|
||||
@foreach (var medium in _media)
|
||||
{
|
||||
<MudToggleItem T="ReleaseMedium?" Value="@medium">@MediumLabel(medium)</MudToggleItem>
|
||||
}
|
||||
</MudToggleGroup>
|
||||
</div>
|
||||
|
||||
@if (_genres.Count > 0)
|
||||
{
|
||||
<div class="archive-filter-row">
|
||||
<MudChipSet T="string"
|
||||
SelectedValue="@_selectedGenre"
|
||||
SelectedValueChanged="@OnGenreSelected"
|
||||
SelectionMode="SelectionMode.ToggleSelection"
|
||||
Class="archive-genre-chips">
|
||||
@foreach (var genre in _genres)
|
||||
{
|
||||
<MudChip T="string" Value="@genre.Genre">@genre.Genre</MudChip>
|
||||
}
|
||||
</MudChipSet>
|
||||
<div class="archive-controls-row">
|
||||
<div class="archive-controls-search">
|
||||
<MudTextField T="string"
|
||||
Value="@SearchText"
|
||||
ValueChanged="@OnSearchInput"
|
||||
Immediate="true"
|
||||
DebounceInterval="400"
|
||||
Placeholder="Search releases or artists"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Clearable="true"
|
||||
Class="archive-search-field"/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var _ in Enumerable.Range(0, 8))
|
||||
<div class="archive-controls-medium">
|
||||
<MudToggleGroup T="ReleaseMedium?"
|
||||
Value="@_selectedMedium"
|
||||
ValueChanged="@OnMediumSelected"
|
||||
SelectionMode="SelectionMode.SingleSelection"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
Class="archive-medium-toggle">
|
||||
<MudToggleItem T="ReleaseMedium?" Value="@(null)">All</MudToggleItem>
|
||||
@foreach (var medium in _media)
|
||||
{
|
||||
<MudToggleItem T="ReleaseMedium?" Value="@medium">@MediumLabel(medium)</MudToggleItem>
|
||||
}
|
||||
</MudToggleGroup>
|
||||
</div>
|
||||
|
||||
@if (_genres.Count > 0)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="archive-card-center">
|
||||
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
<div class="archive-controls-genre">
|
||||
<MudChipSet T="string"
|
||||
SelectedValue="@_selectedGenre"
|
||||
SelectedValueChanged="@OnGenreSelected"
|
||||
SelectionMode="SelectionMode.ToggleSelection"
|
||||
Class="archive-genre-chips">
|
||||
@foreach (var genre in _genres)
|
||||
{
|
||||
<MudChip T="string" Value="@genre.Genre">@genre.Genre</MudChip>
|
||||
}
|
||||
</MudChipSet>
|
||||
</div>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
else if (_releases.Count == 0)
|
||||
{
|
||||
<div class="archive-empty">
|
||||
<MudText Typo="Typo.h6">No releases found</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var release in _releases)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="archive-card-center">
|
||||
<a href="@DetailHref(release)" class="archive-card-link">
|
||||
<div class="archive-release-card">
|
||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||
{
|
||||
<div class="archive-release-cover"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(release.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="archive-release-cover archive-release-cover--fallback"></div>
|
||||
}
|
||||
|
||||
<div class="archive-release-body">
|
||||
<MudText Typo="Typo.subtitle1" Class="archive-release-title text-truncate">
|
||||
@release.Title
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="archive-release-artist text-truncate">
|
||||
@release.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@* The grid itself is the shared release-card component; each card routes by its own medium through
|
||||
the one ReleaseRoutes table. The Archive-specific search/filter chrome above stays here. *@
|
||||
<ReleaseGallery Releases="@_releases"
|
||||
Loading="@_loading"
|
||||
HrefResolver="@ReleaseRoutes.DetailHref"
|
||||
EmptyMessage="No releases found" />
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,14 @@ namespace DeepDrftPublic.Client.Pages;
|
||||
/// The public archive: a release-cardinal searchable browser over every release across all media
|
||||
/// (Phase 9 §8.H, decision H2). Replaces the former three-card medium overview. Search (Title /
|
||||
/// Artist), an enum-driven medium filter, and a genre filter narrow the release list; each card
|
||||
/// routes to its per-medium detail. Mirrors the <see cref="TracksView"/> seam: the unfiltered first
|
||||
/// page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor replays
|
||||
/// the card entrance animations.
|
||||
/// routes to its per-medium detail. Mirrors the <see cref="MediumBrowseBase"/> seam: the unfiltered
|
||||
/// first page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor
|
||||
/// replays the card entrance animations.
|
||||
///
|
||||
/// Filter state is URL-bound (Phase 11 §5): <c>q</c> / <c>medium</c> / <c>genre</c> enter via
|
||||
/// <see cref="SupplyParameterFromQueryAttribute"/> and leave via <see cref="NavigationManager.NavigateTo(string)"/>.
|
||||
/// The URL is the single source of truth — filter handlers only navigate; the seed-and-fetch reaction
|
||||
/// lives in <see cref="OnParametersSetAsync"/> so back/forward history "just works" (§5.3 Option A).
|
||||
/// </summary>
|
||||
public partial class ArchiveView : ComponentBase, IDisposable
|
||||
{
|
||||
@@ -24,6 +29,14 @@ public partial class ArchiveView : ComponentBase, IDisposable
|
||||
[Inject] public required IReleaseDataService ReleaseData { get; set; }
|
||||
[Inject] public required ITrackDataService TrackData { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
|
||||
// Query-string-bound filter inputs (§5.1). All optional; an absent param means "no filter on that
|
||||
// axis". `medium` is the lowercase enum token the data service already speaks, parsed back with
|
||||
// Enum.TryParse(ignoreCase) + Enum.IsDefined (the BatchUpload / API posture).
|
||||
[SupplyParameterFromQuery(Name = "q")] public string? QueryParam { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "genre")] public string? GenreParam { get; set; }
|
||||
|
||||
// Medium filter chips are enum-driven so a fourth medium surfaces a chip from one lookup entry,
|
||||
// with no markup fork (Phase 9 extension discipline).
|
||||
@@ -41,24 +54,50 @@ public partial class ArchiveView : ComponentBase, IDisposable
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
// Idempotency guard for the OnParametersSet reaction (§5.3): a same-route query change re-fires
|
||||
// OnParametersSet, and the search debounce can race a rapid medium-chip nav. We only re-seed and
|
||||
// re-fetch when the composed filter triple actually changed. Sentinel != any real key so the first
|
||||
// reaction always runs.
|
||||
private string? _loadedFilterKey;
|
||||
|
||||
private bool HasActiveFilter =>
|
||||
_selectedMedium is not null
|
||||
|| !string.IsNullOrWhiteSpace(_selectedGenre)
|
||||
|| !string.IsNullOrWhiteSpace(SearchText);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// The seed-and-fetch reaction lives here, not in OnInitialized: a same-route query change reuses
|
||||
// the component and fires only OnParametersSet (§5.3). Keyed off the composed filter triple so an
|
||||
// identical param set (debounce vs. chip-nav race) is a no-op.
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Seed filter fields from the URL before the restore/fetch decision so HasActiveFilter reflects
|
||||
// the requested URL (a direct /archive?medium=mix load must fetch, not restore the bridge).
|
||||
SeedFromQuery();
|
||||
|
||||
var filterKey = ComposeFilterKey();
|
||||
if (filterKey == _loadedFilterKey)
|
||||
return;
|
||||
|
||||
var firstReaction = _loadedFilterKey is null;
|
||||
_loadedFilterKey = filterKey;
|
||||
|
||||
// The genre chip source is the release-cardinal distinct-genre list (already sourced from the
|
||||
// release join — see GetDistinctGenresAsync). It only renders interactively, so it is fetched
|
||||
// lazily on the interactive pass rather than persisted.
|
||||
if (RendererInfo.IsInteractive)
|
||||
if (RendererInfo.IsInteractive && _genres.Count == 0)
|
||||
await LoadGenres();
|
||||
|
||||
// The prerendered page is always the unfiltered first page. Restore it only when no filter is
|
||||
// active; a filtered interactive pass must fetch its own narrowed result instead.
|
||||
if (!HasActiveFilter
|
||||
// The prerendered page is always the unfiltered first page. Restore it only on the first
|
||||
// reaction and only when no filter is active; a filtered load (or a later filter change) must
|
||||
// fetch its own narrowed result instead.
|
||||
if (firstReaction
|
||||
&& !HasActiveFilter
|
||||
&& PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored)
|
||||
&& restored is not null)
|
||||
{
|
||||
@@ -70,6 +109,22 @@ public partial class ArchiveView : ComponentBase, IDisposable
|
||||
await LoadReleases();
|
||||
}
|
||||
|
||||
// Maps the query-string params onto the filter fields. `medium` parsed leniently (ignoreCase) and
|
||||
// validated with Enum.IsDefined so a stray token degrades to "All" rather than throwing.
|
||||
private void SeedFromQuery()
|
||||
{
|
||||
SearchText = string.IsNullOrWhiteSpace(QueryParam) ? null : QueryParam;
|
||||
_selectedGenre = string.IsNullOrWhiteSpace(GenreParam) ? null : GenreParam;
|
||||
_selectedMedium =
|
||||
!string.IsNullOrWhiteSpace(MediumParam)
|
||||
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
|
||||
&& Enum.IsDefined(medium)
|
||||
? medium
|
||||
: null;
|
||||
}
|
||||
|
||||
private string ComposeFilterKey() => $"{SearchText}|{_selectedMedium}|{_selectedGenre}";
|
||||
|
||||
private async Task LoadGenres()
|
||||
{
|
||||
var result = await TrackData.GetGenres();
|
||||
@@ -95,35 +150,42 @@ public partial class ArchiveView : ComponentBase, IDisposable
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
// Filter handlers only navigate (§5.3 Option A): the query-param change re-runs the seed-and-fetch
|
||||
// reaction in OnParametersSet, keeping the URL the single source of truth and making back/forward
|
||||
// history correct for free.
|
||||
|
||||
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
|
||||
// burst reaches here. Re-fetches with the composed filter (search + medium + genre).
|
||||
private async Task OnSearchInput(string? value)
|
||||
{
|
||||
SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
await LoadReleases();
|
||||
}
|
||||
// burst reaches here.
|
||||
private void OnSearchInput(string? value) => NavigateToFilter(
|
||||
search: string.IsNullOrWhiteSpace(value) ? null : value,
|
||||
medium: _selectedMedium,
|
||||
genre: _selectedGenre);
|
||||
|
||||
private async Task OnMediumSelected(ReleaseMedium? medium)
|
||||
{
|
||||
_selectedMedium = medium;
|
||||
await LoadReleases();
|
||||
}
|
||||
private void OnMediumSelected(ReleaseMedium? medium) => NavigateToFilter(
|
||||
search: SearchText,
|
||||
medium: medium,
|
||||
genre: _selectedGenre);
|
||||
|
||||
private async Task OnGenreSelected(string? genre)
|
||||
{
|
||||
_selectedGenre = string.IsNullOrWhiteSpace(genre) ? null : genre;
|
||||
await LoadReleases();
|
||||
}
|
||||
private void OnGenreSelected(string? genre) => NavigateToFilter(
|
||||
search: SearchText,
|
||||
medium: _selectedMedium,
|
||||
genre: string.IsNullOrWhiteSpace(genre) ? null : genre);
|
||||
|
||||
// Per-medium detail target. Session/Mix open their own detail page; a Cut has no single-release
|
||||
// detail page, so it opens the track gallery filtered to its release title — the same destination
|
||||
// AlbumsView's Cut cards use, preserving the established navigation.
|
||||
private static string DetailHref(ReleaseDto release) => release.Medium switch
|
||||
// Composes the /archive?... URL from the requested filter triple. Each axis is omitted when null
|
||||
// (matches the null-means-all scheme); values are escaped. Plain /archive when all three are clear.
|
||||
private void NavigateToFilter(string? search, ReleaseMedium? medium, string? genre)
|
||||
{
|
||||
ReleaseMedium.Session => $"/sessions/{release.Id}",
|
||||
ReleaseMedium.Mix => $"/mixes/{release.Id}",
|
||||
_ => $"/tracks?album={Uri.EscapeDataString(release.Title)}",
|
||||
};
|
||||
var query = new List<string>(3);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query.Add($"q={Uri.EscapeDataString(search)}");
|
||||
if (medium is not null)
|
||||
query.Add($"medium={medium.ToString()!.ToLowerInvariant()}");
|
||||
if (!string.IsNullOrWhiteSpace(genre))
|
||||
query.Add($"genre={Uri.EscapeDataString(genre)}");
|
||||
|
||||
var url = query.Count > 0 ? $"/archive?{string.Join('&', query)}" : "/archive";
|
||||
Navigation.NavigateTo(url);
|
||||
}
|
||||
|
||||
// Display label for a medium filter chip. Centralised so a new medium's label is one entry, not a
|
||||
// markup change. "DJ Mix" matches the CMS Type-chip wording (§8.D).
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.archive-search-row {
|
||||
/* Single flex row: search left | toggle center | chips right.
|
||||
On narrow screens the medium toggle reflows to its own centered row below. */
|
||||
.archive-controls-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 12px 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 36px 0 20px 0;
|
||||
}
|
||||
|
||||
.archive-controls-search {
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* archive-search-field rides on MudTextField, whose root is a child Razor component element.
|
||||
@@ -15,69 +24,29 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.archive-filter-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.archive-card-center {
|
||||
.archive-controls-medium {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.archive-card-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.archive-release-card {
|
||||
.archive-controls-genre {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 120ms ease;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.archive-release-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
/* Narrow screens: push the medium toggle to a full-width centered second row.
|
||||
Search and genre chips stay together on the first row. */
|
||||
@media (max-width: 600px) {
|
||||
.archive-controls-medium {
|
||||
order: 1;
|
||||
flex: 1 0 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.archive-release-cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.archive-release-cover--fallback {
|
||||
background-color: var(--mud-palette-dark, #1a2238);
|
||||
}
|
||||
|
||||
.archive-release-body {
|
||||
padding: 8px 4px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* archive-release-title / archive-release-artist ride on MudText (child Razor component); ::deep
|
||||
pierces into its output since Blazor isolation does not scope-stamp child component roots. */
|
||||
::deep .archive-release-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::deep .archive-release-artist {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.archive-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
.archive-controls-genre {
|
||||
order: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
@page "/cuts/{EntryKey}"
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits CutDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
||||
<div class="d-flex justify-center mt-4">
|
||||
<MudButton Href="/cuts"
|
||||
Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack">
|
||||
All cuts
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var release = ViewModel.Release;
|
||||
var hasGenre = release.Genre is not null;
|
||||
var hasYear = release.ReleaseDate is not null;
|
||||
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
|
||||
|
||||
<ReleaseDetailScaffold Title="@release.Title"
|
||||
Artist="@release.Artist"
|
||||
Track="@firstTrack"
|
||||
BackHref="/cuts"
|
||||
BackLabel="All cuts"
|
||||
ShowShareRow="false">
|
||||
<Header>
|
||||
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
|
||||
<div class="cut-detail-header">
|
||||
<div class="cut-detail-meta">
|
||||
<MudText Typo="Typo.h3">@release.Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@release.Artist</MudText>
|
||||
|
||||
@if (hasGenre || hasYear)
|
||||
{
|
||||
<div class="cut-detail-subline">
|
||||
@if (hasGenre)
|
||||
{
|
||||
<span class="cut-detail-genre">@release.Genre</span>
|
||||
}
|
||||
@if (hasGenre && hasYear)
|
||||
{
|
||||
<span class="cut-detail-sep">·</span>
|
||||
}
|
||||
@if (hasYear)
|
||||
{
|
||||
<span class="cut-detail-year">@release.ReleaseDate!.Value.Year</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cut-detail-actions">
|
||||
@* Header Play loads the full album into the queue at index 0 (§3.4 seam,
|
||||
closed P11 W1). Disabled until at least one streamable track is resolved. *@
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.PlayArrow"
|
||||
Disabled="@(firstTrack is null || !RendererInfo.IsInteractive)"
|
||||
OnClick="@PlayAlbum">
|
||||
Play
|
||||
</MudButton>
|
||||
|
||||
@* Release-mode share: copies the canonical /cuts/{entryKey} URL, not a single track (§3b). *@
|
||||
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cut-detail-cover">
|
||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
|
||||
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
<BodyContent>
|
||||
<MudDivider Class="cut-detail-divider" />
|
||||
@if (ViewModel.Tracks.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="cut-detail-empty">No tracks in this cut yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cut-detail-tracklist">
|
||||
@for (var i = 0; i < ViewModel.Tracks.Count; i++)
|
||||
{
|
||||
var track = ViewModel.Tracks[i];
|
||||
var index = i;
|
||||
<div class="cut-detail-track-row">
|
||||
<span class="cut-detail-track-number">@track.TrackNumber</span>
|
||||
<div class="cut-detail-track-play">
|
||||
<PlayStateIcon Track="@track"
|
||||
Size="Size.Medium"
|
||||
Color="Color.Secondary"
|
||||
OnToggle="@(() => PlayTrack(track, index))" />
|
||||
</div>
|
||||
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</BodyContent>
|
||||
</ReleaseDetailScaffold>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
// Header Play: load the full album into the queue starting at track 0.
|
||||
private Task PlayAlbum()
|
||||
{
|
||||
if (ViewModel.Tracks.Count == 0) return Task.CompletedTask;
|
||||
if (Queue is not null) return Queue.PlayRelease(ViewModel.Tracks, 0);
|
||||
|
||||
// Queue cascade absent (prerender or non-interactive): fall back to direct single-track play.
|
||||
return PlayerService is not null
|
||||
? PlayerService.SelectTrackStreaming(ViewModel.Tracks[0])
|
||||
: Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Row play: toggle if this track is already playing/paused, otherwise load the album at this
|
||||
// row's index so playback continues to the end from the chosen track.
|
||||
private async Task PlayTrack(TrackDto track, int index)
|
||||
{
|
||||
if (PlayerService is null) return;
|
||||
|
||||
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
|
||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Queue is not null)
|
||||
{
|
||||
await Queue.PlayRelease(ViewModel.Tracks, index);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Queue cascade absent: fall back to direct single-track play.
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Load + prerender-bridge logic for the Cut album-detail page (/cuts/{entryKey}). Mirrors
|
||||
/// <see cref="ReleaseDetailBase"/>'s discipline (id-addressed load in OnParametersSetAsync,
|
||||
/// PersistentComponentState bridge guarded on id) but carries the multi-track payload (release +
|
||||
/// ordered track list) the Cut page needs. Kept separate from the single-track base so neither
|
||||
/// grows a medium conditional — the two release shapes are genuinely different (one track vs many).
|
||||
/// </summary>
|
||||
public abstract class CutDetailBase : ComponentBase, IDisposable
|
||||
{
|
||||
private const string PersistKey = "cut-detail";
|
||||
|
||||
[Parameter] public string EntryKey { get; set; } = string.Empty;
|
||||
[Inject] public required CutDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
// The release EntryKey the ViewModel currently holds — tracks param-only navigations (e.g.
|
||||
// /cuts/{a} -> /cuts/{b}) which reuse this component instance and fire OnParametersSet without
|
||||
// re-running OnInitialized. Without it the page would keep the prior album's tracks.
|
||||
private string? _loadedKey;
|
||||
private bool _loaded;
|
||||
|
||||
protected override void OnInitialized()
|
||||
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (_loaded && _loadedKey == EntryKey) return;
|
||||
|
||||
// Capture the key synchronously before any await so a re-entrant call (rapid navigation or a
|
||||
// re-render that changes EntryKey while Load is in flight) sees the correct guard state.
|
||||
_loadedKey = EntryKey;
|
||||
_loaded = true;
|
||||
|
||||
// The bridged payload carries the release and its ordered tracks so the interactive pass
|
||||
// renders identically without a second round-trip. Guard on the key: a payload for a different
|
||||
// release must not seed this page (stale-bridge bleed across navigation).
|
||||
if (PersistentState.TryTakeFromJson<BridgedCut>(PersistKey, out var restored)
|
||||
&& restored?.Release is not null
|
||||
&& restored.Release.EntryKey == EntryKey)
|
||||
{
|
||||
ViewModel.Restore(restored.Release, restored.Tracks);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ViewModel.Load(EntryKey);
|
||||
}
|
||||
}
|
||||
|
||||
private Task Persist()
|
||||
{
|
||||
if (ViewModel.Release is not null)
|
||||
PersistentState.PersistAsJson(PersistKey, new BridgedCut(ViewModel.Release, ViewModel.Tracks));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
|
||||
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
|
||||
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
|
||||
}
|
||||
@@ -22,5 +22,5 @@ public partial class GenresView : ComponentBase
|
||||
}
|
||||
|
||||
private void OpenGenre(string genre)
|
||||
=> Navigation.NavigateTo($"/tracks?genre={Uri.EscapeDataString(genre)}");
|
||||
=> Navigation.NavigateTo($"/archive?genre={Uri.EscapeDataString(genre)}");
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
<p class="cta-sub">Immerse yourself. The current is always running.</p>
|
||||
</div>
|
||||
<div class="cta-actions">
|
||||
<a class="btn-white" href="/tracks">Explore the Archive</a>
|
||||
<a class="btn-white" href="/archive">Explore the Archive</a>
|
||||
@* TODO: route to /schedule when live-session schedule page exists *@
|
||||
<button class="btn-outline-white" type="button">View Live Schedule</button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace DeepDrftPublic.Client.Pages;
|
||||
/// Shared fetch + prerender-bridge logic for the medium browse pages (Sessions, Mixes). Subclasses
|
||||
/// supply only the <see cref="Medium"/> and <see cref="DetailRoute"/>; this base fetches the paged
|
||||
/// releases and bridges the prerendered result across the prerender -> WASM seam so the WASM pass
|
||||
/// does not re-fetch and replay the card animations (see the TracksView seam).
|
||||
/// does not re-fetch and replay the card animations.
|
||||
/// </summary>
|
||||
public abstract class MediumBrowseBase : ComponentBase, IDisposable
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/mixes/{Id:long}"
|
||||
@page "/mixes/{EntryKey}"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits ReleaseDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
|
||||
@@ -31,41 +32,104 @@ else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
else
|
||||
{
|
||||
var release = ViewModel.Release;
|
||||
var hasGenre = release.Genre is not null;
|
||||
var hasDate = release.ReleaseDate is not null;
|
||||
|
||||
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
|
||||
above it via the mix-detail-foreground stacking context. *@
|
||||
<MixWaveformVisualizer ReleaseId="@release.Id" />
|
||||
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
|
||||
playback only when the player is on this mix's track. *@
|
||||
<MixWaveformVisualizer ReleaseEntryKey="@release.EntryKey" TrackId="@ViewModel.Track?.Id" />
|
||||
|
||||
<div class="mix-detail-foreground">
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
|
||||
@* Mix keeps the scaffold solely for the Phase 10 top row (back link | controls | lava-lamp).
|
||||
Title/artist/genre/date/share/play all move into the overlaid hero, so the scaffold's
|
||||
default header and meta regions are suppressed (ShowHeader/ShowMeta=false) and the share
|
||||
row stays off (ShowShareRow=false). *@
|
||||
<ReleaseDetailScaffold Title="@release.Title"
|
||||
Artist="@release.Artist"
|
||||
Track="@ViewModel.Track"
|
||||
BackHref="/mixes"
|
||||
BackLabel="All mixes"
|
||||
ShowMeta="@(hasGenre || hasDate)">
|
||||
<MetaContent>
|
||||
@if (hasGenre)
|
||||
{
|
||||
<div>
|
||||
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Tertiary" Class="deepdrft-genre-chip">
|
||||
@release.Genre
|
||||
</MudChip>
|
||||
</div>
|
||||
}
|
||||
@if (hasDate)
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.overline">Released</MudText>
|
||||
<MudText Typo="Typo.body1">@release.ReleaseDate!.Value.ToString("MMMM yyyy")</MudText>
|
||||
</div>
|
||||
}
|
||||
</MetaContent>
|
||||
ShowHeader="false"
|
||||
ShowMeta="false"
|
||||
ShowShareRow="false">
|
||||
<TopContent>
|
||||
@* The eight-knob band lives in its own full-width area below the back/lamp top row.
|
||||
Phase 10 §4: the control is ALWAYS rendered; the lava-lamp toggle feeds its Visible
|
||||
parameter, and the control itself @if-gates the knobs while holding the container's
|
||||
reserved height — so content below never pops on toggle. The band mutates the shared
|
||||
MixVisualizerControlState; the backdrop bridge pushes the dials. A knob drag does not
|
||||
toggle it — the lamp's click does. *@
|
||||
<MixVisualizerControls Visible="@_controlsExpanded" />
|
||||
</TopContent>
|
||||
<TopRightAction>
|
||||
@* Lava-lamp button top-right, across from the back link. Toggles the knob band below the
|
||||
row. The icon swaps to its FILLED variant while the band is shown (§7f / Part B). *@
|
||||
<MudTooltip Text="Visualizer settings">
|
||||
<MudIconButton Icon="@(_controlsExpanded ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
|
||||
Size="Size.Large"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@ToggleSettings"
|
||||
aria-label="Visualizer settings"
|
||||
aria-expanded="@_controlsExpanded" />
|
||||
</MudTooltip>
|
||||
</TopRightAction>
|
||||
<Hero>
|
||||
@* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The
|
||||
cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
|
||||
to null). Share and play ride in as slots, matching Sessions. *@
|
||||
<ReleaseHeroOverlay Class="mix-hero"
|
||||
HeroImageKey="@release.ImagePath"
|
||||
PlaceholderIcon="@Icons.Material.Filled.Album"
|
||||
Title="@release.Title"
|
||||
Artist="@release.Artist"
|
||||
Genre="@release.Genre"
|
||||
ReleaseDate="@release.ReleaseDate">
|
||||
<ShareContent>
|
||||
@* Release-mode share: copies the canonical /mixes/{entryKey} URL, not a single track (§3b). *@
|
||||
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
|
||||
</ShareContent>
|
||||
<PlayContent>
|
||||
@if (ViewModel.Track is not null)
|
||||
{
|
||||
<PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
}
|
||||
</PlayContent>
|
||||
</ReleaseHeroOverlay>
|
||||
</Hero>
|
||||
</ReleaseDetailScaffold>
|
||||
</MudContainer>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
protected override string PersistKey => "mix-detail";
|
||||
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
// The hero now carries the play affordance (the scaffold's header is suppressed), so the
|
||||
// play-toggle is wired here directly — mirroring SessionDetail. Toggle if this track is already
|
||||
// active, otherwise start a fresh stream.
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
var track = ViewModel.Track;
|
||||
if (track is null || PlayerService is null) return;
|
||||
|
||||
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
|
||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
}
|
||||
}
|
||||
|
||||
// Lava-lamp knob-band visibility. Pure presentation over MixVisualizerControlState — gates whether
|
||||
// the seven-knob MixVisualizerControls is rendered into the TopContent band; toggling it touches no
|
||||
// control value or bridge push. The lava-lamp button's filled/outline glyph is driven off this flag.
|
||||
private bool _controlsExpanded;
|
||||
|
||||
private void ToggleSettings() => _controlsExpanded = !_controlsExpanded;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,15 @@
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Mix's per-page hero variance: a centered medium square, overriding ReleaseHeroOverlay's default
|
||||
wide (16/10) aspect. The cover art is square album art, and a smaller square frees the surrounding
|
||||
canvas for the lava-lamp visualizer. The mix-hero class lands on the overlay component's root
|
||||
.release-hero <div> (a child Razor component's native output), so ::deep is required to reach it. */
|
||||
::deep .release-hero.mix-hero {
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: 480px;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@@ -8,33 +8,57 @@ namespace DeepDrftPublic.Client.Pages;
|
||||
/// Shared load + prerender-bridge logic for the single-release detail pages (Session, Mix).
|
||||
/// Subclasses supply only their markup; this base loads the release through
|
||||
/// <see cref="ReleaseDetailViewModel"/> and bridges the prerendered release across the prerender ->
|
||||
/// WASM seam so the WASM pass does not re-fetch (see the TracksView seam). The playable track is
|
||||
/// WASM seam so the WASM pass does not re-fetch (see the MediumBrowseBase seam). The playable track is
|
||||
/// re-resolved on a restore miss only.
|
||||
/// </summary>
|
||||
public abstract class ReleaseDetailBase : ComponentBase, IDisposable
|
||||
{
|
||||
[Parameter] public long Id { get; set; }
|
||||
[Parameter] public string EntryKey { get; set; } = string.Empty;
|
||||
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
// The release EntryKey the ViewModel currently holds. Tracks param-only navigations (e.g.
|
||||
// /mixes/{a} -> /mixes/{b}) which reuse this component instance and fire OnParametersSet
|
||||
// without re-running OnInitialized — without this, the page would keep the prior
|
||||
// release's track and Play would stream the wrong audio.
|
||||
private string? _loadedKey;
|
||||
private bool _loaded;
|
||||
|
||||
// Distinct keys per medium so a Session restore never lands on a Mix page.
|
||||
protected abstract string PersistKey { get; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override void OnInitialized()
|
||||
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
// Re-run whenever the route key changes. Component instances are reused across
|
||||
// same-template navigations, so the load decision must live here, not in
|
||||
// OnInitialized (which fires once per instance).
|
||||
if (_loaded && _loadedKey == EntryKey) return;
|
||||
|
||||
// Capture the key synchronously before any await so that a re-entrant call
|
||||
// (rapid navigation or a re-render that changes EntryKey while Load is in flight)
|
||||
// sees the correct guard state. Without this, a second OnParametersSetAsync
|
||||
// for the same key would bypass the guard above and start a second Load,
|
||||
// causing two ViewModel.Load calls to race on the single scoped instance.
|
||||
_loadedKey = EntryKey;
|
||||
_loaded = true;
|
||||
|
||||
// The bridged payload carries both the release and its resolved track so the interactive
|
||||
// pass renders identically without a second round-trip.
|
||||
if (PersistentState.TryTakeFromJson<BridgedDetail>(PersistKey, out var restored) && restored?.Release is not null)
|
||||
// pass renders identically without a second round-trip. Guard on the key: a payload for a
|
||||
// different release must not seed this page (stale-bridge bleed across navigation).
|
||||
if (PersistentState.TryTakeFromJson<BridgedDetail>(PersistKey, out var restored)
|
||||
&& restored?.Release is not null
|
||||
&& restored.Release.EntryKey == EntryKey)
|
||||
{
|
||||
ViewModel.Restore(restored.Release, restored.Track);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ViewModel.Load(Id);
|
||||
await ViewModel.Load(EntryKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@page "/sessions/{Id:long}"
|
||||
@page "/sessions/{EntryKey}"
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits ReleaseDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
|
||||
@@ -37,50 +39,59 @@ else
|
||||
var heroKey = release.SessionMetadata?.HeroImageEntryKey;
|
||||
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
|
||||
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
|
||||
var hasGenre = release.Genre is not null;
|
||||
var hasDate = release.ReleaseDate is not null;
|
||||
|
||||
<ReleaseDetailScaffold Title="@release.Title"
|
||||
Artist="@release.Artist"
|
||||
Track="@ViewModel.Track"
|
||||
BackHref="/sessions"
|
||||
BackLabel="All sessions"
|
||||
ShowMeta="@(hasGenre || hasDate)">
|
||||
<Hero>
|
||||
<div class="session-detail-hero">
|
||||
@if (!string.IsNullOrEmpty(heroImage))
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="session-detail-page">
|
||||
|
||||
<MudLink Href="/sessions" Typo="Typo.body2" Class="deepdrft-track-detail-back">
|
||||
← All sessions
|
||||
</MudLink>
|
||||
|
||||
@* The overlay shows the cover thumbnail only when it differs from the resolved hero image —
|
||||
when there is no dedicated hero, heroImage already falls back to release.ImagePath, so the
|
||||
thumb would duplicate the background. That logic lives in ReleaseHeroOverlay. *@
|
||||
<ReleaseHeroOverlay HeroImageKey="@heroImage"
|
||||
CoverThumbKey="@release.ImagePath"
|
||||
PlaceholderIcon="@Icons.Material.Filled.Piano"
|
||||
Title="@release.Title"
|
||||
Artist="@release.Artist"
|
||||
Genre="@release.Genre"
|
||||
ReleaseDate="@release.ReleaseDate">
|
||||
<ShareContent>
|
||||
@* Release-mode share: copies the canonical /sessions/{entryKey} URL, not a single track (§3b). *@
|
||||
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
|
||||
</ShareContent>
|
||||
<PlayContent>
|
||||
@if (ViewModel.Track is not null)
|
||||
{
|
||||
<MudPaper Elevation="2" Class="session-detail-hero-img"
|
||||
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(heroImage)}');")" />
|
||||
<PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Piano" Color="Color.Primary" />
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
</Hero>
|
||||
<MetaContent>
|
||||
@if (hasGenre)
|
||||
{
|
||||
<div>
|
||||
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Tertiary" Class="deepdrft-genre-chip">
|
||||
@release.Genre
|
||||
</MudChip>
|
||||
</div>
|
||||
}
|
||||
@if (hasDate)
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.overline">Released</MudText>
|
||||
<MudText Typo="Typo.body1">@release.ReleaseDate!.Value.ToString("MMMM yyyy")</MudText>
|
||||
</div>
|
||||
}
|
||||
</MetaContent>
|
||||
</ReleaseDetailScaffold>
|
||||
</PlayContent>
|
||||
</ReleaseHeroOverlay>
|
||||
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@code {
|
||||
protected override string PersistKey => "session-detail";
|
||||
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
// Mirrors the play-toggle wiring the shared scaffold owns. Session detail composes the player
|
||||
// affordance directly (it diverges from ReleaseDetailScaffold for the overlay layout), so the
|
||||
// toggle logic lives here: toggle if this track is already active, otherwise start a fresh stream.
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
var track = ViewModel.Track;
|
||||
if (track is null || PlayerService is null) return;
|
||||
|
||||
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
|
||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
/* Hero-dominant: a wide 16:9 image rather than the square cover used on track detail. */
|
||||
.session-detail-hero {
|
||||
margin: 0 auto 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Session detail is hero-dominant: a large background image with the detail components overlaid
|
||||
directly on top (the overlay composition lives in ReleaseHeroOverlay). The page widens to the
|
||||
Large container (set in markup) rather than the shared 760px detail column. */
|
||||
|
||||
/* session-detail-hero-img rides on MudPaper (child Razor component); ::deep pierces its output. */
|
||||
::deep .session-detail-hero-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
::deep .session-detail-page {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
@page "/track/{EntryKey}"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<PageTitle>@(ViewModel.Track?.TrackName ?? "Track") - DeepDrft</PageTitle>
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-cover">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Width="100%" Height="320px" />
|
||||
</div>
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
|
||||
</div>
|
||||
<div class="deepdrft-track-detail-meta">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="30%" Height="24px" />
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="25%" Height="24px" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ViewModel.NotFound)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Track not found.</MudText>
|
||||
<MudText Typo="Typo.body2" Align="Align.Center" Color="Color.Secondary">
|
||||
This track may have been moved or removed.
|
||||
</MudText>
|
||||
<div class="d-flex justify-center mt-4">
|
||||
<MudButton Href="/tracks"
|
||||
Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack">
|
||||
All tracks
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ViewModel.Track is not null)
|
||||
{
|
||||
var track = ViewModel.Track;
|
||||
var release = track.Release;
|
||||
var hasMeta = release is not null
|
||||
&& (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null);
|
||||
|
||||
<ReleaseDetailScaffold Title="@track.TrackName"
|
||||
Artist="@release?.Artist"
|
||||
Track="@track"
|
||||
BackHref="/tracks"
|
||||
BackLabel="All tracks"
|
||||
ShowMeta="@hasMeta">
|
||||
<Hero>
|
||||
<div class="deepdrft-track-detail-cover">
|
||||
@if (!string.IsNullOrEmpty(release?.ImagePath))
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
|
||||
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
</Hero>
|
||||
<MetaContent>
|
||||
@if (hasMeta)
|
||||
{
|
||||
@if (release?.Title is not null)
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.overline">Album</MudText>
|
||||
<MudText Typo="Typo.body1">@release.Title</MudText>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (release?.Genre is not null)
|
||||
{
|
||||
<div>
|
||||
<MudChip T="string"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@release.Genre
|
||||
</MudChip>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (release?.ReleaseDate is not null)
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.overline">Released</MudText>
|
||||
<MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</MetaContent>
|
||||
</ReleaseDetailScaffold>
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
public partial class TrackDetail : ComponentBase, IDisposable
|
||||
{
|
||||
private const string PersistKey = "track-detail";
|
||||
|
||||
[Parameter] public required string EntryKey { get; set; }
|
||||
[Inject] public required TrackDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Carry the prerendered track across the prerender -> interactive (WASM) seam.
|
||||
// Without this, the WASM pass gets a fresh scoped ViewModel, re-renders the
|
||||
// skeleton, and re-fetches. Mirror the TracksView bridge: persist on the way
|
||||
// out of prerender, restore on the interactive pass, and only fetch on a miss.
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistTrack);
|
||||
|
||||
if (PersistentState.TryTakeFromJson<TrackDto>(PersistKey, out var restored) && restored is not null)
|
||||
{
|
||||
ViewModel.Track = restored;
|
||||
ViewModel.IsLoading = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
await ViewModel.Load(EntryKey);
|
||||
}
|
||||
}
|
||||
|
||||
private Task PersistTrack()
|
||||
{
|
||||
if (ViewModel.Track is not null)
|
||||
{
|
||||
PersistentState.PersistAsJson(PersistKey, ViewModel.Track);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@page "/tracks/{EntryKey}"
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inject IReleaseDataService ReleaseData
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@* Addressable deep-link fallback for a bare release EntryKey (Phase 11 §2, shape iii). Unlike the
|
||||
player bar / Archive / AlbumsView call sites, an external /tracks/{entryKey} link carries only the
|
||||
key, so this page fetches the release to discover its medium, then forwards through the same
|
||||
ReleaseRoutes resolver — one medium→route table, no second source. replace:true keeps the router
|
||||
out of history so Back skips this hop. Capture EntryKey before the await per the InteractiveAuto
|
||||
route-param convention. *@
|
||||
|
||||
@code {
|
||||
[Parameter] public string EntryKey { get; set; } = string.Empty;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var entryKey = EntryKey;
|
||||
var result = await ReleaseData.GetByEntryKey(entryKey);
|
||||
|
||||
var target = result is { Success: true, Value: { } release }
|
||||
? ReleaseRoutes.DetailHref(release)
|
||||
: "/cuts"; // Unknown key: fall back to the Cuts gallery rather than 404.
|
||||
|
||||
Navigation.NavigateTo(target, forceLoad: false, replace: true);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
@page "/tracks"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<PageTitle>DeepDrft Track Gallery</PageTitle>
|
||||
|
||||
<div>
|
||||
<div class="tracks-view-container">
|
||||
@* Search + filter affordances are interactive-only: the debounce timer and pill clear
|
||||
need WASM. During prerender/non-interactive they are hidden, matching the view-mode
|
||||
toggle's interactivity gate. *@
|
||||
@if (RendererInfo.IsInteractive)
|
||||
{
|
||||
<div class="tracks-search-row">
|
||||
<MudTextField T="string"
|
||||
Value="@ViewModel.SearchText"
|
||||
ValueChanged="@OnSearchInput"
|
||||
Immediate="true"
|
||||
DebounceInterval="400"
|
||||
Placeholder="Search tracks, artists, albums"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Clearable="true"
|
||||
Class="tracks-search-field"/>
|
||||
</div>
|
||||
|
||||
@if (ViewModel.FilterAlbum is not null || ViewModel.FilterGenre is not null)
|
||||
{
|
||||
<div class="tracks-filter-pills">
|
||||
<MudChip T="string"
|
||||
Color="Color.Tertiary"
|
||||
Variant="Variant.Filled"
|
||||
OnClose="@(_ => ClearFilter())">
|
||||
@(ViewModel.FilterAlbum is not null
|
||||
? $"Album: {ViewModel.FilterAlbum}"
|
||||
: $"Genre: {ViewModel.FilterGenre}")
|
||||
</MudChip>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (ViewModel.Page != null)
|
||||
{
|
||||
<div class="tracks-view-header">
|
||||
<MudToggleGroup T="GalleryViewMode" @bind-Value="_viewMode" Disabled="@(!RendererInfo.IsInteractive)" Class="tracks-view-toggle">
|
||||
<MudToggleItem Value="GalleryViewMode.Grid">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ViewModule"/>
|
||||
</MudToggleItem>
|
||||
<MudToggleItem Value="GalleryViewMode.List">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ViewList"/>
|
||||
</MudToggleItem>
|
||||
</MudToggleGroup>
|
||||
</div>
|
||||
<div class="tracks-content">
|
||||
<TracksGallery Tracks="@ViewModel.Page.Items"
|
||||
ViewMode="@_viewMode"
|
||||
ActiveTrack="@PlayerService.CurrentTrack"
|
||||
IsPlaying="@PlayerService.IsPlaying"
|
||||
IsPaused="@PlayerService.IsPaused"
|
||||
OnPlay="@PlayTrack"
|
||||
OnPause="@PauseTrack"/>
|
||||
</div>
|
||||
<div class="tracks-footer py-4">
|
||||
<MudPagination Count="@ViewModel.Page.TotalPages"
|
||||
Selected="@ViewModel.Page.Page"
|
||||
SelectedChanged="@SetPage"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
BoundaryCount="2"
|
||||
MiddleCount="3"/>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="tracks-content">
|
||||
<MudGrid Spacing="3">
|
||||
@foreach (var i in Enumerable.Range(0, 12))
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="deepdrft-track-gallery-item-center">
|
||||
<MudSkeleton Width="240px" Height="240px" SkeletonType="SkeletonType.Rectangle"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</div>
|
||||
<div class="tracks-footer">
|
||||
<MudSkeleton Height="60px" Width="240px" Class="justify-center"/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,163 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Controls;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
public partial class TracksView : ComponentBase, IDisposable
|
||||
{
|
||||
private const string PersistKey = "tracks-page";
|
||||
|
||||
[Inject] public required TracksViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
|
||||
|
||||
// Filter params arrive on the URL: /tracks?album=X, /tracks?genre=Y, /tracks?q=Z. Copied into
|
||||
// the ViewModel on init before the first fetch so the gallery renders filtered on direct nav.
|
||||
[SupplyParameterFromQuery(Name = "album")] public string? AlbumQuery { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "genre")] public string? GenreQuery { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "q")] public string? SearchQuery { get; set; }
|
||||
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
// Ephemeral view-mode selection — presentation-only, not persisted across navigation.
|
||||
private GalleryViewMode _viewMode = GalleryViewMode.Grid;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Seed filter state from the URL before any fetch or restore decision.
|
||||
ViewModel.FilterAlbum = string.IsNullOrWhiteSpace(AlbumQuery) ? null : AlbumQuery;
|
||||
ViewModel.FilterGenre = string.IsNullOrWhiteSpace(GenreQuery) ? null : GenreQuery;
|
||||
ViewModel.SearchText = string.IsNullOrWhiteSpace(SearchQuery) ? null : SearchQuery;
|
||||
|
||||
// Carry the prerendered page across the prerender -> interactive (WASM) seam.
|
||||
// Without this, the WASM pass gets a fresh scoped ViewModel (Page == null),
|
||||
// re-renders the skeleton, re-fetches, and replaces the gallery DOM a few
|
||||
// seconds in — replaying TrackCard entrance animations. Mirror the dark-mode
|
||||
// PersistentComponentState bridge: persist on the way out of prerender,
|
||||
// restore on the interactive pass, and only fetch on a miss.
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistTracks);
|
||||
|
||||
// The prerendered page is always unfiltered. When the URL carries filter params, that
|
||||
// restored page is wrong for this view — skip the restore and fetch with the filter.
|
||||
if (!ViewModel.HasActiveFilter
|
||||
&& PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored)
|
||||
&& restored is not null)
|
||||
{
|
||||
ViewModel.Page = restored;
|
||||
ViewModel.PageNumber = restored.Page;
|
||||
}
|
||||
else
|
||||
{
|
||||
await SetPage(ViewModel.PageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// The gallery's per-card icons read off the player's live state (CurrentTrack /
|
||||
// IsPlaying / IsPaused), which mutates outside this component's render path:
|
||||
// the player bar's play/pause/stop/close all change it directly. The cascade is
|
||||
// IsFixed, so the provider's re-render never reaches us — subscribe to the
|
||||
// multicast side-channel and re-render on every state change.
|
||||
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
|
||||
{
|
||||
if (_subscribedService != null)
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
|
||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||
_subscribedService = PlayerService;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private Task PersistTracks()
|
||||
{
|
||||
// Only persist the unfiltered page. A filtered page restored onto a later plain /tracks
|
||||
// visit would show the wrong results, so a filtered render leaves the cache untouched.
|
||||
if (ViewModel.Page is not null && !ViewModel.HasActiveFilter)
|
||||
{
|
||||
PersistentState.PersistAsJson(PersistKey, ViewModel.Page);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SetPage(int newPage)
|
||||
{
|
||||
var result = await ViewModel.TrackData.GetPage(
|
||||
newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending,
|
||||
ViewModel.SearchText, ViewModel.FilterAlbum, ViewModel.FilterGenre);
|
||||
|
||||
if (result is { Success: true, Value: PagedResult<TrackDto> pageResult })
|
||||
{
|
||||
ViewModel.Page = pageResult;
|
||||
ViewModel.PageSize = pageResult.PageSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
|
||||
// burst reaches here. Resets to page 1 since the result set changes, then re-fetches with the
|
||||
// active filter (search + any album/genre pill compose).
|
||||
private async Task OnSearchInput(string? value)
|
||||
{
|
||||
ViewModel.SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
ViewModel.PageNumber = 1;
|
||||
await SetPage(1);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Clears the album/genre pill and returns to the unfiltered gallery. Updates the URL (drops the
|
||||
// query param) and re-fetches in place. SearchText is intentionally left intact — the pill only
|
||||
// represents FilterAlbum/FilterGenre, not free-text search, so clearing it must not discard an
|
||||
// active search term. Blazor reuses the component on a same-route query change and does not
|
||||
// re-run OnInitializedAsync, so the state reset + refetch happen here explicitly rather than
|
||||
// relying on re-init.
|
||||
private async Task ClearFilter()
|
||||
{
|
||||
ViewModel.FilterAlbum = null;
|
||||
ViewModel.FilterGenre = null;
|
||||
ViewModel.PageNumber = 1;
|
||||
|
||||
Navigation.NavigateTo("/tracks");
|
||||
await SetPage(1);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task PlayTrack(TrackDto track)
|
||||
{
|
||||
// Resume the current track if it's merely paused; otherwise stream the new selection.
|
||||
if (PlayerService.CurrentTrack?.Id == track.Id && PlayerService.IsPaused)
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PauseTrack(TrackDto track)
|
||||
{
|
||||
if (PlayerService.CurrentTrack?.Id == track.Id && PlayerService.IsPlaying)
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_persistingSubscription.Dispose();
|
||||
|
||||
if (_subscribedService != null)
|
||||
{
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
_subscribedService = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/* Layout for the tracks page.
|
||||
Dead flex/height rules removed — the sticky-footer intent they encoded required
|
||||
a height target that was never set; normal block flow is sufficient for a
|
||||
paginated gallery. Horizontal inset is owned by MudContainer in TracksGallery. */
|
||||
|
||||
.tracks-view-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tracks-content {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.tracks-footer {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tracks-view-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.tracks-search-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
|
||||
/* tracks-search-field rides on MudTextField, whose root is a child Razor component element.
|
||||
Blazor isolation does not stamp the scope attribute there, so ::deep is required. */
|
||||
::deep .tracks-search-field {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tracks-filter-pills {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
@@ -43,6 +43,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
/// <inheritdoc />
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Action? TrackEnded;
|
||||
|
||||
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
|
||||
{
|
||||
_audioInterop = audioInterop;
|
||||
@@ -268,6 +271,12 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
CurrentTime = 0;
|
||||
Duration = null;
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Fire AFTER the state notification so any queue orchestrator that advances on this
|
||||
// signal selects the next track against a fully-settled idle state. Raised only on
|
||||
// organic end-of-stream — stop/unload/track-switch go through ResetToIdle, which does
|
||||
// not raise this — so a subscriber can treat it unambiguously as "advance the queue."
|
||||
TrackEnded?.Invoke();
|
||||
}
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user