Merge dev into p10-w4-popover-knobs (integrate concurrent Phase 11 scaffold changes)

# Conflicts:
#	DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs
This commit is contained in:
daniel-c-harvey
2026-06-16 00:47:58 -04:00
43 changed files with 2170 additions and 106 deletions
+32
View File
@@ -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<TrackDto> 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<TrackDto>, 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
+1 -1
View File
@@ -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.
@@ -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.
@@ -16,6 +16,7 @@ public record UpdateTrackMetadataRequest(
string Artist,
string? Album,
string? Genre,
string? Description,
DateOnly? ReleaseDate,
string? ImagePath = null,
ReleaseType? ReleaseType = null,
+4 -2
View File
@@ -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,
@@ -35,6 +35,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.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");
@@ -0,0 +1,308 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "description",
table: "release",
type: "character varying(4000)",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "description",
table: "release");
}
}
}
@@ -90,6 +90,11 @@ namespace DeepDrftData.Migrations
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
+2
View File
@@ -24,6 +24,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
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<TrackEntity, TrackDto>
Title = dto.Title,
Artist = dto.Artist,
Genre = dto.Genre,
Description = dto.Description,
ReleaseDate = dto.ReleaseDate,
ImagePath = dto.ImagePath,
Medium = dto.Medium,
+1
View File
@@ -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;
@@ -22,6 +22,11 @@
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12">
<MudTextField Value="Description" ValueChanged="@((string v) => DescriptionChanged.InvokeAsync(v))"
T="string" Label="Description" Lines="4" MaxLength="4000"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12" sm="6">
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@@ -79,6 +84,8 @@
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
[Parameter] public string Genre { get; set; } = string.Empty;
[Parameter] public EventCallback<string> GenreChanged { get; set; }
[Parameter] public string Description { get; set; } = string.Empty;
[Parameter] public EventCallback<string> DescriptionChanged { get; set; }
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
@@ -33,6 +33,7 @@
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
@@ -137,6 +138,7 @@
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
@@ -211,6 +213,7 @@
_albumName = albumName;
_artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty;
_description = release?.Description ?? string.Empty;
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
_medium = release?.Medium ?? ReleaseMedium.Cut;
@@ -382,6 +385,7 @@
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
@@ -446,6 +450,7 @@
_artist,
album,
genre,
description,
releaseDate,
imagePathForUpdate,
_releaseType,
@@ -480,6 +485,7 @@
_artist,
album,
genre,
description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
@@ -508,6 +514,7 @@
_artist,
album,
genre,
description,
releaseDate,
linkPath,
_releaseType,
@@ -21,6 +21,7 @@
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
@@ -126,6 +127,7 @@
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
@@ -342,6 +344,7 @@
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
@@ -369,6 +372,7 @@
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath,
_releaseType,
+4 -1
View File
@@ -39,6 +39,7 @@ public class CmsTrackService : ICmsTrackService
string artist,
string? album,
string? genre,
string? description,
string? releaseDate,
string? originalFileName,
long createdByUserId,
@@ -58,6 +59,7 @@ public class CmsTrackService : ICmsTrackService
multipart.Add(new StringContent(artist), "artist");
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
if (!string.IsNullOrWhiteSpace(description)) multipart.Add(new StringContent(description), "description");
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
// Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename.
if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
@@ -371,7 +373,7 @@ public class CmsTrackService : ICmsTrackService
public async Task<Result> 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,
+2 -1
View File
@@ -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
/// </summary>
Task<Result> 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,
+1
View File
@@ -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;
+4
View File
@@ -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;
@@ -21,6 +21,10 @@ else
Fixed="Fixed"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"
HasNext="HasNext"
HasPrevious="HasPrevious"
SkipNext="@SkipNext"
SkipPrevious="@SkipPrevious"
Class="transport-zone"/>
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
@@ -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<bool> 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;
/// <summary>
/// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary>
@@ -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
@@ -2,12 +2,25 @@
@using DeepDrftPublic.Client.Controls
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@if (!Fixed)
{
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
Color="Color.Primary"
Size="Size.Large"
OnClick="@SkipPrevious"
Disabled="!HasPrevious"/>
}
<PlayStateIcon Size="Size.Large"
Color="Color.Primary"
Disabled="!CanPlay"
OnToggle="@TogglePlayPause"/>
@if (!Fixed)
{
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
Color="Color.Primary"
Size="Size.Large"
OnClick="@SkipNext"
Disabled="!HasNext"/>
<MudIconButton Icon="@Icons.Material.Filled.Stop"
Color="Color.Primary"
Size="Size.Large"
@@ -16,4 +16,13 @@ public partial class PlayerControls : ComponentBase
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public required EventCallback TogglePlayPause { get; set; }
[Parameter] public required EventCallback Stop { get; set; }
/// <summary>Whether the queue has a track to skip forward to. Drives the skip-next affordance.</summary>
[Parameter] public bool HasNext { get; set; }
/// <summary>Whether the queue has a track to step back to. Drives the skip-previous affordance.</summary>
[Parameter] public bool HasPrevious { get; set; }
[Parameter] public EventCallback SkipNext { get; set; }
[Parameter] public EventCallback SkipPrevious { get; set; }
}
@@ -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)
{
<MudProgressCircular Color="Color.Tertiary"
@@ -14,5 +14,9 @@ public partial class PlayerTransportZone : ComponentBase
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public EventCallback TogglePlayPause { get; set; }
[Parameter] public EventCallback Stop { get; set; }
[Parameter] public bool HasNext { get; set; }
[Parameter] public bool HasPrevious { get; set; }
[Parameter] public EventCallback SkipNext { get; set; }
[Parameter] public EventCallback SkipPrevious { get; set; }
[Parameter] public string? Class { get; set; }
}
@@ -3,5 +3,7 @@
If instance swapping at runtime is ever needed, change IsFixed to false (adds subscription
overhead on every parent re-render, but allows children to see the new reference). *@
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
@ChildContent
<CascadingValue Value="@(_queueService)" IsFixed="true">
@ChildContent
</CascadingValue>
</CascadingValue>
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required ILogger<StreamingAudioPlayerService> 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);
}
/// <summary>
@@ -38,6 +46,11 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
/// </summary>
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();
@@ -19,20 +19,30 @@
@TopContent
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h3">@Title</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
</div>
@* The header region. A composer that wants the default masthead+play row supplies nothing; one
that needs a different arrangement (e.g. the Cut album's left-meta / right-cover split) supplies
its own Header fragment. Layout variance rides this slot, never a boolean flag (Phase 9 §5.3). *@
@if (Header is not null)
{
@Header
}
else
{
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h3">@Title</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
</div>
@* Play only makes sense once a playable track is resolved. *@
@if (Track is not null)
{
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
</MudStack>
}
</MudStack>
@* Play only makes sense once a playable track is resolved. *@
@if (Track is not null)
{
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
</MudStack>
}
</MudStack>
}
@Hero
@@ -44,7 +54,12 @@
</div>
}
@if (Track is not null)
@* Multi-track body region (the Cut album's track list). Single-track media leave it null. *@
@BodyContent
@* The default share row is bound to the single resolved track. A composer that owns its own share
affordance (the Cut header carries Play + Share inline) suppresses it via ShowShareRow. *@
@if (Track is not null && ShowShareRow)
{
<div class="deepdrft-share-row">
<SharePopover EntryKey="@Track.EntryKey" />
@@ -38,9 +38,24 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// </summary>
[Parameter] public RenderFragment? TopRightAction { get; set; }
/// <summary>
/// Optional replacement for the header region (masthead + play affordance). When null, the
/// scaffold renders its default masthead+play row wired to <see cref="PlayTrack"/>. A composer
/// that needs a different header arrangement (e.g. the Cut album's left-meta / right-cover split
/// with its own Play/Share buttons) supplies this — layout variance rides the slot, never a
/// boolean flag (Phase 9 §5.3).
/// </summary>
[Parameter] public RenderFragment? Header { get; set; }
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
[Parameter] public RenderFragment? Hero { get; set; }
/// <summary>
/// Optional body region rendered below the meta block — the Cut album's multi-track listing.
/// Single-track media leave it null.
/// </summary>
[Parameter] public RenderFragment? BodyContent { get; set; }
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
[Parameter] public RenderFragment? MetaContent { get; set; }
@@ -51,6 +66,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// </summary>
[Parameter] public bool ShowMeta { get; set; } = true;
/// <summary>
/// Gate for the default track-keyed share row at the foot of the scaffold. A composer that owns
/// its own share affordance (the Cut header carries Play + Share inline) sets this false to
/// suppress the duplicate. Defaults to shown.
/// </summary>
[Parameter] public bool ShowShareRow { get; set; } = true;
private async Task PlayTrack()
{
if (Track is null || PlayerService is null) return;
+163
View File
@@ -0,0 +1,163 @@
@page "/cuts/{Id:long}"
@using DeepDrftModels.DTOs
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits CutDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
@if (ViewModel.IsLoading)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
</div>
</div>
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
<div class="d-flex justify-center mt-4">
<MudButton Href="/cuts"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack">
All cuts
</MudButton>
</div>
</div>
</div>
}
else
{
var release = ViewModel.Release;
var hasGenre = release.Genre is not null;
var hasYear = release.ReleaseDate is not null;
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@firstTrack"
BackHref="/cuts"
BackLabel="All cuts"
ShowShareRow="false">
<Header>
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
<div class="cut-detail-header">
<div class="cut-detail-meta">
<MudText Typo="Typo.h3">@release.Title</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@release.Artist</MudText>
@if (hasGenre || hasYear)
{
<div class="cut-detail-subline">
@if (hasGenre)
{
<span class="cut-detail-genre">@release.Genre</span>
}
@if (hasGenre && hasYear)
{
<span class="cut-detail-sep">&middot;</span>
}
@if (hasYear)
{
<span class="cut-detail-year">@release.ReleaseDate!.Value.Year</span>
}
</div>
}
<div class="cut-detail-actions">
@* Header Play starts the album's first track. Wired to the single-slot player
today; the §3.4 queue seam means a future swap to QueueService.PlayRelease
is a one-line change inside PlayAlbum, not a markup edit. Disabled until a
streamable track is resolved. *@
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.PlayArrow"
Disabled="@(firstTrack is null || !RendererInfo.IsInteractive)"
OnClick="@PlayAlbum">
Play
</MudButton>
@if (firstTrack is not null)
{
<SharePopover EntryKey="@firstTrack.EntryKey" />
}
</div>
</div>
<div class="cut-detail-cover">
@if (!string.IsNullOrEmpty(release.ImagePath))
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
}
else
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</MudPaper>
}
</div>
</div>
</Header>
<BodyContent>
<MudDivider Class="cut-detail-divider" />
@if (ViewModel.Tracks.Count == 0)
{
<MudText Typo="Typo.body2" Class="cut-detail-empty">No tracks in this cut yet.</MudText>
}
else
{
<div class="cut-detail-tracklist">
@foreach (var track in ViewModel.Tracks)
{
<div class="cut-detail-track-row">
<span class="cut-detail-track-number">@track.TrackNumber</span>
<div class="cut-detail-track-play">
<PlayStateIcon Track="@track"
Size="Size.Medium"
Color="Color.Secondary"
OnToggle="@(() => PlayTrack(track))" />
</div>
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
</div>
}
</div>
}
</BodyContent>
</ReleaseDetailScaffold>
}
@code {
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// Header Play: start the album's first track. The §3.4 queue seam lives here — swapping this body
// to `Queue.PlayRelease(ViewModel.Tracks)` once IQueueService (track 11.F) lands is a one-line
// change with no other edit to this page. The queue type is not referenced here because it does
// not exist in this worktree.
private Task PlayAlbum()
{
var first = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
return first is null ? Task.CompletedTask : PlayTrack(first);
}
// Row play: toggle if this track is already active, otherwise start a fresh stream. Mirrors the
// scaffold's own PlayTrack wiring (SessionDetail uses the same idiom for its diverged layout).
private async Task PlayTrack(TrackDto track)
{
if (PlayerService is null) return;
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else
{
await PlayerService.SelectTrackStreaming(track);
}
}
}
@@ -0,0 +1,68 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
/// <summary>
/// Load + prerender-bridge logic for the Cut album-detail page (/cuts/{id}). Mirrors
/// <see cref="ReleaseDetailBase"/>'s discipline (id-addressed load in OnParametersSetAsync,
/// PersistentComponentState bridge guarded on id) but carries the multi-track payload (release +
/// ordered track list) the Cut page needs. Kept separate from the single-track base so neither
/// grows a medium conditional — the two release shapes are genuinely different (one track vs many).
/// </summary>
public abstract class CutDetailBase : ComponentBase, IDisposable
{
private const string PersistKey = "cut-detail";
[Parameter] public long Id { get; set; }
[Inject] public required CutDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
// The release id the ViewModel currently holds — tracks param-only navigations (e.g.
// /cuts/5 -> /cuts/8) which reuse this component instance and fire OnParametersSet without
// re-running OnInitialized. Without it the page would keep the prior album's tracks.
private long _loadedId;
private bool _loaded;
protected override void OnInitialized()
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
protected override async Task OnParametersSetAsync()
{
if (_loaded && _loadedId == Id) return;
// Capture the id synchronously before any await so a re-entrant call (rapid navigation or a
// re-render that changes Id while Load is in flight) sees the correct guard state.
_loadedId = Id;
_loaded = true;
// The bridged payload carries the release and its ordered tracks so the interactive pass
// renders identically without a second round-trip. Guard on the id: a payload for a different
// release must not seed this page (stale-bridge bleed across navigation).
if (PersistentState.TryTakeFromJson<BridgedCut>(PersistKey, out var restored)
&& restored?.Release is not null
&& restored.Release.Id == Id)
{
ViewModel.Restore(restored.Release, restored.Tracks);
}
else
{
await ViewModel.Load(Id);
}
}
private Task Persist()
{
if (ViewModel.Release is not null)
PersistentState.PersistAsJson(PersistKey, new BridgedCut(ViewModel.Release, ViewModel.Tracks));
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
}
@@ -43,6 +43,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
/// <inheritdoc />
public event Action? StateChanged;
/// <inheritdoc />
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();
}
@@ -40,6 +40,15 @@ public interface IPlayerService
/// <see cref="OnStateChanged"/> (throttled to ~10/s during streaming).
/// </summary>
event Action? StateChanged;
/// <summary>
/// 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."
/// </summary>
event Action? TrackEnded;
// Control methods
Task InitializeAsync();
@@ -0,0 +1,80 @@
using DeepDrftModels.DTOs;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Orchestrates ordered playback ("what plays next") <em>above</em> the single-slot
/// <see cref="IStreamingPlayerService"/>. 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 <see cref="IStreamingPlayerService.SelectTrackStreaming"/>
/// — it adds no new playback semantics.
///
/// <para>
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/>
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> +
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only
/// adding new ones — so consumers written against today's surface keep working.
/// </para>
///
/// <para>
/// With an empty queue (<see cref="CurrentIndex"/> == -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.
/// </para>
/// </summary>
public interface IQueueService
{
/// <summary>The ordered tracks currently queued. Empty when nothing is enqueued.</summary>
IReadOnlyList<TrackDto> Items { get; }
/// <summary>
/// Index into <see cref="Items"/> of the track the queue considers current, or -1 when the
/// queue is empty. Always a valid index into <see cref="Items"/> when non-negative.
/// </summary>
int CurrentIndex { get; }
/// <summary>The current track, or null when the queue is empty.</summary>
TrackDto? Current { get; }
/// <summary>True when there is a track after <see cref="CurrentIndex"/> to advance to.</summary>
bool HasNext { get; }
/// <summary>True when there is a track before <see cref="CurrentIndex"/> to step back to.</summary>
bool HasPrevious { get; }
/// <summary>
/// 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.
/// </summary>
event Action? QueueChanged;
/// <summary>
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to
/// the end from there. No-op when <paramref name="tracks"/> is empty.
/// </summary>
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
/// <summary>Appends a track to the end of the queue without changing what is currently playing.</summary>
void Enqueue(TrackDto track);
/// <summary>Appends tracks to the end of the queue without changing what is currently playing.</summary>
void EnqueueRange(IEnumerable<TrackDto> tracks);
/// <summary>
/// Advances to the next track and streams it. No-op when <see cref="HasNext"/> is false.
/// </summary>
Task Next();
/// <summary>
/// Steps back to the previous track and streams it. No-op when <see cref="HasPrevious"/> is false.
/// </summary>
Task Previous();
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
void Clear();
}
@@ -0,0 +1,144 @@
using DeepDrftModels.DTOs;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Default <see cref="IQueueService"/>: a single-slot orchestrator over an
/// <see cref="IStreamingPlayerService"/>. Holds the ordered list and current index as pure state,
/// drives playback through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>,
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal.
///
/// <para>
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
/// So the queue is bound to the player via <see cref="Attach"/> (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 <c>IServiceProvider</c>. The queue's
/// own constructor stays parameterless, so the queue logic is unit-testable against a fake player with
/// no container.
/// </para>
/// </summary>
public sealed class QueueService : IQueueService, IDisposable
{
private readonly List<TrackDto> _items = new();
private IStreamingPlayerService? _player;
public IReadOnlyList<TrackDto> 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;
/// <summary>
/// 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; <see cref="Dispose"/> unsubscribes.
/// </summary>
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<TrackDto> tracks, int startIndex = 0)
{
var list = tracks as IReadOnlyList<TrackDto> ?? 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<TrackDto> 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.
//
// 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
// 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;
}
}
}
+1
View File
@@ -26,6 +26,7 @@ public static class Startup
services.AddScoped<ReleaseClient>();
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
services.AddScoped<ReleaseDetailViewModel>();
services.AddScoped<CutDetailViewModel>();
// Mix visualizer controls — scoped so the four slider positions persist across navigation
// within a session and reset on a fresh page load (see MixVisualizerControlState).
@@ -0,0 +1,78 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
namespace DeepDrftPublic.Client.ViewModels;
/// <summary>
/// State for the Cut album-detail page (/cuts/{id}). Unlike <see cref="ReleaseDetailViewModel"/>
/// (which resolves a single playable track for Session/Mix), a Cut is multi-track: it loads the
/// release and the full ordered track list for that release. The list is fetched through the
/// existing releaseId-filtered track page sorted by TrackNumber — the explicit 1-based ordinal
/// (Phase 8) that the public read both sorts on and projects onto TrackDto. Scoped; every flag is
/// reset per <see cref="Load"/> so a reused instance never bleeds across navigations.
/// </summary>
public class CutDetailViewModel
{
private readonly IReleaseDataService _releaseData;
private readonly ITrackDataService _trackData;
// A Cut covers the whole album in one page. Matches the gallery's page-size convention; a single
// album never approaches this ceiling (the API caps PageSize at 100 regardless).
private const int AlbumPageSize = 100;
public ReleaseDto? Release { get; private set; }
public IReadOnlyList<TrackDto> Tracks { get; private set; } = [];
public bool IsLoading { get; private set; } = true;
public bool NotFound { get; private set; }
public CutDetailViewModel(IReleaseDataService releaseData, ITrackDataService trackData)
{
_releaseData = releaseData;
_trackData = trackData;
}
/// <summary>Seed state directly from a bridged prerender payload — no fetch.</summary>
public void Restore(ReleaseDto release, IReadOnlyList<TrackDto> tracks)
{
Release = release;
Tracks = tracks;
NotFound = false;
IsLoading = false;
}
public async Task Load(long releaseId)
{
IsLoading = true;
NotFound = false;
Release = null;
Tracks = [];
try
{
var releaseResult = await _releaseData.GetById(releaseId);
if (releaseResult is not { Success: true, Value: { } release })
{
NotFound = true;
return;
}
Release = release;
// The album's tracks via the releaseId-filtered page — an exact join, not a title string
// (which collides across same-titled releases and breaks on rename). Sorted by TrackNumber
// so rows render in saved order. A Cut with no streamable tracks simply leaves the list
// empty (the page renders the header with no rows).
var trackResult = await _trackData.GetPage(
pageNumber: 1,
pageSize: AlbumPageSize,
sortColumn: "TrackNumber",
releaseId: release.Id);
if (trackResult is { Success: true, Value: { Items: { } items } })
Tracks = items.ToList();
}
finally
{
IsLoading = false;
}
}
}
@@ -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 1brisk 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 0slow 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;
}
@@ -446,3 +446,110 @@ h2, h3, h4, h5, h6,
text-align: center;
}
}
/* =============================================================================
CUT ALBUM DETAIL (/cuts/{id})
Header splits left-meta / right-cover; the cover carries an explicit theme
border (the new visual element vs. the borderless Session/Mix covers).
============================================================================= */
.cut-detail-header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 2rem;
margin: 2rem 0 1.5rem;
}
.cut-detail-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
flex: 1 1 auto;
}
.cut-detail-subline {
display: flex;
align-items: center;
gap: 0.5rem;
opacity: 0.75;
font-family: var(--deepdrft-font-mono);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.cut-detail-sep { opacity: 0.5; }
.cut-detail-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
/* Square cover with a framed theme border — the new visual element this page introduces. */
.cut-detail-cover {
aspect-ratio: 1 / 1;
width: 260px;
flex: 0 0 auto;
overflow: hidden;
border: 3px solid var(--mud-palette-secondary);
box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent);
}
.cut-detail-divider { margin: 1.5rem 0 0.5rem; }
.cut-detail-empty {
opacity: 0.7;
padding: 1rem 0;
}
.cut-detail-tracklist {
display: flex;
flex-direction: column;
}
.cut-detail-track-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
padding: 0.25rem 0;
border-bottom: 1px solid color-mix(in srgb, var(--mud-palette-text-secondary) 12%, transparent);
}
.cut-detail-track-row:last-child { border-bottom: none; }
.cut-detail-track-number {
width: 1.75rem;
text-align: right;
flex: 0 0 auto;
opacity: 0.55;
font-family: var(--deepdrft-font-mono);
font-size: 0.9rem;
}
.cut-detail-track-play { flex: 0 0 auto; }
.cut-detail-track-name {
flex: 1 1 auto;
min-width: 0;
}
/* Stack the header on narrow screens: cover above the meta column. */
@media (max-width: 599px) {
.cut-detail-header {
flex-direction: column-reverse;
align-items: stretch;
gap: 1.25rem;
}
.cut-detail-cover {
width: 100%;
max-width: 320px;
margin: 0 auto;
}
}
@@ -0,0 +1,152 @@
using Data.Data.Repositories;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Models.Common;
namespace DeepDrftTests;
/// <summary>
/// Backs the public read path that the /cuts/{id} album page consumes (Phase 11 §3a, §3.3).
/// <c>CutDetailViewModel.Load</c> fetches an album's tracks through the releaseId-filtered track page
/// sorted by "TrackNumber"; that maps to <see cref="TrackRepository.GetPagedFilteredAsync"/> with a
/// <see cref="TrackFilter.ReleaseId"/> predicate and an <c>OrderBy(t =&gt; t.TrackNumber)</c>
/// expression. These tests exercise that exact query — the join narrowing, the explicit-ordinal
/// ordering (not insertion order), and the projection of TrackNumber onto the DTO the page renders.
///
/// Provider note: runs on the EF in-memory provider, which executes the ReleaseId equality, the
/// ordinal sort, and the count in process — every predicate this path uses (no ILike branch here).
/// Mirrors <see cref="TrackFilterQueryTests"/>.
/// </summary>
[TestFixture]
public class CutDetailTrackOrderingTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown() => _context.Dispose();
private TrackRepository CreateRepository()
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
private static ReleaseEntity Release(string title, string artist)
=> new() { Title = title, Artist = artist };
// A track linked to the given release with an explicit ordinal.
private static TrackEntity Track(string name, int trackNumber, ReleaseEntity? release = null)
=> new()
{
EntryKey = Guid.NewGuid().ToString("N"),
TrackName = name,
TrackNumber = trackNumber,
Release = release,
};
private async Task SeedAsync(params TrackEntity[] tracks)
{
_context.Tracks.AddRange(tracks);
await _context.SaveChangesAsync();
}
// The album page's query: filter to one release, order by the explicit ordinal.
private static PagingParameters<TrackEntity> OrderedByTrackNumber()
=> new() { Page = 1, PageSize = 100, OrderBy = t => t.TrackNumber, IsDescending = false };
// The release-id filter narrows to that album only — a sibling release's tracks never leak in.
[Test]
public async Task ReleaseIdFilter_ReturnsOnlyThatReleasesTracks()
{
var albumA = Release("Album A", "Artist");
var albumB = Release("Album B", "Artist");
await SeedAsync(
Track("A-one", 1, albumA),
Track("A-two", 2, albumA),
Track("B-one", 1, albumB),
Track("Loose", 1));
var repo = CreateRepository();
var result = await repo.GetPagedFilteredAsync(
OrderedByTrackNumber(), new TrackFilter { ReleaseId = albumA.Id });
Assert.That(result.TotalCount, Is.EqualTo(2));
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "A-one", "A-two" }));
}
// The ordering is by the explicit ordinal, not insertion order: tracks seeded out of order
// come back ascending by TrackNumber. This is the guarantee /cuts/{id} relies on for its rows.
[Test]
public async Task OrderByTrackNumber_SortsByExplicitOrdinalNotInsertionOrder()
{
var album = Release("Album", "Artist");
// Insert deliberately scrambled relative to the intended track order.
await SeedAsync(
Track("Third", 3, album),
Track("First", 1, album),
Track("Second", 2, album));
var repo = CreateRepository();
var result = await repo.GetPagedFilteredAsync(
OrderedByTrackNumber(), new TrackFilter { ReleaseId = album.Id });
Assert.That(
result.Items.Select(t => t.TrackName).ToList(),
Is.EqualTo(new[] { "First", "Second", "Third" }),
"rows must order by the explicit TrackNumber ordinal, not the order they were inserted");
Assert.That(
result.Items.Select(t => t.TrackNumber).ToList(),
Is.EqualTo(new[] { 1, 2, 3 }));
}
// The DTO the page renders carries the ordinal — TrackConverter projects TrackNumber onto
// TrackDto, so the row's number label and the saved order survive the entity -> DTO mapping.
[Test]
public async Task TrackConverter_ProjectsTrackNumberOntoDto()
{
var album = Release("Album", "Artist");
await SeedAsync(
Track("First", 1, album),
Track("Second", 2, album));
var repo = CreateRepository();
var result = await repo.GetPagedFilteredAsync(
OrderedByTrackNumber(), new TrackFilter { ReleaseId = album.Id });
var dtos = result.Items.Select(TrackConverter.Convert).ToList();
Assert.That(dtos.Select(d => d.TrackNumber).ToList(), Is.EqualTo(new[] { 1, 2 }));
Assert.That(dtos.Select(d => d.TrackName).ToList(), Is.EqualTo(new[] { "First", "Second" }));
}
// An album with no streamable tracks yields an empty page (no rows, no error) — the page header
// still renders; the track list is simply empty.
[Test]
public async Task ReleaseIdFilter_WithNoTracks_ReturnsEmptyPage()
{
var empty = Release("Empty Album", "Artist");
var other = Release("Other", "Artist");
await SeedAsync(Track("Other-one", 1, other));
// Persist the empty release with no tracks linked to it.
_context.Releases.Add(empty);
await _context.SaveChangesAsync();
var repo = CreateRepository();
var result = await repo.GetPagedFilteredAsync(
OrderedByTrackNumber(), new TrackFilter { ReleaseId = empty.Id });
Assert.That(result.TotalCount, Is.EqualTo(0));
Assert.That(result.Items, Is.Empty);
}
}
+4
View File
@@ -30,6 +30,10 @@
<ItemGroup>
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
<!-- Referenced for the client-side queue orchestrator (QueueService / IQueueService).
The queue is pure domain logic, unit-testable against a fake IStreamingPlayerService
with no browser/JS. -->
<ProjectReference Include="..\DeepDrftPublic.Client\DeepDrftPublic.Client.csproj" />
</ItemGroup>
</Project>
+76
View File
@@ -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]
+488
View File
@@ -0,0 +1,488 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the play-queue orchestrator (<see cref="QueueService"/>). The queue is pure
/// domain logic over the single-slot player, so it is exercised here against a recording fake
/// (<see cref="FakeStreamingPlayer"/>) — 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 <see cref="IPlayerService.TrackEnded"/> signal.
/// </summary>
[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<TrackDto> 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<TrackDto>());
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<TrackDto>
{
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);
});
}
/// <summary>
/// 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.
/// </summary>
[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()
{
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));
}
/// <summary>
/// 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 <see cref="IStreamingPlayerService"/>
/// 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.
/// </summary>
private sealed class FakeStreamingPlayer : IStreamingPlayerService
{
public List<TrackDto> SelectedTracks { get; } = new();
public int StopCount { get; private set; }
public void RaiseTrackEnded() => TrackEnded?.Invoke();
/// <summary>
/// Sets <see cref="CurrentTrack"/> to <paramref name="track"/> without recording a
/// <see cref="SelectedTracks"/> entry. Models a direct single-track play (SessionDetail,
/// StreamNowButton, resume) that overwrites the player state without going through the queue.
/// </summary>
public void SimulateDirectPlay(TrackDto track) => CurrentTrack = track;
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;
}
}
+2
View File
@@ -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 20004000); `/genres` fate (out of scope, flag as adjacent).