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.
This commit is contained in:
@@ -13,20 +13,30 @@
|
|||||||
|
|
||||||
@TopContent
|
@TopContent
|
||||||
|
|
||||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
@* The header region. A composer that wants the default masthead+play row supplies nothing; one
|
||||||
<div class="deepdrft-track-detail-masthead">
|
that needs a different arrangement (e.g. the Cut album's left-meta / right-cover split) supplies
|
||||||
<MudText Typo="Typo.h3">@Title</MudText>
|
its own Header fragment. Layout variance rides this slot, never a boolean flag (Phase 9 §5.3). *@
|
||||||
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
|
@if (Header is not null)
|
||||||
</div>
|
{
|
||||||
|
@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. *@
|
@* Play only makes sense once a playable track is resolved. *@
|
||||||
@if (Track is not null)
|
@if (Track is not null)
|
||||||
{
|
{
|
||||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||||
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||||
</MudStack>
|
</MudStack>
|
||||||
}
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
}
|
||||||
|
|
||||||
@Hero
|
@Hero
|
||||||
|
|
||||||
@@ -38,7 +48,12 @@
|
|||||||
</div>
|
</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">
|
<div class="deepdrft-share-row">
|
||||||
<SharePopover EntryKey="@Track.EntryKey" />
|
<SharePopover EntryKey="@Track.EntryKey" />
|
||||||
|
|||||||
@@ -31,9 +31,24 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public RenderFragment? TopContent { get; set; }
|
[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>
|
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
|
||||||
[Parameter] public RenderFragment? Hero { get; set; }
|
[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>
|
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
|
||||||
[Parameter] public RenderFragment? MetaContent { get; set; }
|
[Parameter] public RenderFragment? MetaContent { get; set; }
|
||||||
|
|
||||||
@@ -44,6 +59,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public bool ShowMeta { get; set; } = true;
|
[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()
|
private async Task PlayTrack()
|
||||||
{
|
{
|
||||||
if (Track is null || PlayerService is null) return;
|
if (Track is null || PlayerService is null) return;
|
||||||
|
|||||||
@@ -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">·</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);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ public static class Startup
|
|||||||
services.AddScoped<ReleaseClient>();
|
services.AddScoped<ReleaseClient>();
|
||||||
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
|
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
|
||||||
services.AddScoped<ReleaseDetailViewModel>();
|
services.AddScoped<ReleaseDetailViewModel>();
|
||||||
|
services.AddScoped<CutDetailViewModel>();
|
||||||
|
|
||||||
// Mix visualizer controls — scoped so the four slider positions persist across navigation
|
// Mix visualizer controls — scoped so the four slider positions persist across navigation
|
||||||
// within a session and reset on a fresh page load (see MixVisualizerControlState).
|
// 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;
|
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 => 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user