Merge p11-w1-cuts-detail into dev (P11 11.A: /cuts/{id} album-detail page)

This commit is contained in:
daniel-c-harvey
2026-06-16 00:37:01 -04:00
8 changed files with 620 additions and 14 deletions
@@ -13,20 +13,30 @@
@TopContent
<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>
@* 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
{
<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>
@* 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>
}
@Hero
@@ -38,7 +48,12 @@
</div>
}
@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)
{
<div class="deepdrft-share-row">
<SharePopover EntryKey="@Track.EntryKey" />
@@ -31,9 +31,24 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// </summary>
[Parameter] public RenderFragment? TopContent { 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; }
@@ -44,6 +59,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;
+163
View File
@@ -0,0 +1,163 @@
@page "/cuts/{Id:long}"
@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">&middot;</span>
}
@if (hasYear)
{
<span class="cut-detail-year">@release.ReleaseDate!.Value.Year</span>
}
</div>
}
<div class="cut-detail-actions">
@* 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. *@
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.PlayArrow"
Disabled="@(firstTrack is null || !RendererInfo.IsInteractive)"
OnClick="@PlayAlbum">
Play
</MudButton>
@if (firstTrack is not null)
{
<SharePopover EntryKey="@firstTrack.EntryKey" />
}
</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">
@foreach (var track in ViewModel.Tracks)
{
<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))" />
</div>
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
</div>
}
</div>
}
</BodyContent>
</ReleaseDetailScaffold>
}
@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);
}
}
}
@@ -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/{id}). 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 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<BridgedCut>(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<TrackDto> Tracks);
}
+1
View File
@@ -26,6 +26,7 @@ public static class Startup
services.AddScoped<ReleaseClient>();
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
services.AddScoped<ReleaseDetailViewModel>();
services.AddScoped<CutDetailViewModel>();
// Mix visualizer controls — scoped so the four slider positions persist across navigation
// within a session and reset on a fresh page load (see MixVisualizerControlState).
@@ -0,0 +1,78 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
namespace DeepDrftPublic.Client.ViewModels;
/// <summary>
/// State for the Cut album-detail page (/cuts/{id}). Unlike <see cref="ReleaseDetailViewModel"/>
/// (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 <see cref="Load"/> so a reused instance never bleeds across navigations.
/// </summary>
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<TrackDto> 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;
}
/// <summary>Seed state directly from a bridged prerender payload — no fetch.</summary>
public void Restore(ReleaseDto release, IReadOnlyList<TrackDto> 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;
}
}
}
@@ -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;
}
}
@@ -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;
/// <summary>
/// Backs the public read path that the /cuts/{id} album page consumes (Phase 11 §3a, §3.3).
/// <c>CutDetailViewModel.Load</c> fetches an album's tracks through the releaseId-filtered track page
/// sorted by "TrackNumber"; that maps to <see cref="TrackRepository.GetPagedFilteredAsync"/> with a
/// <see cref="TrackFilter.ReleaseId"/> predicate and an <c>OrderBy(t =&gt; t.TrackNumber)</c>
/// 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 <see cref="TrackFilterQueryTests"/>.
/// </summary>
[TestFixture]
public class CutDetailTrackOrderingTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown() => _context.Dispose();
private TrackRepository CreateRepository()
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.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<TrackEntity> 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);
}
}