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)
+{
+
+}
+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). *@
+
+
+
+
+ @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);
+ }
+}