From 07ddc69cee24b29b8fcd395dae18008d5e04f93b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 15 Jun 2026 23:59:19 -0400 Subject: [PATCH] feat(public): add /cuts/{id} album-detail page Compose ReleaseDetailScaffold via Header + BodyContent slots for the Cut album view: left meta + Play/Share, right theme-bordered cover, TrackNumber- ordered track list with per-row play. CutDetailBase carries the multi-track prerender bridge. --- .../Controls/ReleaseDetailScaffold.razor | 43 +++-- .../Controls/ReleaseDetailScaffold.razor.cs | 22 +++ DeepDrftPublic.Client/Pages/CutDetail.razor | 163 ++++++++++++++++++ DeepDrftPublic.Client/Pages/CutDetailBase.cs | 68 ++++++++ DeepDrftPublic.Client/Startup.cs | 1 + .../ViewModels/CutDetailViewModel.cs | 78 +++++++++ .../wwwroot/styles/deepdrft-styles.css | 107 ++++++++++++ DeepDrftTests/CutDetailTrackOrderingTests.cs | 152 ++++++++++++++++ 8 files changed, 620 insertions(+), 14 deletions(-) create mode 100644 DeepDrftPublic.Client/Pages/CutDetail.razor create mode 100644 DeepDrftPublic.Client/Pages/CutDetailBase.cs create mode 100644 DeepDrftPublic.Client/ViewModels/CutDetailViewModel.cs create mode 100644 DeepDrftTests/CutDetailTrackOrderingTests.cs diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor index 947945d..2be722f 100644 --- a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor +++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor @@ -13,20 +13,30 @@ @TopContent - -
- @Title - @Artist -
+ @* 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). *@ + @if (Header is not null) + { + @Header + } + else + { + +
+ @Title + @Artist +
- @* Play only makes sense once a playable track is resolved. *@ - @if (Track is not null) - { - - - - } -
+ @* Play only makes sense once a playable track is resolved. *@ + @if (Track is not null) + { + + + + } +
+ } @Hero @@ -38,7 +48,12 @@ } - @if (Track is not null) + @* 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) {
diff --git a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs index 9f90815..da5f1f4 100644 --- a/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs +++ b/DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs @@ -31,9 +31,24 @@ public partial class ReleaseDetailScaffold : ComponentBase /// [Parameter] public RenderFragment? TopContent { get; set; } + /// + /// Optional replacement for the header region (masthead + play affordance). When null, the + /// scaffold renders its default masthead+play row wired to . 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). + /// + [Parameter] public RenderFragment? Header { get; set; } + /// Medium-specific hero visual (cover art, hero image, or waveform background). [Parameter] public RenderFragment? Hero { get; set; } + /// + /// Optional body region rendered below the meta block — the Cut album's multi-track listing. + /// Single-track media leave it null. + /// + [Parameter] public RenderFragment? BodyContent { get; set; } + /// Optional medium-specific metadata block, rendered under a divider when present. [Parameter] public RenderFragment? MetaContent { get; set; } @@ -44,6 +59,13 @@ public partial class ReleaseDetailScaffold : ComponentBase /// [Parameter] public bool ShowMeta { get; set; } = true; + /// + /// 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. + /// + [Parameter] public bool ShowShareRow { get; set; } = true; + private async Task PlayTrack() { if (Track is null || PlayerService is null) return; diff --git a/DeepDrftPublic.Client/Pages/CutDetail.razor b/DeepDrftPublic.Client/Pages/CutDetail.razor new file mode 100644 index 0000000..c810752 --- /dev/null +++ b/DeepDrftPublic.Client/Pages/CutDetail.razor @@ -0,0 +1,163 @@ +@page "/cuts/{Id:long}" +@using DeepDrftModels.DTOs +@using DeepDrftPublic.Client.Controls +@using DeepDrftPublic.Client.Services +@inherits CutDetailBase + +@(ViewModel.Release?.Title ?? "Cut") - DeepDrft + +@if (ViewModel.IsLoading) +{ +
+
+ + +
+
+} +else if (ViewModel.NotFound || ViewModel.Release is null) +{ +
+
+ Cut not found. +
+ + All cuts + +
+
+
+} +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; + + +
+ @* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@ +
+
+ @release.Title + @release.Artist + + @if (hasGenre || hasYear) + { +
+ @if (hasGenre) + { + @release.Genre + } + @if (hasGenre && hasYear) + { + · + } + @if (hasYear) + { + @release.ReleaseDate!.Value.Year + } +
+ } + +
+ @* Header Play starts the album's first track. Wired to the single-slot player + today; the §3.4 queue seam means a future swap to QueueService.PlayRelease + is a one-line change inside PlayAlbum, not a markup edit. Disabled until a + streamable track is resolved. *@ + + Play + + + @if (firstTrack is not null) + { + + } +
+
+ +
+ @if (!string.IsNullOrEmpty(release.ImagePath)) + { + + } + else + { + + + + } +
+
+
+ + + @if (ViewModel.Tracks.Count == 0) + { + No tracks in this cut yet. + } + else + { +
+ @foreach (var track in ViewModel.Tracks) + { +
+ @track.TrackNumber +
+ +
+ @track.TrackName +
+ } +
+ } +
+
+} + +@code { + [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } + + // Header Play: start the album's first track. The §3.4 queue seam lives here — swapping this body + // to `Queue.PlayRelease(ViewModel.Tracks)` once IQueueService (track 11.F) lands is a one-line + // change with no other edit to this page. The queue type is not referenced here because it does + // not exist in this worktree. + private Task PlayAlbum() + { + var first = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null; + return first is null ? Task.CompletedTask : PlayTrack(first); + } + + // Row play: toggle if this track is already active, otherwise start a fresh stream. Mirrors the + // scaffold's own PlayTrack wiring (SessionDetail uses the same idiom for its diverged layout). + private async Task PlayTrack(TrackDto track) + { + if (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); + } + } +} diff --git a/DeepDrftPublic.Client/Pages/CutDetailBase.cs b/DeepDrftPublic.Client/Pages/CutDetailBase.cs new file mode 100644 index 0000000..9979557 --- /dev/null +++ b/DeepDrftPublic.Client/Pages/CutDetailBase.cs @@ -0,0 +1,68 @@ +using DeepDrftModels.DTOs; +using DeepDrftPublic.Client.ViewModels; +using Microsoft.AspNetCore.Components; + +namespace DeepDrftPublic.Client.Pages; + +/// +/// Load + prerender-bridge logic for the Cut album-detail page (/cuts/{id}). Mirrors +/// '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). +/// +public abstract class CutDetailBase : ComponentBase, IDisposable +{ + private const string PersistKey = "cut-detail"; + + [Parameter] public long Id { get; set; } + [Inject] public required CutDetailViewModel ViewModel { get; set; } + [Inject] public required PersistentComponentState PersistentState { get; set; } + + private PersistingComponentStateSubscription _persistingSubscription; + + // The release id the ViewModel currently holds — tracks param-only navigations (e.g. + // /cuts/5 -> /cuts/8) which reuse this component instance and fire OnParametersSet without + // re-running OnInitialized. Without it the page would keep the prior album's tracks. + private long _loadedId; + private bool _loaded; + + protected override void OnInitialized() + => _persistingSubscription = PersistentState.RegisterOnPersisting(Persist); + + protected override async Task OnParametersSetAsync() + { + if (_loaded && _loadedId == Id) return; + + // Capture the id synchronously before any await so a re-entrant call (rapid navigation or a + // re-render that changes Id while Load is in flight) sees the correct guard state. + _loadedId = Id; + _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 id: a payload for a different + // release must not seed this page (stale-bridge bleed across navigation). + if (PersistentState.TryTakeFromJson(PersistKey, out var restored) + && restored?.Release is not null + && restored.Release.Id == Id) + { + ViewModel.Restore(restored.Release, restored.Tracks); + } + else + { + await ViewModel.Load(Id); + } + } + + 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 Tracks); +} diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs index 39ca11b..18ad2f9 100644 --- a/DeepDrftPublic.Client/Startup.cs +++ b/DeepDrftPublic.Client/Startup.cs @@ -26,6 +26,7 @@ public static class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Mix visualizer controls — scoped so the four slider positions persist across navigation // within a session and reset on a fresh page load (see MixVisualizerControlState). diff --git a/DeepDrftPublic.Client/ViewModels/CutDetailViewModel.cs b/DeepDrftPublic.Client/ViewModels/CutDetailViewModel.cs new file mode 100644 index 0000000..3fd1342 --- /dev/null +++ b/DeepDrftPublic.Client/ViewModels/CutDetailViewModel.cs @@ -0,0 +1,78 @@ +using DeepDrftModels.DTOs; +using DeepDrftPublic.Client.Services; + +namespace DeepDrftPublic.Client.ViewModels; + +/// +/// State for the Cut album-detail page (/cuts/{id}). Unlike +/// (which resolves a single playable track for Session/Mix), a Cut is multi-track: it loads the +/// release and the full ordered track list for that release. The list is fetched through the +/// existing releaseId-filtered track page sorted by TrackNumber — the explicit 1-based ordinal +/// (Phase 8) that the public read both sorts on and projects onto TrackDto. Scoped; every flag is +/// reset per so a reused instance never bleeds across navigations. +/// +public class CutDetailViewModel +{ + private readonly IReleaseDataService _releaseData; + private readonly ITrackDataService _trackData; + + // A Cut covers the whole album in one page. Matches the gallery's page-size convention; a single + // album never approaches this ceiling (the API caps PageSize at 100 regardless). + private const int AlbumPageSize = 100; + + public ReleaseDto? Release { get; private set; } + public IReadOnlyList Tracks { get; private set; } = []; + public bool IsLoading { get; private set; } = true; + public bool NotFound { get; private set; } + + public CutDetailViewModel(IReleaseDataService releaseData, ITrackDataService trackData) + { + _releaseData = releaseData; + _trackData = trackData; + } + + /// Seed state directly from a bridged prerender payload — no fetch. + public void Restore(ReleaseDto release, IReadOnlyList tracks) + { + Release = release; + Tracks = tracks; + NotFound = false; + IsLoading = false; + } + + public async Task Load(long releaseId) + { + IsLoading = true; + NotFound = false; + Release = null; + Tracks = []; + + try + { + var releaseResult = await _releaseData.GetById(releaseId); + if (releaseResult is not { Success: true, Value: { } release }) + { + NotFound = true; + return; + } + + Release = release; + + // The album's tracks via the releaseId-filtered page — an exact join, not a title string + // (which collides across same-titled releases and breaks on rename). Sorted by TrackNumber + // so rows render in saved order. A Cut with no streamable tracks simply leaves the list + // empty (the page renders the header with no rows). + var trackResult = await _trackData.GetPage( + pageNumber: 1, + pageSize: AlbumPageSize, + sortColumn: "TrackNumber", + releaseId: release.Id); + if (trackResult is { Success: true, Value: { Items: { } items } }) + Tracks = items.ToList(); + } + finally + { + IsLoading = false; + } + } +} diff --git a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css index 1032225..43ab5e3 100644 --- a/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css +++ b/DeepDrftPublic/wwwroot/styles/deepdrft-styles.css @@ -426,3 +426,110 @@ h2, h3, h4, h5, h6, text-align: center; } } + +/* ============================================================================= + CUT ALBUM DETAIL (/cuts/{id}) + Header splits left-meta / right-cover; the cover carries an explicit theme + border (the new visual element vs. the borderless Session/Mix covers). + ============================================================================= */ + +.cut-detail-header { + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + gap: 2rem; + margin: 2rem 0 1.5rem; +} + +.cut-detail-meta { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 0; + flex: 1 1 auto; +} + +.cut-detail-subline { + display: flex; + align-items: center; + gap: 0.5rem; + opacity: 0.75; + font-family: var(--deepdrft-font-mono); + font-size: 0.85rem; + margin-top: 0.25rem; +} + +.cut-detail-sep { opacity: 0.5; } + +.cut-detail-actions { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + margin-top: 1rem; +} + +/* Square cover with a framed theme border — the new visual element this page introduces. */ +.cut-detail-cover { + aspect-ratio: 1 / 1; + width: 260px; + flex: 0 0 auto; + overflow: hidden; + border: 3px solid var(--mud-palette-secondary); + box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent); +} + +.cut-detail-divider { margin: 1.5rem 0 0.5rem; } + +.cut-detail-empty { + opacity: 0.7; + padding: 1rem 0; +} + +.cut-detail-tracklist { + display: flex; + flex-direction: column; +} + +.cut-detail-track-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + padding: 0.25rem 0; + border-bottom: 1px solid color-mix(in srgb, var(--mud-palette-text-secondary) 12%, transparent); +} + +.cut-detail-track-row:last-child { border-bottom: none; } + +.cut-detail-track-number { + width: 1.75rem; + text-align: right; + flex: 0 0 auto; + opacity: 0.55; + font-family: var(--deepdrft-font-mono); + font-size: 0.9rem; +} + +.cut-detail-track-play { flex: 0 0 auto; } + +.cut-detail-track-name { + flex: 1 1 auto; + min-width: 0; +} + +/* Stack the header on narrow screens: cover above the meta column. */ +@media (max-width: 599px) { + .cut-detail-header { + flex-direction: column-reverse; + align-items: stretch; + gap: 1.25rem; + } + + .cut-detail-cover { + width: 100%; + max-width: 320px; + margin: 0 auto; + } +} diff --git a/DeepDrftTests/CutDetailTrackOrderingTests.cs b/DeepDrftTests/CutDetailTrackOrderingTests.cs new file mode 100644 index 0000000..0e8e966 --- /dev/null +++ b/DeepDrftTests/CutDetailTrackOrderingTests.cs @@ -0,0 +1,152 @@ +using Data.Data.Repositories; +using DeepDrftData; +using DeepDrftData.Data; +using DeepDrftData.Repositories; +using DeepDrftModels.DTOs; +using DeepDrftModels.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Models.Common; + +namespace DeepDrftTests; + +/// +/// Backs the public read path that the /cuts/{id} album page consumes (Phase 11 §3a, §3.3). +/// CutDetailViewModel.Load fetches an album's tracks through the releaseId-filtered track page +/// sorted by "TrackNumber"; that maps to with a +/// predicate and an OrderBy(t => t.TrackNumber) +/// expression. These tests exercise that exact query — the join narrowing, the explicit-ordinal +/// ordering (not insertion order), and the projection of TrackNumber onto the DTO the page renders. +/// +/// Provider note: runs on the EF in-memory provider, which executes the ReleaseId equality, the +/// ordinal sort, and the count in process — every predicate this path uses (no ILike branch here). +/// Mirrors . +/// +[TestFixture] +public class CutDetailTrackOrderingTests +{ + private DeepDrftContext _context = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new DeepDrftContext(options); + } + + [TearDown] + public void TearDown() => _context.Dispose(); + + private TrackRepository CreateRepository() + => new(_context, NullLogger>.Instance); + + private static ReleaseEntity Release(string title, string artist) + => new() { Title = title, Artist = artist }; + + // A track linked to the given release with an explicit ordinal. + private static TrackEntity Track(string name, int trackNumber, ReleaseEntity? release = null) + => new() + { + EntryKey = Guid.NewGuid().ToString("N"), + TrackName = name, + TrackNumber = trackNumber, + Release = release, + }; + + private async Task SeedAsync(params TrackEntity[] tracks) + { + _context.Tracks.AddRange(tracks); + await _context.SaveChangesAsync(); + } + + // The album page's query: filter to one release, order by the explicit ordinal. + private static PagingParameters OrderedByTrackNumber() + => new() { Page = 1, PageSize = 100, OrderBy = t => t.TrackNumber, IsDescending = false }; + + // The release-id filter narrows to that album only — a sibling release's tracks never leak in. + [Test] + public async Task ReleaseIdFilter_ReturnsOnlyThatReleasesTracks() + { + var albumA = Release("Album A", "Artist"); + var albumB = Release("Album B", "Artist"); + await SeedAsync( + Track("A-one", 1, albumA), + Track("A-two", 2, albumA), + Track("B-one", 1, albumB), + Track("Loose", 1)); + + var repo = CreateRepository(); + var result = await repo.GetPagedFilteredAsync( + OrderedByTrackNumber(), new TrackFilter { ReleaseId = albumA.Id }); + + Assert.That(result.TotalCount, Is.EqualTo(2)); + Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "A-one", "A-two" })); + } + + // The ordering is by the explicit ordinal, not insertion order: tracks seeded out of order + // come back ascending by TrackNumber. This is the guarantee /cuts/{id} relies on for its rows. + [Test] + public async Task OrderByTrackNumber_SortsByExplicitOrdinalNotInsertionOrder() + { + var album = Release("Album", "Artist"); + // Insert deliberately scrambled relative to the intended track order. + await SeedAsync( + Track("Third", 3, album), + Track("First", 1, album), + Track("Second", 2, album)); + + var repo = CreateRepository(); + var result = await repo.GetPagedFilteredAsync( + OrderedByTrackNumber(), new TrackFilter { ReleaseId = album.Id }); + + Assert.That( + result.Items.Select(t => t.TrackName).ToList(), + Is.EqualTo(new[] { "First", "Second", "Third" }), + "rows must order by the explicit TrackNumber ordinal, not the order they were inserted"); + Assert.That( + result.Items.Select(t => t.TrackNumber).ToList(), + Is.EqualTo(new[] { 1, 2, 3 })); + } + + // The DTO the page renders carries the ordinal — TrackConverter projects TrackNumber onto + // TrackDto, so the row's number label and the saved order survive the entity -> DTO mapping. + [Test] + public async Task TrackConverter_ProjectsTrackNumberOntoDto() + { + var album = Release("Album", "Artist"); + await SeedAsync( + Track("First", 1, album), + Track("Second", 2, album)); + + var repo = CreateRepository(); + var result = await repo.GetPagedFilteredAsync( + OrderedByTrackNumber(), new TrackFilter { ReleaseId = album.Id }); + + var dtos = result.Items.Select(TrackConverter.Convert).ToList(); + + Assert.That(dtos.Select(d => d.TrackNumber).ToList(), Is.EqualTo(new[] { 1, 2 })); + Assert.That(dtos.Select(d => d.TrackName).ToList(), Is.EqualTo(new[] { "First", "Second" })); + } + + // An album with no streamable tracks yields an empty page (no rows, no error) — the page header + // still renders; the track list is simply empty. + [Test] + public async Task ReleaseIdFilter_WithNoTracks_ReturnsEmptyPage() + { + var empty = Release("Empty Album", "Artist"); + var other = Release("Other", "Artist"); + await SeedAsync(Track("Other-one", 1, other)); + // Persist the empty release with no tracks linked to it. + _context.Releases.Add(empty); + await _context.SaveChangesAsync(); + + var repo = CreateRepository(); + var result = await repo.GetPagedFilteredAsync( + OrderedByTrackNumber(), new TrackFilter { ReleaseId = empty.Id }); + + Assert.That(result.TotalCount, Is.EqualTo(0)); + Assert.That(result.Items, Is.Empty); + } +}