From 07ddc69cee24b29b8fcd395dae18008d5e04f93b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 15 Jun 2026 23:59:19 -0400 Subject: [PATCH 1/6] 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); + } +} From cfacc9f79a80bc41526980b7de338174e915a621 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 16 Jun 2026 00:00:06 -0400 Subject: [PATCH 2/6] feat(release): add plain-text Description field plumbed CMS->DTO->release (11.G) New nullable Description column (max 4000) on ReleaseEntity, rides the Genre write channel through upload + edit; multiline CMS input. Migration authored, not applied. --- DeepDrftAPI/Controllers/TrackController.cs | 3 + .../Models/UpdateTrackMetadataRequest.cs | 1 + DeepDrftAPI/Services/UnifiedTrackService.cs | 6 +- .../Configurations/ReleaseConfiguration.cs | 5 + ...16035252_AddReleaseDescription.Designer.cs | 308 ++++++++++++++++++ .../20260616035252_AddReleaseDescription.cs | 29 ++ .../DeepDrftContextModelSnapshot.cs | 5 + DeepDrftData/TrackConverter.cs | 2 + DeepDrftData/TrackManager.cs | 1 + .../Pages/Tracks/AlbumHeaderFields.razor | 7 + .../Components/Pages/Tracks/BatchEdit.razor | 7 + .../Components/Pages/Tracks/BatchUpload.razor | 4 + DeepDrftManager/Services/CmsTrackService.cs | 5 +- DeepDrftManager/Services/ICmsTrackService.cs | 3 +- DeepDrftModels/DTOs/ReleaseDto.cs | 1 + DeepDrftModels/Entities/ReleaseEntity.cs | 4 + DeepDrftTests/MediumWritePathTests.cs | 76 +++++ 17 files changed, 463 insertions(+), 4 deletions(-) create mode 100644 DeepDrftData/Migrations/20260616035252_AddReleaseDescription.Designer.cs create mode 100644 DeepDrftData/Migrations/20260616035252_AddReleaseDescription.cs diff --git a/DeepDrftAPI/Controllers/TrackController.cs b/DeepDrftAPI/Controllers/TrackController.cs index fb0fde9..d46eb66 100644 --- a/DeepDrftAPI/Controllers/TrackController.cs +++ b/DeepDrftAPI/Controllers/TrackController.cs @@ -189,6 +189,7 @@ public class TrackController : ControllerBase [FromForm] string? artist, [FromForm] string? album, [FromForm] string? genre, + [FromForm] string? description, [FromForm] string? releaseDate, [FromForm] string? originalFileName, [FromForm] long createdByUserId, @@ -283,6 +284,7 @@ public class TrackController : ControllerBase artist, string.IsNullOrWhiteSpace(album) ? null : album, string.IsNullOrWhiteSpace(genre) ? null : genre, + string.IsNullOrWhiteSpace(description) ? null : description, parsedReleaseDate, createdByUserId, string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName, @@ -412,6 +414,7 @@ public class TrackController : ControllerBase release.Artist = request.Artist; release.Title = request.Album ?? string.Empty; release.Genre = request.Genre; + release.Description = request.Description; release.ReleaseDate = request.ReleaseDate; // ImagePath is tri-state: null = no change, "" = clear, value = set. diff --git a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs index 6d97717..d443028 100644 --- a/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs +++ b/DeepDrftAPI/Models/UpdateTrackMetadataRequest.cs @@ -16,6 +16,7 @@ public record UpdateTrackMetadataRequest( string Artist, string? Album, string? Genre, + string? Description, DateOnly? ReleaseDate, string? ImagePath = null, ReleaseType? ReleaseType = null, diff --git a/DeepDrftAPI/Services/UnifiedTrackService.cs b/DeepDrftAPI/Services/UnifiedTrackService.cs index d0718ef..0327229 100644 --- a/DeepDrftAPI/Services/UnifiedTrackService.cs +++ b/DeepDrftAPI/Services/UnifiedTrackService.cs @@ -57,6 +57,7 @@ public class UnifiedTrackService string artist, string? album, string? genre, + string? description, DateOnly? releaseDate, long createdByUserId, string? originalFileName, @@ -106,8 +107,8 @@ public class UnifiedTrackService // Resolve the release FK before persisting the track. An upload with an album lands on the // shared release (created on first sighting); an upload without one stays a loose track with - // a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides - // on the release, not the track. + // a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader) + // rides on the release, not the track. long? releaseId = null; if (!string.IsNullOrWhiteSpace(album)) { @@ -116,6 +117,7 @@ public class UnifiedTrackService Title = album, Artist = artist, Genre = genre, + Description = description, ReleaseDate = releaseDate, ReleaseType = releaseType, Medium = medium, diff --git a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs index a9eb1c0..1e9ab4e 100644 --- a/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs +++ b/DeepDrftData/Data/Configurations/ReleaseConfiguration.cs @@ -35,6 +35,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration .HasMaxLength(100) .HasColumnName("genre"); + // Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration). + builder.Property(e => e.Description) + .HasMaxLength(4000) + .HasColumnName("description"); + builder.Property(e => e.ReleaseDate) .HasColumnName("release_date"); diff --git a/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.Designer.cs b/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.Designer.cs new file mode 100644 index 0000000..5515556 --- /dev/null +++ b/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.Designer.cs @@ -0,0 +1,308 @@ +// +using System; +using DeepDrftData.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + [DbContext(typeof(DeepDrftContext))] + [Migration("20260616035252_AddReleaseDescription")] + partial class AddReleaseDescription + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WaveformEntryKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("waveform_entry_key"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_mix_metadata_is_deleted"); + + b.HasIndex("ReleaseId") + .IsUnique() + .HasDatabaseName("IX_mix_metadata_release_id"); + + b.ToTable("mix_metadata", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Artist") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("artist"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedByUserId") + .HasColumnType("bigint") + .HasColumnName("created_by_user_id"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + + b.Property("Genre") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("genre"); + + b.Property("ImagePath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("image_path"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Medium") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Cut") + .HasColumnName("medium"); + + b.Property("ReleaseDate") + .HasColumnType("date") + .HasColumnName("release_date"); + + b.Property("ReleaseType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Single") + .HasColumnName("release_type"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_release_is_deleted"); + + b.HasIndex("Title", "Artist") + .IsUnique() + .HasDatabaseName("IX_release_title_artist") + .HasFilter("\"is_deleted\" = false"); + + b.ToTable("release", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HeroImageEntryKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("hero_image_entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_session_metadata_is_deleted"); + + b.HasIndex("ReleaseId") + .IsUnique() + .HasDatabaseName("IX_session_metadata_release_id"); + + b.ToTable("session_metadata", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("OriginalFileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_file_name"); + + b.Property("ReleaseId") + .HasColumnType("bigint") + .HasColumnName("release_id"); + + b.Property("TrackName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("track_name"); + + b.Property("TrackNumber") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("track_number"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("IX_track_is_deleted"); + + b.HasIndex("ReleaseId"); + + b.ToTable("track", (string)null); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithOne("MixMetadata") + .HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithOne("SessionMetadata") + .HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => + { + b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") + .WithMany("Tracks") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => + { + b.Navigation("MixMetadata"); + + b.Navigation("SessionMetadata"); + + b.Navigation("Tracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.cs b/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.cs new file mode 100644 index 0000000..2f1316a --- /dev/null +++ b/DeepDrftData/Migrations/20260616035252_AddReleaseDescription.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeepDrftData.Migrations +{ + /// + public partial class AddReleaseDescription : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "description", + table: "release", + type: "character varying(4000)", + maxLength: 4000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "description", + table: "release"); + } + } +} diff --git a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs index 16400b3..dbfb337 100644 --- a/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs +++ b/DeepDrftData/Migrations/DeepDrftContextModelSnapshot.cs @@ -90,6 +90,11 @@ namespace DeepDrftData.Migrations .HasColumnType("bigint") .HasColumnName("created_by_user_id"); + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("description"); + b.Property("Genre") .HasMaxLength(100) .HasColumnType("character varying(100)") diff --git a/DeepDrftData/TrackConverter.cs b/DeepDrftData/TrackConverter.cs index a8c0a8b..9de23ac 100644 --- a/DeepDrftData/TrackConverter.cs +++ b/DeepDrftData/TrackConverter.cs @@ -24,6 +24,7 @@ public class TrackConverter : IEntityToModelConverter Title = entity.Title, Artist = entity.Artist, Genre = entity.Genre, + Description = entity.Description, ReleaseDate = entity.ReleaseDate, ImagePath = entity.ImagePath, Medium = entity.Medium, @@ -55,6 +56,7 @@ public class TrackConverter : IEntityToModelConverter Title = dto.Title, Artist = dto.Artist, Genre = dto.Genre, + Description = dto.Description, ReleaseDate = dto.ReleaseDate, ImagePath = dto.ImagePath, Medium = dto.Medium, diff --git a/DeepDrftData/TrackManager.cs b/DeepDrftData/TrackManager.cs index 108e228..643aa64 100644 --- a/DeepDrftData/TrackManager.cs +++ b/DeepDrftData/TrackManager.cs @@ -281,6 +281,7 @@ public class TrackManager releaseEntity.Title = release.Title; releaseEntity.Artist = release.Artist; releaseEntity.Genre = release.Genre; + releaseEntity.Description = release.Description; releaseEntity.ReleaseDate = release.ReleaseDate; releaseEntity.ImagePath = release.ImagePath; releaseEntity.Medium = release.Medium; diff --git a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor index 113c85d..3ea9236 100644 --- a/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor +++ b/DeepDrftManager/Components/Pages/Tracks/AlbumHeaderFields.razor @@ -22,6 +22,11 @@ T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" Disabled="Disabled" /> + + + @@ -79,6 +84,8 @@ [Parameter] public EventCallback ArtistChanged { get; set; } [Parameter] public string Genre { get; set; } = string.Empty; [Parameter] public EventCallback GenreChanged { get; set; } + [Parameter] public string Description { get; set; } = string.Empty; + [Parameter] public EventCallback DescriptionChanged { get; set; } [Parameter] public string ReleaseDate { get; set; } = string.Empty; [Parameter] public EventCallback ReleaseDateChanged { get; set; } [Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; diff --git a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor index 9e80a5f..d344903 100644 --- a/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor +++ b/DeepDrftManager/Components/Pages/Tracks/BatchEdit.razor @@ -33,6 +33,7 @@ UpdateAsync( long id, string trackName, string artist, - string? album, string? genre, DateOnly? releaseDate, + string? album, string? genre, string? description, DateOnly? releaseDate, string? imagePath = null, ReleaseType? releaseType = null, ReleaseMedium? medium = null, @@ -385,6 +387,7 @@ public class CmsTrackService : ICmsTrackService artist, album, genre, + description, releaseDate, imagePath, releaseType = releaseType.HasValue ? (int?)releaseType.Value : null, diff --git a/DeepDrftManager/Services/ICmsTrackService.cs b/DeepDrftManager/Services/ICmsTrackService.cs index c6b2edb..3925e7f 100644 --- a/DeepDrftManager/Services/ICmsTrackService.cs +++ b/DeepDrftManager/Services/ICmsTrackService.cs @@ -30,6 +30,7 @@ public interface ICmsTrackService string artist, string? album, string? genre, + string? description, string? releaseDate, string? originalFileName, long createdByUserId, @@ -85,7 +86,7 @@ public interface ICmsTrackService /// Task UpdateAsync( long id, string trackName, string artist, - string? album, string? genre, DateOnly? releaseDate, + string? album, string? genre, string? description, DateOnly? releaseDate, string? imagePath = null, ReleaseType? releaseType = null, ReleaseMedium? medium = null, diff --git a/DeepDrftModels/DTOs/ReleaseDto.cs b/DeepDrftModels/DTOs/ReleaseDto.cs index 7d5b7bf..5df19f6 100644 --- a/DeepDrftModels/DTOs/ReleaseDto.cs +++ b/DeepDrftModels/DTOs/ReleaseDto.cs @@ -12,6 +12,7 @@ public class ReleaseDto : BaseModel public string Title { get; set; } = string.Empty; public string Artist { get; set; } = string.Empty; public string? Genre { get; set; } + public string? Description { get; set; } public DateOnly? ReleaseDate { get; set; } public string? ImagePath { get; set; } public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut; diff --git a/DeepDrftModels/Entities/ReleaseEntity.cs b/DeepDrftModels/Entities/ReleaseEntity.cs index 1ef3aa7..6cf3533 100644 --- a/DeepDrftModels/Entities/ReleaseEntity.cs +++ b/DeepDrftModels/Entities/ReleaseEntity.cs @@ -15,6 +15,10 @@ public class ReleaseEntity : BaseEntity, IEntity public required string Title { get; set; } public required string Artist { get; set; } public string? Genre { get; set; } + // Free-text prose blurb describing the release. Uniform across media (Cut/Session/Mix), so it + // lives on the base table alongside Genre rather than in a per-medium satellite. Plain text, + // max 4000 (configured in ReleaseConfiguration); nullable so existing rows migrate as NULL. + public string? Description { get; set; } public DateOnly? ReleaseDate { get; set; } public string? ImagePath { get; set; } public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; diff --git a/DeepDrftTests/MediumWritePathTests.cs b/DeepDrftTests/MediumWritePathTests.cs index a282c63..a56e647 100644 --- a/DeepDrftTests/MediumWritePathTests.cs +++ b/DeepDrftTests/MediumWritePathTests.cs @@ -161,6 +161,82 @@ public class MediumWritePathTests Assert.That(dto.ReleaseType, Is.Null); } + // 11.G — Description round-trips through both converter directions verbatim (no medium dance, + // unlike ReleaseType): entity → DTO preserves the prose, and DTO → entity carries it back. + [Test] + public void Convert_Description_RoundTripsBothDirections() + { + const string prose = "A late-night set\nrecorded at the Vault."; + var entity = new ReleaseEntity + { + Title = "Live at the Vault", Artist = "Artist A", + Medium = ReleaseMedium.Session, Description = prose, + }; + + var dto = TrackConverter.Convert(entity); + Assert.That(dto.Description, Is.EqualTo(prose), "entity → DTO preserves Description"); + + var back = TrackConverter.Convert(dto); + Assert.That(back.Description, Is.EqualTo(prose), "DTO → entity preserves Description"); + } + + // 11.G — a null Description round-trips as null in both directions (existing rows migrate as NULL). + [Test] + public void Convert_NullDescription_RoundTripsAsNull() + { + var entity = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Description = null }; + + var dto = TrackConverter.Convert(entity); + Assert.That(dto.Description, Is.Null); + + var back = TrackConverter.Convert(dto); + Assert.That(back.Description, Is.Null); + } + + // 11.G — Description rides the release-cardinal write channel onto the persisted release row, + // exactly as Genre does. FindOrCreateRelease is the upload-path projection point. + [Test] + public async Task FindOrCreateRelease_NewRelease_PersistsDescription() + { + const string prose = "Three cuts pressed for the summer."; + var manager = CreateManager(CreateRepository()); + + var data = ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut); + data.Description = prose; + + var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data); + + Assert.That(result.Success, Is.True); + Assert.That(result.Value!.Description, Is.EqualTo(prose)); + + var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id); + Assert.That(stored!.Description, Is.EqualTo(prose)); + } + + // 11.C — editing a track's linked release sets the Description on the persisted release row, + // mirroring the PUT api/track/meta apply (release.Description = request.Description). + [Test] + public async Task Update_SetsReleaseDescription_PersistsDescription() + { + const string prose = "Now with a proper blurb."; + var repo = CreateRepository(); + ITrackService manager = CreateManager(repo); + + var release = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut }; + var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release }; + _context.Tracks.Add(track); + await _context.SaveChangesAsync(); + + var loaded = (await manager.GetById(track.Id)).Value!; + loaded.Release!.Description = prose; + + var result = await manager.Update(loaded); + Assert.That(result.Success, Is.True); + + var stored = await CreateRepository().GetReleaseByIdAsync(release.Id); + Assert.That(stored!.Description, Is.EqualTo(prose)); + } + // 9.5.C — releaseId filter returns only the tracks of the given release. Built on the repository // directly to assert the WHERE release_id predicate in isolation. [Test] From 2b42e01cd0d7d89cc1b8415d529191a0c46740b3 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 16 Jun 2026 00:04:44 -0400 Subject: [PATCH 3/6] feat(player): add IQueueService orchestrating album playback above the single-slot player (P11 11.F) Queue owns ordered tracks, current index, skip-fwd/back, and auto-advance via the player's TrackEnded hook; binds through Attach (no ctor growth, no service-locator). Player-bar skip controls; empty-queue play unchanged. Adds QueueService unit tests. --- .../AudioPlayerBar/AudioPlayerBar.razor | 4 + .../AudioPlayerBar/AudioPlayerBar.razor.cs | 32 ++ .../AudioPlayerBar/PlayerControls.razor | 13 + .../AudioPlayerBar/PlayerControls.razor.cs | 9 + .../AudioPlayerBar/PlayerTransportZone.razor | 6 +- .../PlayerTransportZone.razor.cs | 4 + .../Controls/AudioPlayerProvider.razor | 4 +- .../Controls/AudioPlayerProvider.razor.cs | 13 + .../Services/AudioPlayerService.cs | 9 + .../Services/IPlayerService.cs | 9 + .../Services/IQueueService.cs | 80 ++++ .../Services/QueueService.cs | 136 ++++++ DeepDrftTests/DeepDrftTests.csproj | 4 + DeepDrftTests/QueueServiceTests.cs | 450 ++++++++++++++++++ 14 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 DeepDrftPublic.Client/Services/IQueueService.cs create mode 100644 DeepDrftPublic.Client/Services/QueueService.cs create mode 100644 DeepDrftTests/QueueServiceTests.cs diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor index e653de1..089f478 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor @@ -21,6 +21,10 @@ else Fixed="Fixed" TogglePlayPause="@TogglePlayPause" Stop="@Stop" + HasNext="HasNext" + HasPrevious="HasPrevious" + SkipNext="@SkipNext" + SkipPrevious="@SkipPrevious" Class="transport-zone"/> diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs index 62cf5a9..7f9a75e 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs @@ -9,6 +9,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar; public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable { [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } + [CascadingParameter] public IQueueService? QueueService { get; set; } [Parameter] public bool Fixed { get; set; } = false; [Parameter] public EventCallback OnMinimized { get; set; } @@ -19,6 +20,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable private bool _isSeeking = false; private double _seekPosition = 0; private IStreamingPlayerService? _subscribedService; + private IQueueService? _subscribedQueue; // Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's // spacer reserves its space. We mirror this element's live height into a CSS @@ -48,6 +50,11 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable private double LoadProgress => PlayerService?.LoadProgress ?? 0; private string? ErrorMessage => PlayerService?.ErrorMessage; + // Skip affordances reflect live queue state. With no queue (null) or an empty queue both are + // false, so the buttons sit disabled and the bar behaves exactly as it did before the queue. + private bool HasNext => QueueService?.HasNext ?? false; + private bool HasPrevious => QueueService?.HasPrevious ?? false; + /// /// Display time - shows seek position while dragging, otherwise current playback time. /// @@ -76,10 +83,35 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable PlayerService.StateChanged += OnPlayerStateChanged; _subscribedService = PlayerService; } + + // The queue cascade is also IsFixed, so re-render the skip affordances off its own + // change signal — same posture as the player StateChanged subscription above. + if (QueueService != null && !ReferenceEquals(QueueService, _subscribedQueue)) + { + if (_subscribedQueue != null) + _subscribedQueue.QueueChanged -= OnQueueChanged; + + QueueService.QueueChanged += OnQueueChanged; + _subscribedQueue = QueueService; + } } private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged); + private void OnQueueChanged() => InvokeAsync(StateHasChanged); + + private async Task SkipNext() + { + if (QueueService == null) return; + await QueueService.Next(); + } + + private async Task SkipPrevious() + { + if (QueueService == null) return; + await QueueService.Previous(); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { // Only the docked, expanded shape needs a spacer: the Fixed embed is diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor index d252d47..7f691d7 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor @@ -2,12 +2,25 @@ @using DeepDrftPublic.Client.Controls + @if (!Fixed) + { + + } @if (!Fixed) { + Whether the queue has a track to skip forward to. Drives the skip-next affordance. + [Parameter] public bool HasNext { get; set; } + + /// Whether the queue has a track to step back to. Drives the skip-previous affordance. + [Parameter] public bool HasPrevious { get; set; } + + [Parameter] public EventCallback SkipNext { get; set; } + [Parameter] public EventCallback SkipPrevious { get; set; } } diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor index e11206f..3510f38 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor +++ b/DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerTransportZone.razor @@ -6,7 +6,11 @@ CanPlay="CanPlay" Fixed="Fixed" TogglePlayPause="TogglePlayPause" - Stop="Stop"/> + Stop="Stop" + HasNext="HasNext" + HasPrevious="HasPrevious" + SkipNext="SkipNext" + SkipPrevious="SkipPrevious"/> @if (IsLoading && !IsStreaming) { - @ChildContent + + @ChildContent + \ No newline at end of file diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs index 2e56bbf..e8c01fc 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs @@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable [Inject] public required ILogger Logger { get; set; } private IStreamingPlayerService? _audioPlayerService; + private QueueService? _queueService; [Parameter] public RenderFragment? ChildContent { get; set; } @@ -29,6 +30,13 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable // Children must not wrap or replace this callback. _audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged)); // OnTrackSelected will be set by individual child components that need it + + // The queue orchestrates above the single-slot player. The player is not DI-registered + // (constructed here), so the queue binds to it via Attach rather than constructor injection — + // no construction cycle, no IServiceProvider. Cascaded alongside the player so the bar and a + // future up-next panel both read it. + _queueService = new QueueService(); + _queueService.Attach(_audioPlayerService); } /// @@ -38,6 +46,11 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable /// public async ValueTask DisposeAsync() { + // Dispose the queue first so it unsubscribes from the player's TrackEnded before the + // player tears down. + _queueService?.Dispose(); + _queueService = null; + if (_audioPlayerService is IAsyncDisposable disposable) { await disposable.DisposeAsync(); diff --git a/DeepDrftPublic.Client/Services/AudioPlayerService.cs b/DeepDrftPublic.Client/Services/AudioPlayerService.cs index df43100..e2f8a14 100644 --- a/DeepDrftPublic.Client/Services/AudioPlayerService.cs +++ b/DeepDrftPublic.Client/Services/AudioPlayerService.cs @@ -43,6 +43,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable /// public event Action? StateChanged; + /// + public event Action? TrackEnded; + protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient) { _audioInterop = audioInterop; @@ -268,6 +271,12 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable CurrentTime = 0; Duration = null; await NotifyStateChanged(); + + // Fire AFTER the state notification so any queue orchestrator that advances on this + // signal selects the next track against a fully-settled idle state. Raised only on + // organic end-of-stream — stop/unload/track-switch go through ResetToIdle, which does + // not raise this — so a subscriber can treat it unambiguously as "advance the queue." + TrackEnded?.Invoke(); } diff --git a/DeepDrftPublic.Client/Services/IPlayerService.cs b/DeepDrftPublic.Client/Services/IPlayerService.cs index ff74384..c0ff051 100644 --- a/DeepDrftPublic.Client/Services/IPlayerService.cs +++ b/DeepDrftPublic.Client/Services/IPlayerService.cs @@ -40,6 +40,15 @@ public interface IPlayerService /// (throttled to ~10/s during streaming). /// event Action? StateChanged; + + /// + /// Raised once when the current track reaches its natural end of playback (the JS + /// end-of-stream callback), distinct from a stop/unload/track-switch. This is the single + /// hook the play-queue subscribes to in order to auto-advance to the next track. It does + /// NOT fire when playback is stopped, the track is switched, or the player is unloaded — + /// only on organic completion — so an orchestrator can treat it as "advance the queue." + /// + event Action? TrackEnded; // Control methods Task InitializeAsync(); diff --git a/DeepDrftPublic.Client/Services/IQueueService.cs b/DeepDrftPublic.Client/Services/IQueueService.cs new file mode 100644 index 0000000..b3cea03 --- /dev/null +++ b/DeepDrftPublic.Client/Services/IQueueService.cs @@ -0,0 +1,80 @@ +using DeepDrftModels.DTOs; + +namespace DeepDrftPublic.Client.Services; + +/// +/// Orchestrates ordered playback ("what plays next") above the single-slot +/// . The player stays a single-track device; the queue owns the +/// track list, the current position, skip-forward/back, and auto-advance on natural track end. It +/// drives playback solely through the player's existing +/// — it adds no new playback semantics. +/// +/// +/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are +/// expected. They are additive — a shuffle/repeat strategy slots in behind / +/// as the "which index is next" decision; reordering mutates +/// and re-emits ; persistence snapshots/restores + +/// . None of those require changing this interface's existing members, only +/// adding new ones — so consumers written against today's surface keep working. +/// +/// +/// +/// With an empty queue ( == -1) the queue is dormant: it drives nothing and +/// auto-advances nothing, so direct single-track play through the player behaves exactly as it did +/// before the queue existed. +/// +/// +public interface IQueueService +{ + /// The ordered tracks currently queued. Empty when nothing is enqueued. + IReadOnlyList Items { get; } + + /// + /// Index into of the track the queue considers current, or -1 when the + /// queue is empty. Always a valid index into when non-negative. + /// + int CurrentIndex { get; } + + /// The current track, or null when the queue is empty. + TrackDto? Current { get; } + + /// True when there is a track after to advance to. + bool HasNext { get; } + + /// True when there is a track before to step back to. + bool HasPrevious { get; } + + /// + /// Raised whenever the queue's contents or current position change. The player bar subscribes + /// to re-render its skip-forward/back affordances. Fires on enqueue, advance, step-back, and clear. + /// + event Action? QueueChanged; + + /// + /// Replaces the queue with (in the order given) and begins streaming + /// the track at . This is the "play album" entry point the Cuts + /// detail page consumes: pass the release's tracks in ordinal order. A header Play uses + /// startIndex: 0; a mid-album row play passes that row's index so the queue continues to + /// the end from there. No-op when is empty. + /// + Task PlayRelease(IEnumerable tracks, int startIndex = 0); + + /// Appends a track to the end of the queue without changing what is currently playing. + void Enqueue(TrackDto track); + + /// Appends tracks to the end of the queue without changing what is currently playing. + void EnqueueRange(IEnumerable tracks); + + /// + /// Advances to the next track and streams it. No-op when is false. + /// + Task Next(); + + /// + /// Steps back to the previous track and streams it. No-op when is false. + /// + Task Previous(); + + /// Empties the queue and resets the position. Does not stop the player. + void Clear(); +} diff --git a/DeepDrftPublic.Client/Services/QueueService.cs b/DeepDrftPublic.Client/Services/QueueService.cs new file mode 100644 index 0000000..03a14c9 --- /dev/null +++ b/DeepDrftPublic.Client/Services/QueueService.cs @@ -0,0 +1,136 @@ +using DeepDrftModels.DTOs; + +namespace DeepDrftPublic.Client.Services; + +/// +/// Default : a single-slot orchestrator over an +/// . Holds the ordered list and current index as pure state, +/// drives playback through the player's existing , +/// and auto-advances on the player's signal. +/// +/// +/// The player instance is not DI-registered — AudioPlayerProvider constructs and cascades it. +/// So the queue is bound to the player via (called once by the provider after it +/// creates the player) rather than constructor injection. This keeps the player single-slot, avoids a +/// construction cycle between provider/player/queue, and needs no IServiceProvider. The queue's +/// own constructor stays parameterless, so the queue logic is unit-testable against a fake player with +/// no container. +/// +/// +public sealed class QueueService : IQueueService, IDisposable +{ + private readonly List _items = new(); + private IStreamingPlayerService? _player; + + public IReadOnlyList Items => _items; + + public int CurrentIndex { get; private set; } = -1; + + public TrackDto? Current => + CurrentIndex >= 0 && CurrentIndex < _items.Count ? _items[CurrentIndex] : null; + + public bool HasNext => CurrentIndex >= 0 && CurrentIndex < _items.Count - 1; + + public bool HasPrevious => CurrentIndex > 0; + + public event Action? QueueChanged; + + /// + /// Binds the queue to the player instance the provider owns, and subscribes to its track-ended + /// signal so the queue auto-advances. Idempotent and re-bindable: re-attaching detaches the prior + /// player first, so the queue never holds a stale subscription after a player swap. Owned by the + /// provider's lifecycle; unsubscribes. + /// + public void Attach(IStreamingPlayerService player) + { + if (ReferenceEquals(_player, player)) return; + + if (_player != null) + _player.TrackEnded -= OnTrackEnded; + + _player = player; + _player.TrackEnded += OnTrackEnded; + } + + public async Task PlayRelease(IEnumerable tracks, int startIndex = 0) + { + var list = tracks as IReadOnlyList ?? tracks.ToList(); + if (list.Count == 0) return; + + var start = Math.Clamp(startIndex, 0, list.Count - 1); + + _items.Clear(); + _items.AddRange(list); + CurrentIndex = start; + QueueChanged?.Invoke(); + + await PlayCurrent(); + } + + public void Enqueue(TrackDto track) + { + _items.Add(track); + QueueChanged?.Invoke(); + } + + public void EnqueueRange(IEnumerable tracks) + { + var before = _items.Count; + _items.AddRange(tracks); + if (_items.Count != before) + QueueChanged?.Invoke(); + } + + public async Task Next() + { + if (!HasNext) return; + CurrentIndex++; + QueueChanged?.Invoke(); + await PlayCurrent(); + } + + public async Task Previous() + { + if (!HasPrevious) return; + CurrentIndex--; + QueueChanged?.Invoke(); + await PlayCurrent(); + } + + public void Clear() + { + if (_items.Count == 0 && CurrentIndex == -1) return; + _items.Clear(); + CurrentIndex = -1; + QueueChanged?.Invoke(); + } + + // Advance on organic end-of-stream only. TrackEnded is not raised by stop/unload/track-switch, + // so a manual stop or a fresh single-track selection elsewhere never spuriously advances the + // queue. When the queue is past its last track, end-of-stream simply stops — nothing to advance. + private void OnTrackEnded() + { + if (!HasNext) return; + // Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's + // end-of-playback callback continuation; we must not block it. Advancing kicks off the next + // stream, whose own failures surface through the player's ErrorMessage/state — the queue does + // not own playback error handling. + _ = Next(); + } + + private async Task PlayCurrent() + { + var track = Current; + if (track is null || _player is null) return; + await _player.SelectTrackStreaming(track); + } + + public void Dispose() + { + if (_player != null) + { + _player.TrackEnded -= OnTrackEnded; + _player = null; + } + } +} diff --git a/DeepDrftTests/DeepDrftTests.csproj b/DeepDrftTests/DeepDrftTests.csproj index a3b1407..cab1aed 100644 --- a/DeepDrftTests/DeepDrftTests.csproj +++ b/DeepDrftTests/DeepDrftTests.csproj @@ -30,6 +30,10 @@ + + diff --git a/DeepDrftTests/QueueServiceTests.cs b/DeepDrftTests/QueueServiceTests.cs new file mode 100644 index 0000000..c44264c --- /dev/null +++ b/DeepDrftTests/QueueServiceTests.cs @@ -0,0 +1,450 @@ +using DeepDrftModels.DTOs; +using DeepDrftPublic.Client.Services; +using Microsoft.AspNetCore.Components; + +namespace DeepDrftTests; + +/// +/// Unit tests for the play-queue orchestrator (). The queue is pure +/// domain logic over the single-slot player, so it is exercised here against a recording fake +/// () — no browser, no JS interop, no DI container. Coverage: +/// enqueue, ordered advance, next/previous bounds, clear, current-index integrity, and +/// auto-advance on the player's signal. +/// +[TestFixture] +public class QueueServiceTests +{ + private FakeStreamingPlayer _player = null!; + private QueueService _queue = null!; + + [SetUp] + public void SetUp() + { + _player = new FakeStreamingPlayer(); + _queue = new QueueService(); + _queue.Attach(_player); + } + + [TearDown] + public void TearDown() => _queue.Dispose(); + + private static List Tracks(int count) => + Enumerable.Range(1, count) + .Select(i => new TrackDto { EntryKey = $"track-{i}", TrackName = $"Track {i}", TrackNumber = i }) + .ToList(); + + // --- Empty-queue invariants (no regression to single-track play) --- + + [Test] + public void NewQueue_IsEmptyWithCurrentIndexNegativeOne() + { + Assert.Multiple(() => + { + Assert.That(_queue.Items, Is.Empty); + Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); + Assert.That(_queue.Current, Is.Null); + Assert.That(_queue.HasNext, Is.False); + Assert.That(_queue.HasPrevious, Is.False); + }); + } + + [Test] + public async Task NextAndPrevious_OnEmptyQueue_AreNoOpsAndDriveNoPlayback() + { + await _queue.Next(); + await _queue.Previous(); + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); + Assert.That(_player.SelectedTracks, Is.Empty); + }); + } + + // --- PlayRelease: enqueue + ordered start --- + + [Test] + public async Task PlayRelease_LoadsTracksInOrderAndStreamsFirst() + { + var tracks = Tracks(3); + + await _queue.PlayRelease(tracks); + + Assert.Multiple(() => + { + Assert.That(_queue.Items.Select(t => t.EntryKey), + Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); + Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); + Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-1")); + Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1")); + }); + } + + [Test] + public async Task PlayRelease_WithStartIndex_StartsMidAlbumAndKeepsRemainderQueued() + { + var tracks = Tracks(4); + + await _queue.PlayRelease(tracks, startIndex: 2); + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); + Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3")); + Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-3")); + Assert.That(_queue.HasNext, Is.True); + Assert.That(_queue.HasPrevious, Is.True); + }); + } + + [Test] + public async Task PlayRelease_ClampsOutOfRangeStartIndex() + { + var tracks = Tracks(3); + + await _queue.PlayRelease(tracks, startIndex: 99); + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); + Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-3")); + }); + } + + [Test] + public async Task PlayRelease_WithEmptyTracks_IsNoOp() + { + await _queue.PlayRelease(Enumerable.Empty()); + + Assert.Multiple(() => + { + Assert.That(_queue.Items, Is.Empty); + Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); + Assert.That(_player.SelectedTracks, Is.Empty); + }); + } + + [Test] + public async Task PlayRelease_ReplacesAnExistingQueue() + { + await _queue.PlayRelease(Tracks(3)); + var second = new List + { + new() { EntryKey = "x-1", TrackName = "X1" }, + new() { EntryKey = "x-2", TrackName = "X2" }, + }; + + await _queue.PlayRelease(second); + + Assert.Multiple(() => + { + Assert.That(_queue.Items, Has.Count.EqualTo(2)); + Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "x-1", "x-2" })); + Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); + }); + } + + // --- Next / Previous mechanics and bounds --- + + [Test] + public async Task Next_AdvancesThroughTracksInOrder() + { + await _queue.PlayRelease(Tracks(3)); + + await _queue.Next(); + Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); + + await _queue.Next(); + Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3")); + + Assert.That(_player.SelectedTracks.Select(t => t.EntryKey), + Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); + } + + [Test] + public async Task Next_AtLastTrack_IsNoOp() + { + await _queue.PlayRelease(Tracks(2), startIndex: 1); + + await _queue.Next(); + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); + Assert.That(_queue.HasNext, Is.False); + // Only the initial PlayRelease selection — Next at the end drove no further playback. + Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async Task Previous_StepsBackThroughTracks() + { + await _queue.PlayRelease(Tracks(3), startIndex: 2); + + await _queue.Previous(); + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); + Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); + Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2")); + }); + } + + [Test] + public async Task Previous_AtFirstTrack_IsNoOp() + { + await _queue.PlayRelease(Tracks(3)); + + await _queue.Previous(); + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); + Assert.That(_queue.HasPrevious, Is.False); + Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1)); + }); + } + + // --- Enqueue / EnqueueRange --- + + [Test] + public void Enqueue_AppendsWithoutChangingCurrentOrStartingPlayback() + { + _queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" }); + + Assert.Multiple(() => + { + Assert.That(_queue.Items, Has.Count.EqualTo(1)); + Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); + Assert.That(_player.SelectedTracks, Is.Empty); + }); + } + + [Test] + public async Task Enqueue_AfterPlayRelease_ExtendsTheQueueAndEnablesHasNext() + { + await _queue.PlayRelease(Tracks(1)); + Assert.That(_queue.HasNext, Is.False); + + _queue.Enqueue(new TrackDto { EntryKey = "appended", TrackName = "Appended" }); + + Assert.Multiple(() => + { + Assert.That(_queue.HasNext, Is.True); + Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); + }); + } + + [Test] + public void EnqueueRange_AppendsAllTracks() + { + _queue.EnqueueRange(Tracks(3)); + + Assert.That(_queue.Items, Has.Count.EqualTo(3)); + } + + // --- Clear --- + + [Test] + public async Task Clear_EmptiesQueueAndResetsIndexWithoutStoppingPlayer() + { + await _queue.PlayRelease(Tracks(3)); + + _queue.Clear(); + + Assert.Multiple(() => + { + Assert.That(_queue.Items, Is.Empty); + Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); + Assert.That(_queue.Current, Is.Null); + // Clear is a queue-state reset; it must not tear the player down. + Assert.That(_player.StopCount, Is.EqualTo(0)); + }); + } + + // --- QueueChanged notifications --- + + [Test] + public async Task MutatingOperations_RaiseQueueChanged() + { + var count = 0; + _queue.QueueChanged += () => count++; + + await _queue.PlayRelease(Tracks(3)); // 1 + await _queue.Next(); // 2 + await _queue.Previous(); // 3 + _queue.Enqueue(new TrackDto { EntryKey = "z", TrackName = "Z" }); // 4 + _queue.Clear(); // 5 + + Assert.That(count, Is.EqualTo(5)); + } + + [Test] + public void Clear_OnAlreadyEmptyQueue_DoesNotRaiseQueueChanged() + { + var raised = false; + _queue.QueueChanged += () => raised = true; + + _queue.Clear(); + + Assert.That(raised, Is.False); + } + + // --- Auto-advance on TrackEnded --- + + [Test] + public async Task TrackEnded_AutoAdvancesToNextTrack() + { + await _queue.PlayRelease(Tracks(3)); + + _player.RaiseTrackEnded(); + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); + Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2")); + Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2")); + }); + } + + [Test] + public async Task TrackEnded_OnLastTrack_DoesNotAdvanceOrReplay() + { + await _queue.PlayRelease(Tracks(2), startIndex: 1); + + _player.RaiseTrackEnded(); + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); + Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1)); + }); + } + + [Test] + public void TrackEnded_OnEmptyQueue_IsIgnored() + { + _player.RaiseTrackEnded(); + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(-1)); + Assert.That(_player.SelectedTracks, Is.Empty); + }); + } + + [Test] + public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd() + { + await _queue.PlayRelease(Tracks(3)); + + _player.RaiseTrackEnded(); // → track-2 + _player.RaiseTrackEnded(); // → track-3 + _player.RaiseTrackEnded(); // last track: no advance + + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(2)); + Assert.That(_player.SelectedTracks.Select(t => t.EntryKey), + Is.EqualTo(new[] { "track-1", "track-2", "track-3" })); + }); + } + + // --- Attach lifecycle --- + + [Test] + public async Task Dispose_UnsubscribesFromTrackEnded_SoNoAutoAdvanceAfterDispose() + { + await _queue.PlayRelease(Tracks(3)); + _queue.Dispose(); + + _player.RaiseTrackEnded(); + + Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); + } + + [Test] + public async Task Attach_ToNewPlayer_RedirectsPlaybackAndAutoAdvance() + { + var second = new FakeStreamingPlayer(); + _queue.Attach(second); + + await _queue.PlayRelease(Tracks(3)); + Assert.That(second.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1")); + + // The old player's TrackEnded must no longer drive this queue. + _player.RaiseTrackEnded(); + Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); + + // The newly attached player does. + second.RaiseTrackEnded(); + Assert.That(_queue.CurrentIndex, Is.EqualTo(1)); + } + + /// + /// Records the tracks the queue asks the player to stream and lets a test raise the + /// player's organic end-of-stream signal. Implements the full + /// surface but only the members the queue actually drives carry behavior; the rest are inert + /// — the queue touches nothing else, which is exactly the seam this fake pins down. + /// + private sealed class FakeStreamingPlayer : IStreamingPlayerService + { + public List SelectedTracks { get; } = new(); + public int StopCount { get; private set; } + + public void RaiseTrackEnded() => TrackEnded?.Invoke(); + + public Task SelectTrackStreaming(TrackDto track) + { + SelectedTracks.Add(track); + CurrentTrack = track; + return Task.CompletedTask; + } + + public Task Stop() + { + StopCount++; + return Task.CompletedTask; + } + + public event Action? TrackEnded; + + // Part of the implemented contract but the queue never subscribes to it, so it is + // intentionally never raised here. +#pragma warning disable CS0067 + public event Action? StateChanged; +#pragma warning restore CS0067 + + // Inert remainder of the contract — the queue never invokes these. + public bool IsInitialized => false; + public bool IsLoaded => false; + public bool IsLoading => false; + public bool IsPlaying => false; + public bool IsPaused => false; + public double CurrentTime => 0; + public double? Duration => null; + public double Volume => 1.0; + public double LoadProgress => 0; + public string? ErrorMessage => null; + public TrackDto? CurrentTrack { get; private set; } + public double[]? WaveformProfile => null; + public EventCallback? OnStateChanged { get; set; } + public EventCallback? OnTrackSelected { get; set; } + public bool IsStreamingMode => false; + public bool CanStartStreaming => false; + public bool HeaderParsed => false; + public int BufferedChunks => 0; + + public Task InitializeAsync() => Task.CompletedTask; + public Task SelectTrack(TrackDto track) => SelectTrackStreaming(track); + public Task Unload() => Task.CompletedTask; + public Task TogglePlayPause() => Task.CompletedTask; + public Task Seek(double position) => Task.CompletedTask; + public Task SetVolume(double volume) => Task.CompletedTask; + public Task ClearError() => Task.CompletedTask; + public Task WarmAudioContext() => Task.CompletedTask; + public Task StageTrack(TrackDto track) => Task.CompletedTask; + } +} From 294414d00a3f9ae56789aff9068ee11095b1e911 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 16 Jun 2026 00:13:51 -0400 Subject: [PATCH 4/6] fix(queue): guard OnTrackEnded against direct-play cross-context advance Only advance when player's CurrentTrack.Id matches queue's Current.Id; direct-play call sites (SessionDetail, StreamNowButton, resume) that supersede the queue no longer spuriously advance the album. Adds regression test covering the scenario. --- .../Services/QueueService.cs | 8 ++++ DeepDrftTests/QueueServiceTests.cs | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/DeepDrftPublic.Client/Services/QueueService.cs b/DeepDrftPublic.Client/Services/QueueService.cs index 03a14c9..2138c37 100644 --- a/DeepDrftPublic.Client/Services/QueueService.cs +++ b/DeepDrftPublic.Client/Services/QueueService.cs @@ -108,9 +108,17 @@ public sealed class QueueService : IQueueService, IDisposable // Advance on organic end-of-stream only. TrackEnded is not raised by stop/unload/track-switch, // so a manual stop or a fresh single-track selection elsewhere never spuriously advances the // queue. When the queue is past its last track, end-of-stream simply stops — nothing to advance. + // + // Guard: only advance when the track that just ended is the queue's own current item. Call sites + // that stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar) + // overwrite the player's CurrentTrack without touching the queue. If their track reaches natural + // end, the player fires TrackEnded — but the queue's Current no longer matches the player's + // CurrentTrack, so we must not advance. Id-based equality is used rather than ReferenceEquals + // because DTO copies through serialisation are not reference-equal. private void OnTrackEnded() { if (!HasNext) return; + if (_player?.CurrentTrack?.Id != Current?.Id) return; // Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's // end-of-playback callback continuation; we must not block it. Advancing kicks off the next // stream, whose own failures surface through the player's ErrorMessage/state — the queue does diff --git a/DeepDrftTests/QueueServiceTests.cs b/DeepDrftTests/QueueServiceTests.cs index c44264c..0321ccd 100644 --- a/DeepDrftTests/QueueServiceTests.cs +++ b/DeepDrftTests/QueueServiceTests.cs @@ -335,6 +335,37 @@ public class QueueServiceTests }); } + /// + /// Regression guard for the cross-context spurious-advance bug: a direct single-track play + /// (Session, StreamNowButton, resume) overwrites the player's CurrentTrack without touching the + /// queue. When that external track reaches its natural end, TrackEnded fires — but the queue's + /// Current no longer matches the player's CurrentTrack, so the queue must NOT advance. + /// + [Test] + public async Task TrackEnded_WhenPlayerCurrentTrackIsNotQueueCurrent_DoesNotAdvance() + { + // Load a 3-track album into the queue (queue.Current → track-1, player.CurrentTrack → track-1). + await _queue.PlayRelease(Tracks(3)); + Assert.That(_queue.CurrentIndex, Is.EqualTo(0)); + + // Simulate a direct play (e.g. SessionDetail streams an unrelated track by Id = 99). + // The player's CurrentTrack is now the session track, but the queue is still on track-1. + var sessionTrack = new TrackDto { Id = 99, EntryKey = "session-track", TrackName = "Session Mix" }; + _player.SimulateDirectPlay(sessionTrack); + + // The session track finishes naturally — player raises TrackEnded. + _player.RaiseTrackEnded(); + + // The queue must not have advanced: index still 0, and no additional SelectTrackStreaming + // calls beyond the initial PlayRelease selection. + Assert.Multiple(() => + { + Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "Queue must not advance when a direct-play track ends"); + Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "No further streaming must be triggered by the queue"); + Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"), "Only the original PlayRelease selection must have been streamed"); + }); + } + [Test] public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd() { @@ -396,6 +427,13 @@ public class QueueServiceTests public void RaiseTrackEnded() => TrackEnded?.Invoke(); + /// + /// Sets to without recording a + /// entry. Models a direct single-track play (SessionDetail, + /// StreamNowButton, resume) that overwrites the player state without going through the queue. + /// + public void SimulateDirectPlay(TrackDto track) => CurrentTrack = track; + public Task SelectTrackStreaming(TrackDto track) { SelectedTracks.Add(track); From c1ed2a9ba32121f770596d30edddf9f58f1a16e1 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 16 Jun 2026 00:16:37 -0400 Subject: [PATCH 5/6] fix(visualizer): vivid HSL field, time-driven bubbling, surface-born bubbles, visible color-shift (P10 W3 rework) --- .../Interop/visualizer/MixVisualizer.ts | 320 +++++++++++++----- 1 file changed, 235 insertions(+), 85 deletions(-) diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index f2c0d0f..e6d2a08 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -73,8 +73,14 @@ export const DEFAULT_COLOR_SHIFT_SPEED = 0.3; */ const NOW_ANCHOR_FROM_TOP = 0.5; -/** Background opacity of the whole ribbon — keeps it a backdrop, not a chart. */ -const RIBBON_OPACITY = 0.22; +/** + * Background opacity of the whole ribbon. Raised from the parity 0.22 → 0.55 for the + * Wave-3-rework "vivid/glassy" pass: at 0.22 the page (off-white / navy) showed through + * ~78% of every pixel and washed the field toward grey. 0.55 lets the saturated navy/moss + * read as real colour while still keeping it a translucent glass backdrop, not an opaque + * chart. (The rim/Fresnel lift on top pushes edges higher.) + */ +const RIBBON_OPACITY = 0.55; /** * Half-width of the ribbon at full loudness, as a fraction of half the canvas @@ -134,7 +140,10 @@ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005; // received/uploaded, first-draw dimensions, GL error after first draw) are gated // here so they can be silenced once the renderer is confirmed healthy. Leave it on // while the runtime fix is being verified through the browser. -const DEBUG = false; +// NOTE: ON for this visual-iteration pass (Phase 10 W3 rework). Daniel tests in-browser; +// the resolved navy/moss RGB + FPS lines confirm the fixes. Flip back to false once the +// look is approved. +const DEBUG = true; const TAG = '[MixVisualizer]'; function debugLog(...args: unknown[]): void { @@ -378,30 +387,55 @@ const float RIBBON_HALF_WIDTH_FRAC = ${RIBBON_HALF_WIDTH_FRAC.toFixed(4)}; // ── Wave 3 tuning constants (all in-shader; Daniel tunes by editing here). ────────── // Colour-shift speed → cycle period (seconds). The slider is normalized [0,1]; we map -// it onto a PERIOD that the field's time-axis phase cycles over. Spec §3a control 4: -// ~60 s (barely-perceptible drift) at 0 → ~4 s (briskly morphing) near 1. We never let -// the period go infinite, so even at 0 the field still drifts (spec §4b "never static"). -// Exponential interpolation gives perceptually even slider feel (a log control over -// rate): period = 60 * (4/60)^speed. At speed 0 → 60 s, speed 0.3 (default) → ~22 s, -// speed 1 → 4 s. Phase rate = 2π / period. -const float COLORSHIFT_PERIOD_SLOW = 60.0; // s at slider 0 — slow drift, never frozen -const float COLORSHIFT_PERIOD_FAST = 4.0; // s at slider 1 — brisk morph +// it onto a PERIOD that the field's time-axis phase cycles over. Reworked range (W3 +// rework): the old 60 s slow end made even the default look frozen — Daniel reported the +// slider "doesn't do anything." Narrowed to ~24 s (a perceptible slow drift) → ~2 s +// (unmistakably brisk morph), so dragging the slider is obvious end to end. Exponential +// map for perceptually even feel: period = 24 * (2/24)^speed. speed 0 → 24 s, speed 0.3 +// (default) → ~12 s, speed 1 → 2 s. Phase rate = 2π / period. Combined with the saturated +// poles below, a full morph cycle now sweeps a visibly different colour, not grey→grey. +const float COLORSHIFT_PERIOD_SLOW = 24.0; // s at slider 0 — slow but perceptible drift +const float COLORSHIFT_PERIOD_FAST = 2.0; // s at slider 1 — unmistakably brisk morph + +// Vividness (W3 rework). The raw theme tokens are muted UI colours (navy text / moss +// secondary, both dark + low-saturation); a naive RGB lerp between them passes through a +// muddy grey midpoint, which is exactly the "mostly grey" Daniel rejected. We mix the +// field in HSL instead (hue/sat/lum interpolate independently, so the path between two +// saturated colours stays saturated — no grey midpoint), and lift saturation + luminance +// of the result so the field reads as rich glassy navy-blue ↔ vivid moss-green. These are +// the punch dials. +const float VIVID_SATURATION_FLOOR = 0.62; // min saturation of any field pixel [0,1] +const float VIVID_SATURATION_BOOST = 0.30; // extra saturation pushed in on top of the lerp +const float VIVID_LUMINANCE_LIFT = 0.14; // lifts the dark poles off black so colour reads // Bubblyness: how far the metaball field spreads to neighbours at max bulge, as a // fraction of the half-window. Larger = more liquid coalescence between bars. const float BUBBLE_SMOOTHMIN_K = 0.18; +// Bubbling motion (W3 rework). Bubblyness used to only thicken the ribbon statically. +// Now it also drives a time-varying swell of the ribbon surface (a lava-lamp roil): a +// low-frequency noise displaces the bar half-width up and down over time, with amplitude +// and churn rate growing with uBubblyness. At 0 the displacement is zero (flat parity +// bars); rising = an increasingly active, undulating surface. +const float BUBBLE_SWELL_AMPLITUDE = 0.35; // max half-width swell (xn units) at bubblyness 1 +const float BUBBLE_SWELL_RATE = 0.55; // churn speed (rad/s scale) of the swell noise +const float BUBBLE_SWELL_FREQ = 2.2; // spatial frequency of the swell along the ribbon + // Detach: how many independent rising blobs we evaluate, and how far (in window // heights) a blob travels over its life before fading + recycling. Bounded so it reads -// as a hypnotic drift, not a particle storm (spec §4e). -const int DETACH_BLOB_COUNT = 7; -const float DETACH_RISE_SPAN = 1.25; // window-heights a blob climbs across its life +// as a hypnotic drift, not a particle storm (spec §4e). Reworked so blobs originate AT +// the waveform surface (where loudness is) and pinch off from it, rather than spawning in +// empty space — see ribbonField's detach block. +const int DETACH_BLOB_COUNT = 6; +const float DETACH_RISE_SPAN = 1.15; // window-heights a blob climbs across its life +const float DETACH_BLOB_DRIFT = 0.05; // horizontal lava-lamp wobble amplitude (xn units) // Glass: specular sharpness, Fresnel falloff, refraction warp strength. Pure aesthetic -// (spec §4f open item) — these are the dials for "maximum style". -const float GLASS_SPECULAR_POWER = 32.0; // higher = tighter hotspot -const float GLASS_FRESNEL_POWER = 3.0; // higher = thinner rim glow -const float GLASS_REFRACT_WARP = 0.06; // field-distortion amount at curved surfaces +// (spec §4f open item) — these are the dials for "maximum style". Pushed up in the W3 +// rework for a stronger, wetter, more obviously-glassy read (Daniel wanted "glassy"). +const float GLASS_SPECULAR_POWER = 48.0; // higher = tighter, harder hotspot +const float GLASS_FRESNEL_POWER = 2.2; // lower = broader, more visible rim glow +const float GLASS_REFRACT_WARP = 0.10; // field-distortion amount at curved surfaces // Fetch one raw sample by its linear index, mapping the 1-D index onto the 2-D // texture grid (col = i mod width, row = i / width). texelFetch ignores filtering @@ -469,6 +503,66 @@ float valueNoise(vec2 p) { return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } +// ── HSL conversion (for the VIVID field — see VIVID_* consts). ────────────────────── +// Mixing two saturated colours in linear RGB drags the midpoint through grey; mixing in +// HSL keeps hue/sat/lum independent so the path between navy and moss stays colourful. +// Standard branchless RGB↔HSL. h,s,l ∈ [0,1]. +vec3 rgb2hsl(vec3 c) { + float mx = max(max(c.r, c.g), c.b); + float mn = min(min(c.r, c.g), c.b); + float l = (mx + mn) * 0.5; + float d = mx - mn; + float s = 0.0; + float h = 0.0; + if (d > 1e-5) { + s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn); + if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); + else if (mx == c.g) h = (c.b - c.r) / d + 2.0; + else h = (c.r - c.g) / d + 4.0; + h /= 6.0; + } + return vec3(h, s, l); +} +float hue2rgb(float p, float q, float t) { + if (t < 0.0) t += 1.0; + if (t > 1.0) t -= 1.0; + if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; + if (t < 1.0 / 2.0) return q; + if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; + return p; +} +vec3 hsl2rgb(vec3 hsl) { + float h = hsl.x, s = hsl.y, l = hsl.z; + if (s < 1e-5) return vec3(l); + float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; + float p = 2.0 * l - q; + return vec3(hue2rgb(p, q, h + 1.0 / 3.0), hue2rgb(p, q, h), hue2rgb(p, q, h - 1.0 / 3.0)); +} +// Interpolate two RGB colours through HSL, taking the SHORT way around the hue circle so +// navy↔moss travels the rich teal/blue arc rather than wrapping through red. Returns the +// result back in RGB with no extra vividness applied (the caller adds the punch). +vec3 mixHsl(vec3 a, vec3 b, float t) { + vec3 ha = rgb2hsl(a); + vec3 hb = rgb2hsl(b); + float dh = hb.x - ha.x; + if (dh > 0.5) dh -= 1.0; // go the short way round the hue wheel + if (dh < -0.5) dh += 1.0; + float h = fract(ha.x + dh * t); + float s = mix(ha.y, hb.y, t); + float l = mix(ha.z, hb.z, t); + return hsl2rgb(vec3(h, s, l)); +} +// Push a colour toward vivid: raise saturation (with a floor) and lift luminance off +// black so the dark theme poles actually read as colour rather than near-grey. amp ∈ [0,1] +// (loudness) lifts a loud bar a little further for the "own living thing" read. +vec3 vivify(vec3 rgb, float amp) { + vec3 hsl = rgb2hsl(rgb); + hsl.y = max(hsl.y, VIVID_SATURATION_FLOOR); + hsl.y = clamp(hsl.y + VIVID_SATURATION_BOOST + amp * 0.10, 0.0, 1.0); + hsl.z = clamp(hsl.z + VIVID_LUMINANCE_LIFT + amp * 0.06, 0.0, 0.92); + return hsl2rgb(hsl); +} + // ── Signed-distance primitives + smooth-min (the metaball machinery). ─────────────── // Box SDF (centred at origin, half-extents b): negative inside, positive outside. float sdBox(vec2 p, vec2 b) { @@ -502,7 +596,7 @@ float smin(float a, float b, float k) { // edge; y is screen-row time as before. Loudness at this row sets the attached // half-width; loudness at neighbouring rows lets the metaball smooth-min coalesce // vertically into a continuous liquid column rather than discrete per-row slabs. -float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth, float playheadFeed, out float ampOut) { +float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth, out float ampOut) { float screenYTop = px.y; float screenX = px.x; @@ -513,7 +607,20 @@ float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth // Normalized horizontal coordinate: 0 at centre, ±1 at the ribbon's max half-width. float xn = (screenX - uResolution.x * 0.5) / maxHalfWidth; - float halfWidthN = amp; // amp ∈ [0,1] already, so the box half-extent in xn units IS amp + + // --- BUBBLING MOTION (§4d rework) ------------------------------------------------- + // Bubblyness now drives a real over-time roil, not just a thicker static ribbon. A + // low-frequency noise sampled over (this row's mix-time, the wall clock) swells the + // bar's half-width up and down continuously — the surface churns like a lava lamp's. + // Amplitude AND churn rate both scale with uBubblyness, so at 0 the term vanishes + // (flat parity bars) and rising = an increasingly active, undulating surface. We key + // the noise to mix-time (not screen-Y) so the swell travels WITH the audio as it + // scrolls, rather than sitting still in screen space. Only applied where there is + // loudness (amp gates it) so silence stays flat. + float swellNoise = valueNoise(vec2(t * BUBBLE_SWELL_FREQ, + uTimeSeconds * BUBBLE_SWELL_RATE)) - 0.5; // ±0.5 + float swell = swellNoise * BUBBLE_SWELL_AMPLITUDE * uBubblyness * amp * 2.0; + float halfWidthN = max(amp + swell, 0.0); // box half-extent in xn units, now animated // --- ATTACHED SHAPE --------------------------------------------------------------- // At bubblyness 0: a thin vertical slab per row → reads as the parity rectangular @@ -554,48 +661,74 @@ float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth attached = smin(attached, smin(up, dn, k), k); } - // --- DETACH: pinch off rising blobs (§4e) ----------------------------------------- - // Building on the attached field: as detach rises, a set of bounded metaballs lift - // off and climb on the uTimeSeconds clock, fading near the top. We weaken the link - // to the parent by reducing the smooth-min k as detach→1 (the liquid "neck" thins - // and breaks), and add the free blobs as independent metaball centres. + // --- DETACH: bubbles pinch off the surface and rise (§4e rework) ------------------ + // Reworked from the old "fixed-column blobs floating in empty space that vibrate" to + // bubbles that EMANATE FROM the waveform: each bubble is born at the ribbon's edge + // (where the loudness is) near the now-line, pinches off, and rises smoothly. Two + // fixes for the rejected version: + // 1. ORIGIN AT THE WAVEFORM. A bubble's birth column sits at ±(loudness) — the bar + // EDGE at its birth time — not a hash-picked column in empty space. We sample the + // datum at the birth time so a bubble only exists where there was actually sound, + // and it starts attached to the surface there. + // 2. NO VIBRATION. The vertical scale now matches the horizontal (xn) scale via the + // screen aspect (yAspect below), so blobs are round, not squashed — the old code + // normalised a vertical distance by maxHalfWidth (a HORIZONTAL scale), which + // stretched blobs and made the SDF-gradient normal unstable → shimmer. Motion is + // a single smooth fract(uTimeSeconds·rate); the only hash use is per-index + // identity (time-invariant), so there is no per-frame jitter. float field = attached; if (uDetach > 0.001) { - // Each blob has a stable per-index identity (its column, size, phase) so the set - // is a calm, repeating drift rather than a random storm. We loop a fixed small - // count (DETACH_BLOB_COUNT) — bounded cost, bounded visuals. + // Map a vertical screen-pixel distance into the same xn units the SDF circle uses, + // so a "circle of radius r" is actually round on screen. xn divides by maxHalfWidth + // (≈ half the canvas width); to match, vertical must divide by the same, hence the + // 1.0 here keeps both axes in maxHalfWidth units (screenY already in px like screenX). + float yToXn = 1.0 / maxHalfWidth; for (int i = 0; i < DETACH_BLOB_COUNT; i++) { float fi = float(i); - // Per-blob constants from a hash so blobs differ but are deterministic. - float seed = hash21(vec2(fi, 7.0)); - // Spawn column: spread across the ribbon width, biased by loudness presence. - float colX = (seed * 2.0 - 1.0) * 0.8; - // Loudness feeding this blob's column at the now line — a louder mix sheds - // bigger blobs. playheadFeed is pre-computed once in main() (fragment-invariant). - float feed = playheadFeed; - float radius = (0.05 + 0.10 * seed) * (0.4 + 0.6 * feed) * uDetach; - // Rise phase: 0→1 over the blob's life, looping. Different phase offset per - // blob so they don't pulse in unison. Speed scales mildly with detach. - float life = fract(uTimeSeconds * (0.06 + 0.05 * seed) + seed); - // Vertical position: starts near the zero-line, climbs DETACH_RISE_SPAN - // window-heights upward (screen-up = decreasing screenYTop). Slight sinus - // horizontal drift for the lava-lamp wobble. - float riseN = life * DETACH_RISE_SPAN; // in window-heights - float blobYTop = nowY - riseN * uResolution.y; - float driftX = colX + 0.06 * sin(uTimeSeconds * 0.7 + seed * 6.28); - // Blob centre offset into our (xn, yTop) eval frame. driftX is already in xn - // units (it's a fraction of the ribbon half-width), so it subtracts directly. - vec2 pBlob = vec2(xn - driftX, - (screenYTop - blobYTop) / maxHalfWidth); + // Per-blob identity from a hash — stable over time (no per-frame term), so the + // blob set is a calm repeating drift, never a random storm. + float seed = hash21(vec2(fi, 7.0)); + float seed2 = hash21(vec2(fi, 19.0)); + float side = seed2 < 0.5 ? -1.0 : 1.0; // which edge of the ribbon it peels off + + // Life 0→1, looping, smooth and continuous on the wall clock. Per-blob phase + // offset so they don't pulse in unison; rise rate scales gently with detach. + float rate = (0.05 + 0.04 * seed) * (0.6 + 0.8 * uDetach); + float life = fract(uTimeSeconds * rate + seed); + + // Birth time: the mix-time at the now-line, nudged per blob so they're born at + // staggered moments. The bubble emanates from the surface AS IT WAS at birth. + float birthT = uPlayheadSeconds - seed * 0.15; + float birthAmp = sampleAt(birthT); + // No surface there (silence) → no bubble. This is what ties bubbles to the + // waveform: they only appear where there was loudness to shed them. + if (birthAmp < 0.02) continue; + + // Birth column = the bar EDGE at birth (±loudness in xn), so the bubble starts + // ON the surface. As it rises it drifts slightly inward/outward (lava wobble). + float birthX = side * birthAmp; + float driftX = birthX + side * DETACH_BLOB_DRIFT * sin(uTimeSeconds * 0.6 + seed * 6.28); + + // Rise: starts at the now-line (the surface) and climbs upward (screen-up = + // decreasing screenYTop), travelling DETACH_RISE_SPAN window-heights over life. + float riseN = life * DETACH_RISE_SPAN; // window-heights climbed + float blobYTop = nowY - riseN * uResolution.y; // screen Y of the blob centre + + // Radius: bigger from a louder birth surface; grows then shrinks across life so + // the bubble swells out of the surface and fades near the top — no hard pop. + float envelope = smoothstep(0.0, 0.15, life) * (1.0 - smoothstep(0.80, 1.0, life)); + float radius = (0.04 + 0.07 * seed) * (0.5 + 0.5 * birthAmp) * uDetach * envelope; + if (radius < 1e-4) continue; // fully faded — skip (also avoids a 0-radius SDF) + + // Blob centre in the (xn, xn) eval frame. Both axes now in maxHalfWidth units + // (driftX already in xn; vertical px scaled by yToXn) → the circle is round. + vec2 pBlob = vec2(xn - driftX, (screenYTop - blobYTop) * yToXn); float blob = sdCircle(pBlob, radius); - // Fade the blob in at birth and out near the top (life→1): scale its radius - // envelope so it grows, holds, then shrinks away — no hard pop. - float envelope = smoothstep(0.0, 0.12, life) * (1.0 - smoothstep(0.78, 1.0, life)); - blob += (1.0 - envelope) * 0.2; // shrink/erase by pushing the SDF positive - // Link strength: blobs near the bar still smooth-min into it (the neck); - // higher detach thins the neck. Free-risen blobs (high life) merge with the - // overall field weakly so they read as separate. - float neckK = BUBBLE_SMOOTHMIN_K * (1.0 - uDetach) * (1.0 - life); + + // Pinch-off neck: while young (low life) and at low detach the bubble stays + // linked to the parent surface via a fat smooth-min neck; as it rises (life→1) + // or detach→1 the neck thins toward a hard union, so it reads as separated. + float neckK = BUBBLE_SMOOTHMIN_K * (1.0 - life) * (1.0 - uDetach * 0.7); field = smin(field, blob, max(neckK, 0.004)); } } @@ -621,25 +754,23 @@ void main() { return; } - // Hoist the playhead-feed tap: sampleAt(uPlayheadSeconds) is fragment-invariant - // (depends only on a uniform) and would otherwise run inside the 7-iteration detach - // blob loop × 5 ribbonField calls = up to 35× per pixel. Compute it once here. - float playheadFeed = sampleAt(uPlayheadSeconds); - // ── EFFECT 2+3 geometry: evaluate the liquid SDF + its gradient (surface normal). ── // The gradient of the SDF is the outward surface normal — we need it for the glass // (specular, Fresnel, refraction). Central differences cost 4 extra field evals; the // step is one device-pixel mapped into the field's xn/yTop frame. + // (The old playhead-feed hoist was removed in the W3 rework: detach now samples a + // per-blob birth-time loudness inside the loop, so there is no single shared tap to + // lift out. The taps remain uniform-only expressions, the same order of cost as before.) vec2 px = vec2(screenX, screenYTop); float amp; - float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, amp); + float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, amp); float e = 1.0; // 1px central-difference step float ignore; - float dRx = ribbonField(px + vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore); - float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore); - float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore); - float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore); + float dRx = ribbonField(px + vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore); + float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore); + float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore); + float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore); // Surface normal in screen space (points OUT of the liquid). y flipped because our // field y is screen-down. Guard the zero-length case at flat interiors. vec2 grad = vec2(dRx - dLx, dUy - dDy); @@ -668,23 +799,33 @@ void main() { float tHere = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond; float xnAbs = clamp(abs((screenX - w * 0.5) / maxHalfWidth), 0.0, 1.0); - // (a) Base field: layered value-noise flowing in time → smooth navy↔moss blend in - // [0,1]. Two octaves give organic, non-repeating morph without being busy. - float base = valueNoise(vec2(tHere * 0.15, phase)); - base += 0.5 * valueNoise(vec2(tHere * 0.30 + 11.0, phase * 1.7 + 5.0)); - base = clamp(base / 1.5, 0.0, 1.0); + // (a) Base field: a strong TIME-DRIVEN sweep plus layered value-noise. The explicit + // sin(phase) term is what makes the colour-shift slider unmistakable — it sweeps + // the whole field navy↔moss once per cycle, so dragging the slider visibly changes + // how fast the field morphs (the old version relied on noise drifting through a + // near-grey lerp, so the morph was invisible — Daniel's "slider does nothing"). + // The noise rides on top for organic, non-repeating variation across the window. + float sweep = 0.5 + 0.5 * sin(phase); // 0→1, one cycle per period + float drift = valueNoise(vec2(tHere * 0.15 + phase * 0.5, phase)); + drift += 0.5 * valueNoise(vec2(tHere * 0.30 + 11.0, phase * 1.7 + 5.0)); + drift = clamp(drift / 1.5, 0.0, 1.0); + float base = clamp(sweep * 0.6 + drift * 0.4, 0.0, 1.0); // time-sweep dominant // (b) Along-bar: blend more toward MOSS at the peak, NAVY near the zero-line — gives // each bar internal structure (spec §4b axis 1). Per-bar liveness: perturb by a - // noise keyed to this bar's time so neighbours differ, and lift saturation with - // loudness so a loud bar reads more vivid than a quiet one. + // noise keyed to this bar's time so neighbours differ. float perBar = valueNoise(vec2(tHere * 4.0, phase * 0.5)) - 0.5; // ±0.5 local jitter - float fieldMix = clamp(base * 0.55 + xnAbs * 0.45 + perBar * 0.20, 0.0, 1.0); + float fieldMix = clamp(base * 0.55 + xnAbs * 0.30 + perBar * 0.20 + amp * 0.15, 0.0, 1.0); - // accent = MOSS, edge = NAVY. Peak/lively → moss; zero-line/calm → navy. - vec3 baseColor = mix(uColorEdge, uColorAccent, fieldMix); - // Loudness vivifies: louder bars push toward moss + brighten slightly ("own thing"). - baseColor = mix(baseColor, uColorAccent, amp * 0.25); + // VIVID navy↔moss (§4b rework). The poles are mixed in HSL (mixHsl), not linear RGB, + // so the path between them stays saturated instead of passing through the muddy grey + // midpoint that made the field "mostly grey". vivify() then lifts saturation + luminance + // off the dark UI tokens so it reads as rich glassy navy ↔ vivid moss. accent = MOSS + // (peak/lively), edge = NAVY (zero-line/calm). + vec3 baseColor = vivify(mixHsl(uColorEdge, uColorAccent, fieldMix), amp); + // Pre-vivified accent for the glass rim/sheen below, so those highlights are vivid moss + // rather than the dull raw token (the rim is the strongest glass cue — keep it punchy). + vec3 vividAccent = vivify(uColorAccent, 1.0); // ── EFFECT 4: glass (§4f) — specular + Fresnel + frosted + refraction, all in-shader. // Fixed virtual light from the upper-left; view direction is straight at the screen. @@ -700,7 +841,9 @@ void main() { float curvature = clamp(length(grad) * maxHalfWidth, 0.0, 1.0); vec2 warp = normal * GLASS_REFRACT_WARP * curvature; float warpMix = clamp(fieldMix + warp.x + warp.y, 0.0, 1.0); - vec3 glassColor = mix(uColorEdge, uColorAccent, warpMix); + // Warped read uses the same VIVID HSL mix as the straight read, so refraction bends a + // saturated colour through the lens rather than revealing the dull raw lerp. + vec3 glassColor = vivify(mixHsl(uColorEdge, uColorAccent, warpMix), amp); glassColor = mix(glassColor, baseColor, 0.5); // blend warped + straight read // (2) Specular hotspot (Blinn-Phong) — the wet gloss. Sharp highlight where the @@ -717,11 +860,12 @@ void main() { float frost = 0.85 + 0.15 * valueNoise(vec2(screenX * 0.05, screenYTop * 0.05)); // Compose the lit glass colour: field base + warped refraction, lifted by sheen and - // a Fresnel rim toward the moss accent, plus a white-hot specular dot. + // a Fresnel rim toward the VIVID moss accent, plus a white-hot specular dot. Using the + // vivified accent (not the dull raw token) keeps the glass cues punchy and glassy. vec3 lit = glassColor; - lit += sheen * uColorAccent; - lit = mix(lit, uColorAccent * 1.3, fresnel * 0.6); // rim glows mossy - lit += spec * vec3(1.0); // specular is white light + lit += sheen * vividAccent; + lit = mix(lit, vividAccent * 1.3, fresnel * 0.7); // rim glows vivid moss + lit += spec * vec3(1.0); // specular is white light // Alpha: the backdrop opacity, lifted at the rim (Fresnel) so edges catch light, and // softened by the frost. Pre-multiplied output for the ONE/ONE_MINUS_SRC_ALPHA blend. @@ -937,7 +1081,13 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { : parseColor(readVar(canvas, '--mud-palette-primary', '#17283f')); const resolved: ResolvedTheme = { accent: moss, edge: navy }; - debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — moss=[${moss.map((c) => c.toFixed(2)).join(', ')}], navy=[${navy.map((c) => c.toFixed(2)).join(', ')}].`); + // Report BOTH poles the shader will actually use, as 0-255 RGB + relative luminance. + // This is the line Daniel watches to confirm the "grey" cause: if the poles are dull + // here (low luminance / low spread) the fix is the in-shader vivify(); if they look + // saturated here the muddying was the old linear-RGB midpoint lerp (now HSL). + const fmt = (c: [number, number, number]) => + `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}) lum=${luminance(c).toFixed(2)}`; + debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — MOSS(accent)=${fmt(moss)} NAVY(edge)=${fmt(navy)}.`); return resolved; } From b27ec1b7d080dd5d56ef3607df13d0e5526d67fa Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 16 Jun 2026 00:19:41 -0400 Subject: [PATCH 6/6] docs: record Phase 11 Wave 1 landed (11.A cuts page, 11.F queue, 11.G description) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotate PLAN.md §11 with landed tracks; add COMPLETED.md Phase 11 section; document TrackNumber as a supported sortColumn in DeepDrftAPI/CLAUDE.md. --- COMPLETED.md | 32 ++++++++++++++++++++++++++++++++ DeepDrftAPI/CLAUDE.md | 2 +- PLAN.md | 2 ++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/COMPLETED.md b/COMPLETED.md index d44a3f1..acbdc43 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -6,6 +6,38 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM --- +## Phase 11 — Public Site Enhancements + +### 11.A — `/cuts/{id}` album-detail page + +**Landed:** 2026-06-16 on dev. + +- **What:** New public Cut album detail page at `/cuts/{id}`. Composes `ReleaseDetailScaffold` via a generalized `Header` slot (left meta: name, artist, genre, year, Play + Share) and a `BodyContent` slot (right theme-bordered cover image; `TrackNumber`-ordered track list with per-row play). `CutDetailBase` carries the multi-track prerender bridge across the prerender→WASM seam (following the `ReleaseDetailBase` pattern); `CutDetailViewModel` holds the loaded state. Header Play and per-row play wire into the existing single-slot `IPlayerService` (`SelectTrackStreaming` / toggle). A `PlayAlbum` method contains a documented one-line seam for a future swap to `IQueueService.PlayRelease` — queue integration is a deferred follow-up, not live in this wave. Reuses the existing `GetById` release endpoint and the `releaseId`-filtered track page; no new API surface. Track ordinal (`TrackNumber`) was verified already built and consumed correctly — no new schema. +- **Why:** Cuts (Studio releases) had no single-release detail page — `/cuts` cards navigated to `/tracks?album={title}` (a track-cardinal view). This makes the album the primary navigable unit on the public site for Cut releases, completing the per-medium detail page set alongside `/sessions/{id}` and `/mixes/{id}`. +- **Shape:** New `CutDetail.razor` + `CutDetailBase.cs` + `CutDetailViewModel.cs` in `DeepDrftPublic.Client`. Composes `ReleaseDetailScaffold` with `Header` and `BodyContent` render fragments. Track list ordered by `TrackNumber`; per-row play binds to `IPlayerService` (`SelectTrackStreaming` / toggle). `PersistentComponentState` bridge is owned by `CutDetailBase` (keyed `"cut-tracks"`). + +--- + +### 11.F — play-queue `IQueueService` + +**Landed:** 2026-06-16 on dev. + +- **What:** A separate `IQueueService` orchestrating album (ordered multi-track) playback above the single-slot player. Holds an ordered track list, a current index, and `Next()`/`Previous()` skip navigation wired into the player-bar controls (skip-forward gated on `HasNext`, skip-back gated on `HasPrevious`). Auto-advance via a new `IPlayerService.TrackEnded` event (raised only on organic end-of-stream): `OnTrackEnded` advances the queue only when `player.CurrentTrack.Id == queue.Current.Id` — an `Id`-equality cross-advance guard that prevents a superseding direct-play call from accidentally advancing the queue. `Attach(IStreamingPlayerService)` binds the queue to the player (called once by `AudioPlayerProvider`); loading a track list into the queue is a separate concern via `PlayRelease`. No detach-on-direct-Play mechanism. Provider-owned and cascaded — not DI-registered, by design. Surface members: `Items`, `CurrentIndex`, `Current`, `HasNext`, `HasPrevious`, `QueueChanged` event; methods `Attach(IStreamingPlayerService)`, `PlayRelease(IEnumerable tracks, int startIndex = 0)`, `Next()`, `Previous()`, `Enqueue`, `EnqueueRange`, `Clear`. +- **Why:** The player was single-slot only. The Cut album detail page (11.A) needs "play album" — an ordered queue that advances through tracks end-to-end. Absorbs the queue half of Phase 1 §1.3 (the preload half remains deferred). Prerequisite for a future `PlayAlbum` integration in 11.A; also exposes skip controls in the player bar. +- **Shape:** New `IQueueService` interface + `QueueService` implementation in `DeepDrftPublic.Client`. `IPlayerService` gains `TrackEnded` event. Player bar gains skip-forward and skip-back controls bound to `IQueueService.Next()`/`Previous()`, gated on `HasNext`/`HasPrevious`. `Attach(IStreamingPlayerService)` wires the queue to the player without constructor growth; `PlayRelease(IEnumerable, int)` loads an ordered track list and starts playback. + +--- + +### 11.G — release Description schema slice + +**Landed:** 2026-06-16 on dev. + +- **What:** New nullable `ReleaseEntity.Description` column (plain text, max 4000 characters) on the base release table, mirrored in `ReleaseDto.Description`. `TrackConverter` round-trip updated. Write-path plumbing threaded wherever `Genre` is: `UpdateTrackMetadataRequest` + upload form fields + `UnifiedTrackService` + `TrackManager` update path. CMS `AlbumHeaderFields` gains a multiline `MudTextField` for Description input. Detail-page rendering deliberately deferred — Description degrades cleanly (null renders nothing) so schema and render can land in either order. EF migration `20260616035252_AddReleaseDescription` authored; **not yet applied** (Daniel-gated). +- **Why:** Commitment 8 from the Phase 11 spec. No `Description` member existed on `ReleaseEntity` or `ReleaseDto` prior to this wave. A base-release free-text field (uniform across all media) lets admins describe a release context, inspiration, or credits. Lives on the base release, not a per-medium satellite (consistent with Phase 9's open/closed spine). +- **Shape:** `ReleaseEntity.Description` nullable string in `DeepDrftData`. EF `ReleaseConfiguration` adds max-length annotation (4000). `ReleaseDto.Description` nullable string. `TrackConverter` updated to map the field on both read and write paths. `UpdateTrackMetadataRequest` gains `Description` field. Upload form (multipart) gains `description` form field. `AlbumHeaderFields.razor` gains a multiline `MudTextField`. Migration `20260616035252_AddReleaseDescription` authored but not applied. + +--- + ## CMS Grid Refinements ### `CmsAlbumBrowser` special-action column promotion diff --git a/DeepDrftAPI/CLAUDE.md b/DeepDrftAPI/CLAUDE.md index fc94350..840a58d 100644 --- a/DeepDrftAPI/CLAUDE.md +++ b/DeepDrftAPI/CLAUDE.md @@ -150,7 +150,7 @@ Paged metadata list from SQL with optional filtering. Public browser data, same - **Query parameters**: - `page` (int, optional, default 1): 1-based page number. - `pageSize` (int, optional, default 20): tracks per page. - - `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`. + - `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`, `"TrackNumber"`. Defaults to `Id`. - `sortDescending` (bool, optional, default false): sort direction. - `q` (string, optional): search text filter (matches track name / artist). - `album` (string, optional): album title filter. diff --git a/PLAN.md b/PLAN.md index 8a2bc65..dfc402b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -226,6 +226,8 @@ Sequenced as **seven waves**; the critical path is `11.A → 11.B → 11.C`, wit - **11.F — queue model.** `IQueueService` above the single-slot player + one new player `TrackEnded` hook + player-bar skip controls. **Free-floating, can start cold day one.** Gates the Cuts "play album" affordance (11.A header Play). **Preload (§1.3 half b) stays OUT** — design the seam, defer the feature. - **11.G — release Description schema slice.** New `ReleaseEntity.Description` column + EF migration (**Daniel-gated apply**), `ReleaseDto` mirror, `TrackConverter` round-trip, write-path plumbing (`UpdateTrackMetadataRequest` + upload form + the unified services, threaded wherever `Genre` is), CMS `AlbumHeaderFields` multiline input (§3d). **Free-floating, can start cold day one** — the only gate is Daniel's migration go-ahead. The **detail-page render is NOT in this wave**: the Cut text block rides 11.A, the Session/Mix block is a small additive touch to those existing pages. Both degrade cleanly (null Description renders nothing), so render & schema can land in either order. +**Landed:** 11.A (2026-06-16); 11.F (2026-06-16); 11.G (2026-06-16). Migration `20260616035252_AddReleaseDescription` authored but not yet applied (Daniel-gated). Tracks 11.B, 11.C, 11.D, 11.E remain open. + **Dependency shape:** `11.A → 11.B → 11.C`; `11.B → 11.E`; **11.D, 11.F, 11.G parallel** (11.D coordinates with 11.C on `ArchiveView`; 11.F's "play album" is consumed by 11.A; 11.G's Description render rides 11.A + a Session/Mix touch, degrading on null). The cold-start items are **11.A**, **11.F**, and **11.G** — kick 11.A + 11.F off first so "play album" works on first ship of the Cut page; 11.G runs alongside on its own track. **Resolved by Daniel (2026-06-15), kept visible per file convention:** player-bar title → release detail (was OQ1); track ordinal in scope **and already built** (was OQ4, reversed then found done); retire the **whole** track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred); release **Description** field in scope (commitment 8 — a real new column, lands as schema slice 11.G with the render on 11.A + a Session/Mix touch). **Still open (spec §7.2):** `/cuts/{id}` scaffold strategy (generalized `Header` slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate `IQueueService` — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); Description render plain-text vs. markdown (recommend plain text + preserved line breaks for v1) and column max-length (recommend 2000–4000); `/genres` fate (out of scope, flag as adjacent).