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 ## CMS Grid Refinements
### `CmsAlbumBrowser` special-action column promotion ### `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**: - **Query parameters**:
- `page` (int, optional, default 1): 1-based page number. - `page` (int, optional, default 1): 1-based page number.
- `pageSize` (int, optional, default 20): tracks per page. - `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. - `sortDescending` (bool, optional, default false): sort direction.
- `q` (string, optional): search text filter (matches track name / artist). - `q` (string, optional): search text filter (matches track name / artist).
- `album` (string, optional): album title filter. - `album` (string, optional): album title filter.
@@ -189,6 +189,7 @@ public class TrackController : ControllerBase
[FromForm] string? artist, [FromForm] string? artist,
[FromForm] string? album, [FromForm] string? album,
[FromForm] string? genre, [FromForm] string? genre,
[FromForm] string? description,
[FromForm] string? releaseDate, [FromForm] string? releaseDate,
[FromForm] string? originalFileName, [FromForm] string? originalFileName,
[FromForm] long createdByUserId, [FromForm] long createdByUserId,
@@ -283,6 +284,7 @@ public class TrackController : ControllerBase
artist, artist,
string.IsNullOrWhiteSpace(album) ? null : album, string.IsNullOrWhiteSpace(album) ? null : album,
string.IsNullOrWhiteSpace(genre) ? null : genre, string.IsNullOrWhiteSpace(genre) ? null : genre,
string.IsNullOrWhiteSpace(description) ? null : description,
parsedReleaseDate, parsedReleaseDate,
createdByUserId, createdByUserId,
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName, string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
@@ -412,6 +414,7 @@ public class TrackController : ControllerBase
release.Artist = request.Artist; release.Artist = request.Artist;
release.Title = request.Album ?? string.Empty; release.Title = request.Album ?? string.Empty;
release.Genre = request.Genre; release.Genre = request.Genre;
release.Description = request.Description;
release.ReleaseDate = request.ReleaseDate; release.ReleaseDate = request.ReleaseDate;
// ImagePath is tri-state: null = no change, "" = clear, value = set. // ImagePath is tri-state: null = no change, "" = clear, value = set.
@@ -16,6 +16,7 @@ public record UpdateTrackMetadataRequest(
string Artist, string Artist,
string? Album, string? Album,
string? Genre, string? Genre,
string? Description,
DateOnly? ReleaseDate, DateOnly? ReleaseDate,
string? ImagePath = null, string? ImagePath = null,
ReleaseType? ReleaseType = null, ReleaseType? ReleaseType = null,
+4 -2
View File
@@ -57,6 +57,7 @@ public class UnifiedTrackService
string artist, string artist,
string? album, string? album,
string? genre, string? genre,
string? description,
DateOnly? releaseDate, DateOnly? releaseDate,
long createdByUserId, long createdByUserId,
string? originalFileName, 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 // 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 // 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 // a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
// on the release, not the track. // rides on the release, not the track.
long? releaseId = null; long? releaseId = null;
if (!string.IsNullOrWhiteSpace(album)) if (!string.IsNullOrWhiteSpace(album))
{ {
@@ -116,6 +117,7 @@ public class UnifiedTrackService
Title = album, Title = album,
Artist = artist, Artist = artist,
Genre = genre, Genre = genre,
Description = description,
ReleaseDate = releaseDate, ReleaseDate = releaseDate,
ReleaseType = releaseType, ReleaseType = releaseType,
Medium = medium, Medium = medium,
@@ -35,6 +35,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnName("genre"); .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) builder.Property(e => e.ReleaseDate)
.HasColumnName("release_date"); .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") .HasColumnType("bigint")
.HasColumnName("created_by_user_id"); .HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("Genre") b.Property<string>("Genre")
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)") .HasColumnType("character varying(100)")
+2
View File
@@ -24,6 +24,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Title = entity.Title, Title = entity.Title,
Artist = entity.Artist, Artist = entity.Artist,
Genre = entity.Genre, Genre = entity.Genre,
Description = entity.Description,
ReleaseDate = entity.ReleaseDate, ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath, ImagePath = entity.ImagePath,
Medium = entity.Medium, Medium = entity.Medium,
@@ -55,6 +56,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Title = dto.Title, Title = dto.Title,
Artist = dto.Artist, Artist = dto.Artist,
Genre = dto.Genre, Genre = dto.Genre,
Description = dto.Description,
ReleaseDate = dto.ReleaseDate, ReleaseDate = dto.ReleaseDate,
ImagePath = dto.ImagePath, ImagePath = dto.ImagePath,
Medium = dto.Medium, Medium = dto.Medium,
+1
View File
@@ -281,6 +281,7 @@ public class TrackManager
releaseEntity.Title = release.Title; releaseEntity.Title = release.Title;
releaseEntity.Artist = release.Artist; releaseEntity.Artist = release.Artist;
releaseEntity.Genre = release.Genre; releaseEntity.Genre = release.Genre;
releaseEntity.Description = release.Description;
releaseEntity.ReleaseDate = release.ReleaseDate; releaseEntity.ReleaseDate = release.ReleaseDate;
releaseEntity.ImagePath = release.ImagePath; releaseEntity.ImagePath = release.ImagePath;
releaseEntity.Medium = release.Medium; releaseEntity.Medium = release.Medium;
@@ -22,6 +22,11 @@
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
Variant="Variant.Outlined" Disabled="Disabled" /> Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem> </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"> <MudItem xs="12" sm="6">
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false"> <MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3"> <MudStack Spacing="3">
@@ -79,6 +84,8 @@
[Parameter] public EventCallback<string> ArtistChanged { get; set; } [Parameter] public EventCallback<string> ArtistChanged { get; set; }
[Parameter] public string Genre { get; set; } = string.Empty; [Parameter] public string Genre { get; set; } = string.Empty;
[Parameter] public EventCallback<string> GenreChanged { get; set; } [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 string ReleaseDate { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; } [Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; [Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
@@ -33,6 +33,7 @@
<AlbumHeaderFields @bind-AlbumName="_albumName" <AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist" @bind-Artist="_artist"
@bind-Genre="_genre" @bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate" @bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType" @bind-ReleaseType="_releaseType"
Medium="_medium" Medium="_medium"
@@ -137,6 +138,7 @@
private string _albumName = string.Empty; private string _albumName = string.Empty;
private string _artist = string.Empty; private string _artist = string.Empty;
private string _genre = string.Empty; private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty; private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single; private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut; private ReleaseMedium _medium = ReleaseMedium.Cut;
@@ -211,6 +213,7 @@
_albumName = albumName; _albumName = albumName;
_artist = release?.Artist ?? string.Empty; _artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty; _genre = release?.Genre ?? string.Empty;
_description = release?.Description ?? string.Empty;
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty; _releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
_releaseType = release?.ReleaseType ?? ReleaseType.Single; _releaseType = release?.ReleaseType ?? ReleaseType.Single;
_medium = release?.Medium ?? ReleaseMedium.Cut; _medium = release?.Medium ?? ReleaseMedium.Cut;
@@ -382,6 +385,7 @@
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"); : DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName; var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre; 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 — // 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 // no separate Track Name editor is shown. Sync here so changes to the Release Name always
@@ -446,6 +450,7 @@
_artist, _artist,
album, album,
genre, genre,
description,
releaseDate, releaseDate,
imagePathForUpdate, imagePathForUpdate,
_releaseType, _releaseType,
@@ -480,6 +485,7 @@
_artist, _artist,
album, album,
genre, genre,
description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate, string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name, row.WavFile.Name,
createdByUserId, createdByUserId,
@@ -508,6 +514,7 @@
_artist, _artist,
album, album,
genre, genre,
description,
releaseDate, releaseDate,
linkPath, linkPath,
_releaseType, _releaseType,
@@ -21,6 +21,7 @@
<AlbumHeaderFields @bind-AlbumName="_albumName" <AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist" @bind-Artist="_artist"
@bind-Genre="_genre" @bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate" @bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType" @bind-ReleaseType="_releaseType"
Medium="_medium" Medium="_medium"
@@ -126,6 +127,7 @@
private string _albumName = string.Empty; private string _albumName = string.Empty;
private string _artist = string.Empty; private string _artist = string.Empty;
private string _genre = string.Empty; private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty; private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single; private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut; private ReleaseMedium _medium = ReleaseMedium.Cut;
@@ -342,6 +344,7 @@
_artist, _artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName, string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre, string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate, string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name, row.WavFile.Name,
createdByUserId, createdByUserId,
@@ -369,6 +372,7 @@
_artist, _artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName, string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre, string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"), string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath, imgPath,
_releaseType, _releaseType,
+4 -1
View File
@@ -39,6 +39,7 @@ public class CmsTrackService : ICmsTrackService
string artist, string artist,
string? album, string? album,
string? genre, string? genre,
string? description,
string? releaseDate, string? releaseDate,
string? originalFileName, string? originalFileName,
long createdByUserId, long createdByUserId,
@@ -58,6 +59,7 @@ public class CmsTrackService : ICmsTrackService
multipart.Add(new StringContent(artist), "artist"); multipart.Add(new StringContent(artist), "artist");
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album"); if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre"); 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"); 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. // 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"); if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
@@ -371,7 +373,7 @@ public class CmsTrackService : ICmsTrackService
public async Task<Result> UpdateAsync( public async Task<Result> UpdateAsync(
long id, string trackName, string artist, long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate, string? album, string? genre, string? description, DateOnly? releaseDate,
string? imagePath = null, string? imagePath = null,
ReleaseType? releaseType = null, ReleaseType? releaseType = null,
ReleaseMedium? medium = null, ReleaseMedium? medium = null,
@@ -385,6 +387,7 @@ public class CmsTrackService : ICmsTrackService
artist, artist,
album, album,
genre, genre,
description,
releaseDate, releaseDate,
imagePath, imagePath,
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null, releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
+2 -1
View File
@@ -30,6 +30,7 @@ public interface ICmsTrackService
string artist, string artist,
string? album, string? album,
string? genre, string? genre,
string? description,
string? releaseDate, string? releaseDate,
string? originalFileName, string? originalFileName,
long createdByUserId, long createdByUserId,
@@ -85,7 +86,7 @@ public interface ICmsTrackService
/// </summary> /// </summary>
Task<Result> UpdateAsync( Task<Result> UpdateAsync(
long id, string trackName, string artist, long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate, string? album, string? genre, string? description, DateOnly? releaseDate,
string? imagePath = null, string? imagePath = null,
ReleaseType? releaseType = null, ReleaseType? releaseType = null,
ReleaseMedium? medium = null, ReleaseMedium? medium = null,
+1
View File
@@ -12,6 +12,7 @@ public class ReleaseDto : BaseModel
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty; public string Artist { get; set; } = string.Empty;
public string? Genre { get; set; } public string? Genre { get; set; }
public string? Description { get; set; }
public DateOnly? ReleaseDate { get; set; } public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; } public string? ImagePath { get; set; }
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut; 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 Title { get; set; }
public required string Artist { get; set; } public required string Artist { get; set; }
public string? Genre { 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 DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; } public string? ImagePath { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
@@ -21,6 +21,10 @@ else
Fixed="Fixed" Fixed="Fixed"
TogglePlayPause="@TogglePlayPause" TogglePlayPause="@TogglePlayPause"
Stop="@Stop" Stop="@Stop"
HasNext="HasNext"
HasPrevious="HasPrevious"
SkipNext="@SkipNext"
SkipPrevious="@SkipPrevious"
Class="transport-zone"/> Class="transport-zone"/>
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/> <VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
@@ -9,6 +9,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
{ {
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? QueueService { get; set; }
[Parameter] public bool Fixed { get; set; } = false; [Parameter] public bool Fixed { get; set; } = false;
[Parameter] public EventCallback<bool> OnMinimized { get; set; } [Parameter] public EventCallback<bool> OnMinimized { get; set; }
@@ -19,6 +20,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private bool _isSeeking = false; private bool _isSeeking = false;
private double _seekPosition = 0; private double _seekPosition = 0;
private IStreamingPlayerService? _subscribedService; private IStreamingPlayerService? _subscribedService;
private IQueueService? _subscribedQueue;
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's // 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 // 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 double LoadProgress => PlayerService?.LoadProgress ?? 0;
private string? ErrorMessage => PlayerService?.ErrorMessage; 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> /// <summary>
/// Display time - shows seek position while dragging, otherwise current playback time. /// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary> /// </summary>
@@ -76,10 +83,35 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
PlayerService.StateChanged += OnPlayerStateChanged; PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService; _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 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) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
// Only the docked, expanded shape needs a spacer: the Fixed embed is // Only the docked, expanded shape needs a spacer: the Fixed embed is
@@ -2,12 +2,25 @@
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <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" <PlayStateIcon Size="Size.Large"
Color="Color.Primary" Color="Color.Primary"
Disabled="!CanPlay" Disabled="!CanPlay"
OnToggle="@TogglePlayPause"/> OnToggle="@TogglePlayPause"/>
@if (!Fixed) @if (!Fixed)
{ {
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
Color="Color.Primary"
Size="Size.Large"
OnClick="@SkipNext"
Disabled="!HasNext"/>
<MudIconButton Icon="@Icons.Material.Filled.Stop" <MudIconButton Icon="@Icons.Material.Filled.Stop"
Color="Color.Primary" Color="Color.Primary"
Size="Size.Large" Size="Size.Large"
@@ -16,4 +16,13 @@ public partial class PlayerControls : ComponentBase
[Parameter] public bool Fixed { get; set; } = false; [Parameter] public bool Fixed { get; set; } = false;
[Parameter] public required EventCallback TogglePlayPause { get; set; } [Parameter] public required EventCallback TogglePlayPause { get; set; }
[Parameter] public required EventCallback Stop { 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" CanPlay="CanPlay"
Fixed="Fixed" Fixed="Fixed"
TogglePlayPause="TogglePlayPause" TogglePlayPause="TogglePlayPause"
Stop="Stop"/> Stop="Stop"
HasNext="HasNext"
HasPrevious="HasPrevious"
SkipNext="SkipNext"
SkipPrevious="SkipPrevious"/>
@if (IsLoading && !IsStreaming) @if (IsLoading && !IsStreaming)
{ {
<MudProgressCircular Color="Color.Tertiary" <MudProgressCircular Color="Color.Tertiary"
@@ -14,5 +14,9 @@ public partial class PlayerTransportZone : ComponentBase
[Parameter] public bool Fixed { get; set; } = false; [Parameter] public bool Fixed { get; set; } = false;
[Parameter] public EventCallback TogglePlayPause { get; set; } [Parameter] public EventCallback TogglePlayPause { get; set; }
[Parameter] public EventCallback Stop { 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; } [Parameter] public string? Class { get; set; }
} }
@@ -3,5 +3,7 @@
If instance swapping at runtime is ever needed, change IsFixed to false (adds subscription 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). *@ overhead on every parent re-render, but allows children to see the new reference). *@
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true"> <CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
@ChildContent <CascadingValue Value="@(_queueService)" IsFixed="true">
@ChildContent
</CascadingValue>
</CascadingValue> </CascadingValue>
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; } [Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
private IStreamingPlayerService? _audioPlayerService; private IStreamingPlayerService? _audioPlayerService;
private QueueService? _queueService;
[Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public RenderFragment? ChildContent { get; set; }
@@ -29,6 +30,13 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
// Children must not wrap or replace this callback. // Children must not wrap or replace this callback.
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged)); _audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
// OnTrackSelected will be set by individual child components that need it // 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> /// <summary>
@@ -38,6 +46,11 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
/// </summary> /// </summary>
public async ValueTask DisposeAsync() 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) if (_audioPlayerService is IAsyncDisposable disposable)
{ {
await disposable.DisposeAsync(); await disposable.DisposeAsync();
@@ -19,20 +19,30 @@
@TopContent @TopContent
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;"> @* The header region. A composer that wants the default masthead+play row supplies nothing; one
<div class="deepdrft-track-detail-masthead"> that needs a different arrangement (e.g. the Cut album's left-meta / right-cover split) supplies
<MudText Typo="Typo.h3">@Title</MudText> its own Header fragment. Layout variance rides this slot, never a boolean flag (Phase 9 §5.3). *@
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText> @if (Header is not null)
</div> {
@Header
}
else
{
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h3">@Title</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
</div>
@* Play only makes sense once a playable track is resolved. *@ @* Play only makes sense once a playable track is resolved. *@
@if (Track is not null) @if (Track is not null)
{ {
<MudStack Row AlignItems="AlignItems.Center" Spacing="1"> <MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" /> <PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
</MudStack> </MudStack>
} }
</MudStack> </MudStack>
}
@Hero @Hero
@@ -44,7 +54,12 @@
</div> </div>
} }
@if (Track is not null) @* Multi-track body region (the Cut album's track list). Single-track media leave it null. *@
@BodyContent
@* The default share row is bound to the single resolved track. A composer that owns its own share
affordance (the Cut header carries Play + Share inline) suppresses it via ShowShareRow. *@
@if (Track is not null && ShowShareRow)
{ {
<div class="deepdrft-share-row"> <div class="deepdrft-share-row">
<SharePopover EntryKey="@Track.EntryKey" /> <SharePopover EntryKey="@Track.EntryKey" />
@@ -38,9 +38,24 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// </summary> /// </summary>
[Parameter] public RenderFragment? TopRightAction { get; set; } [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> /// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
[Parameter] public RenderFragment? Hero { get; set; } [Parameter] public RenderFragment? Hero { get; set; }
/// <summary>
/// Optional body region rendered below the meta block — the Cut album's multi-track listing.
/// Single-track media leave it null.
/// </summary>
[Parameter] public RenderFragment? BodyContent { get; set; }
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary> /// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
[Parameter] public RenderFragment? MetaContent { get; set; } [Parameter] public RenderFragment? MetaContent { get; set; }
@@ -51,6 +66,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
/// </summary> /// </summary>
[Parameter] public bool ShowMeta { get; set; } = true; [Parameter] public bool ShowMeta { get; set; } = true;
/// <summary>
/// Gate for the default track-keyed share row at the foot of the scaffold. A composer that owns
/// its own share affordance (the Cut header carries Play + Share inline) sets this false to
/// suppress the duplicate. Defaults to shown.
/// </summary>
[Parameter] public bool ShowShareRow { get; set; } = true;
private async Task PlayTrack() private async Task PlayTrack()
{ {
if (Track is null || PlayerService is null) return; if (Track is null || PlayerService is null) return;
+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 /> /// <inheritdoc />
public event Action? StateChanged; public event Action? StateChanged;
/// <inheritdoc />
public event Action? TrackEnded;
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient) protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
{ {
_audioInterop = audioInterop; _audioInterop = audioInterop;
@@ -268,6 +271,12 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
CurrentTime = 0; CurrentTime = 0;
Duration = null; Duration = null;
await NotifyStateChanged(); 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). /// <see cref="OnStateChanged"/> (throttled to ~10/s during streaming).
/// </summary> /// </summary>
event Action? StateChanged; 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 // Control methods
Task InitializeAsync(); 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<ReleaseClient>();
services.AddScoped<IReleaseDataService, ReleaseClientDataService>(); services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
services.AddScoped<ReleaseDetailViewModel>(); services.AddScoped<ReleaseDetailViewModel>();
services.AddScoped<CutDetailViewModel>();
// Mix visualizer controls — scoped so the four slider positions persist across navigation // Mix visualizer controls — scoped so the four slider positions persist across navigation
// within a session and reset on a fresh page load (see MixVisualizerControlState). // within a session and reset on a fresh page load (see MixVisualizerControlState).
@@ -0,0 +1,78 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
namespace DeepDrftPublic.Client.ViewModels;
/// <summary>
/// State for the Cut album-detail page (/cuts/{id}). Unlike <see cref="ReleaseDetailViewModel"/>
/// (which resolves a single playable track for Session/Mix), a Cut is multi-track: it loads the
/// release and the full ordered track list for that release. The list is fetched through the
/// existing releaseId-filtered track page sorted by TrackNumber — the explicit 1-based ordinal
/// (Phase 8) that the public read both sorts on and projects onto TrackDto. Scoped; every flag is
/// reset per <see cref="Load"/> so a reused instance never bleeds across navigations.
/// </summary>
public class CutDetailViewModel
{
private readonly IReleaseDataService _releaseData;
private readonly ITrackDataService _trackData;
// A Cut covers the whole album in one page. Matches the gallery's page-size convention; a single
// album never approaches this ceiling (the API caps PageSize at 100 regardless).
private const int AlbumPageSize = 100;
public ReleaseDto? Release { get; private set; }
public IReadOnlyList<TrackDto> Tracks { get; private set; } = [];
public bool IsLoading { get; private set; } = true;
public bool NotFound { get; private set; }
public CutDetailViewModel(IReleaseDataService releaseData, ITrackDataService trackData)
{
_releaseData = releaseData;
_trackData = trackData;
}
/// <summary>Seed state directly from a bridged prerender payload — no fetch.</summary>
public void Restore(ReleaseDto release, IReadOnlyList<TrackDto> tracks)
{
Release = release;
Tracks = tracks;
NotFound = false;
IsLoading = false;
}
public async Task Load(long releaseId)
{
IsLoading = true;
NotFound = false;
Release = null;
Tracks = [];
try
{
var releaseResult = await _releaseData.GetById(releaseId);
if (releaseResult is not { Success: true, Value: { } release })
{
NotFound = true;
return;
}
Release = release;
// The album's tracks via the releaseId-filtered page — an exact join, not a title string
// (which collides across same-titled releases and breaks on rename). Sorted by TrackNumber
// so rows render in saved order. A Cut with no streamable tracks simply leaves the list
// empty (the page renders the header with no rows).
var trackResult = await _trackData.GetPage(
pageNumber: 1,
pageSize: AlbumPageSize,
sortColumn: "TrackNumber",
releaseId: release.Id);
if (trackResult is { Success: true, Value: { Items: { } items } })
Tracks = items.ToList();
}
finally
{
IsLoading = false;
}
}
}
@@ -73,8 +73,14 @@ export const DEFAULT_COLOR_SHIFT_SPEED = 0.3;
*/ */
const NOW_ANCHOR_FROM_TOP = 0.5; 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 * 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 // 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 // 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. // 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]'; const TAG = '[MixVisualizer]';
function debugLog(...args: unknown[]): void { 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). ────────── // ── 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 // 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: // it onto a PERIOD that the field's time-axis phase cycles over. Reworked range (W3
// ~60 s (barely-perceptible drift) at 0 → ~4 s (briskly morphing) near 1. We never let // rework): the old 60 s slow end made even the default look frozen — Daniel reported the
// the period go infinite, so even at 0 the field still drifts (spec §4b "never static"). // slider "doesn't do anything." Narrowed to ~24 s (a perceptible slow drift) → ~2 s
// Exponential interpolation gives perceptually even slider feel (a log control over // (unmistakably brisk morph), so dragging the slider is obvious end to end. Exponential
// rate): period = 60 * (4/60)^speed. At speed 0 → 60 s, speed 0.3 (default) → ~22 s, // map for perceptually even feel: period = 24 * (2/24)^speed. speed 0 → 24 s, speed 0.3
// speed 1 → 4 s. Phase rate = 2π / period. // (default) → ~12 s, speed 1 → 2 s. Phase rate = 2π / period. Combined with the saturated
const float COLORSHIFT_PERIOD_SLOW = 60.0; // s at slider 0 — slow drift, never frozen // poles below, a full morph cycle now sweeps a visibly different colour, not grey→grey.
const float COLORSHIFT_PERIOD_FAST = 4.0; // s at slider 1brisk morph 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 // 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. // fraction of the half-window. Larger = more liquid coalescence between bars.
const float BUBBLE_SMOOTHMIN_K = 0.18; 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 // 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 // heights) a blob travels over its life before fading + recycling. Bounded so it reads
// as a hypnotic drift, not a particle storm (spec §4e). // as a hypnotic drift, not a particle storm (spec §4e). Reworked so blobs originate AT
const int DETACH_BLOB_COUNT = 7; // the waveform surface (where loudness is) and pinch off from it, rather than spawning in
const float DETACH_RISE_SPAN = 1.25; // window-heights a blob climbs across its life // 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 // Glass: specular sharpness, Fresnel falloff, refraction warp strength. Pure aesthetic
// (spec §4f open item) — these are the dials for "maximum style". // (spec §4f open item) — these are the dials for "maximum style". Pushed up in the W3
const float GLASS_SPECULAR_POWER = 32.0; // higher = tighter hotspot // rework for a stronger, wetter, more obviously-glassy read (Daniel wanted "glassy").
const float GLASS_FRESNEL_POWER = 3.0; // higher = thinner rim glow const float GLASS_SPECULAR_POWER = 48.0; // higher = tighter, harder hotspot
const float GLASS_REFRACT_WARP = 0.06; // field-distortion amount at curved surfaces 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 // 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 // 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); 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). ─────────────── // ── Signed-distance primitives + smooth-min (the metaball machinery). ───────────────
// Box SDF (centred at origin, half-extents b): negative inside, positive outside. // Box SDF (centred at origin, half-extents b): negative inside, positive outside.
float sdBox(vec2 p, vec2 b) { 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 // 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 // half-width; loudness at neighbouring rows lets the metaball smooth-min coalesce
// vertically into a continuous liquid column rather than discrete per-row slabs. // 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 screenYTop = px.y;
float screenX = px.x; 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. // Normalized horizontal coordinate: 0 at centre, ±1 at the ribbon's max half-width.
float xn = (screenX - uResolution.x * 0.5) / maxHalfWidth; 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 --------------------------------------------------------------- // --- ATTACHED SHAPE ---------------------------------------------------------------
// At bubblyness 0: a thin vertical slab per row → reads as the parity rectangular // 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); attached = smin(attached, smin(up, dn, k), k);
} }
// --- DETACH: pinch off rising blobs (§4e) ----------------------------------------- // --- DETACH: bubbles pinch off the surface and rise (§4e rework) ------------------
// Building on the attached field: as detach rises, a set of bounded metaballs lift // Reworked from the old "fixed-column blobs floating in empty space that vibrate" to
// off and climb on the uTimeSeconds clock, fading near the top. We weaken the link // bubbles that EMANATE FROM the waveform: each bubble is born at the ribbon's edge
// to the parent by reducing the smooth-min k as detach→1 (the liquid "neck" thins // (where the loudness is) near the now-line, pinches off, and rises smoothly. Two
// and breaks), and add the free blobs as independent metaball centres. // 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; float field = attached;
if (uDetach > 0.001) { if (uDetach > 0.001) {
// Each blob has a stable per-index identity (its column, size, phase) so the set // Map a vertical screen-pixel distance into the same xn units the SDF circle uses,
// is a calm, repeating drift rather than a random storm. We loop a fixed small // so a "circle of radius r" is actually round on screen. xn divides by maxHalfWidth
// count (DETACH_BLOB_COUNT) — bounded cost, bounded visuals. // (≈ 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++) { for (int i = 0; i < DETACH_BLOB_COUNT; i++) {
float fi = float(i); float fi = float(i);
// Per-blob constants from a hash so blobs differ but are deterministic. // Per-blob identity from a hash — stable over time (no per-frame term), so the
float seed = hash21(vec2(fi, 7.0)); // blob set is a calm repeating drift, never a random storm.
// Spawn column: spread across the ribbon width, biased by loudness presence. float seed = hash21(vec2(fi, 7.0));
float colX = (seed * 2.0 - 1.0) * 0.8; float seed2 = hash21(vec2(fi, 19.0));
// Loudness feeding this blob's column at the now line — a louder mix sheds float side = seed2 < 0.5 ? -1.0 : 1.0; // which edge of the ribbon it peels off
// bigger blobs. playheadFeed is pre-computed once in main() (fragment-invariant).
float feed = playheadFeed; // Life 0→1, looping, smooth and continuous on the wall clock. Per-blob phase
float radius = (0.05 + 0.10 * seed) * (0.4 + 0.6 * feed) * uDetach; // offset so they don't pulse in unison; rise rate scales gently with detach.
// Rise phase: 0→1 over the blob's life, looping. Different phase offset per float rate = (0.05 + 0.04 * seed) * (0.6 + 0.8 * uDetach);
// blob so they don't pulse in unison. Speed scales mildly with detach. float life = fract(uTimeSeconds * rate + seed);
float life = fract(uTimeSeconds * (0.06 + 0.05 * seed) + seed);
// Vertical position: starts near the zero-line, climbs DETACH_RISE_SPAN // Birth time: the mix-time at the now-line, nudged per blob so they're born at
// window-heights upward (screen-up = decreasing screenYTop). Slight sinus // staggered moments. The bubble emanates from the surface AS IT WAS at birth.
// horizontal drift for the lava-lamp wobble. float birthT = uPlayheadSeconds - seed * 0.15;
float riseN = life * DETACH_RISE_SPAN; // in window-heights float birthAmp = sampleAt(birthT);
float blobYTop = nowY - riseN * uResolution.y; // No surface there (silence) → no bubble. This is what ties bubbles to the
float driftX = colX + 0.06 * sin(uTimeSeconds * 0.7 + seed * 6.28); // waveform: they only appear where there was loudness to shed them.
// Blob centre offset into our (xn, yTop) eval frame. driftX is already in xn if (birthAmp < 0.02) continue;
// units (it's a fraction of the ribbon half-width), so it subtracts directly.
vec2 pBlob = vec2(xn - driftX, // Birth column = the bar EDGE at birth (±loudness in xn), so the bubble starts
(screenYTop - blobYTop) / maxHalfWidth); // 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); 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. // Pinch-off neck: while young (low life) and at low detach the bubble stays
float envelope = smoothstep(0.0, 0.12, life) * (1.0 - smoothstep(0.78, 1.0, life)); // linked to the parent surface via a fat smooth-min neck; as it rises (life→1)
blob += (1.0 - envelope) * 0.2; // shrink/erase by pushing the SDF positive // or detach→1 the neck thins toward a hard union, so it reads as separated.
// Link strength: blobs near the bar still smooth-min into it (the neck); float neckK = BUBBLE_SMOOTHMIN_K * (1.0 - life) * (1.0 - uDetach * 0.7);
// 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);
field = smin(field, blob, max(neckK, 0.004)); field = smin(field, blob, max(neckK, 0.004));
} }
} }
@@ -621,25 +754,23 @@ void main() {
return; 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). ── // ── 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 // 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 // (specular, Fresnel, refraction). Central differences cost 4 extra field evals; the
// step is one device-pixel mapped into the field's xn/yTop frame. // 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); vec2 px = vec2(screenX, screenYTop);
float amp; 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 e = 1.0; // 1px central-difference step
float ignore; float ignore;
float dRx = ribbonField(px + vec2(e, 0.0), 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, playheadFeed, ignore); float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore);
float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore); float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore);
float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, 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 // 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. // field y is screen-down. Guard the zero-length case at flat interiors.
vec2 grad = vec2(dRx - dLx, dUy - dDy); vec2 grad = vec2(dRx - dLx, dUy - dDy);
@@ -668,23 +799,33 @@ void main() {
float tHere = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond; float tHere = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond;
float xnAbs = clamp(abs((screenX - w * 0.5) / maxHalfWidth), 0.0, 1.0); 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 // (a) Base field: a strong TIME-DRIVEN sweep plus layered value-noise. The explicit
// [0,1]. Two octaves give organic, non-repeating morph without being busy. // sin(phase) term is what makes the colour-shift slider unmistakable — it sweeps
float base = valueNoise(vec2(tHere * 0.15, phase)); // the whole field navy↔moss once per cycle, so dragging the slider visibly changes
base += 0.5 * valueNoise(vec2(tHere * 0.30 + 11.0, phase * 1.7 + 5.0)); // how fast the field morphs (the old version relied on noise drifting through a
base = clamp(base / 1.5, 0.0, 1.0); // 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 // (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 // 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 // noise keyed to this bar's time so neighbours differ.
// loudness so a loud bar reads more vivid than a quiet one.
float perBar = valueNoise(vec2(tHere * 4.0, phase * 0.5)) - 0.5; // ±0.5 local jitter 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. // VIVID navy↔moss (§4b rework). The poles are mixed in HSL (mixHsl), not linear RGB,
vec3 baseColor = mix(uColorEdge, uColorAccent, fieldMix); // so the path between them stays saturated instead of passing through the muddy grey
// Loudness vivifies: louder bars push toward moss + brighten slightly ("own thing"). // midpoint that made the field "mostly grey". vivify() then lifts saturation + luminance
baseColor = mix(baseColor, uColorAccent, amp * 0.25); // 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. // ── 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. // 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); float curvature = clamp(length(grad) * maxHalfWidth, 0.0, 1.0);
vec2 warp = normal * GLASS_REFRACT_WARP * curvature; vec2 warp = normal * GLASS_REFRACT_WARP * curvature;
float warpMix = clamp(fieldMix + warp.x + warp.y, 0.0, 1.0); 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 glassColor = mix(glassColor, baseColor, 0.5); // blend warped + straight read
// (2) Specular hotspot (Blinn-Phong) — the wet gloss. Sharp highlight where the // (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)); 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 // 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; vec3 lit = glassColor;
lit += sheen * uColorAccent; lit += sheen * vividAccent;
lit = mix(lit, uColorAccent * 1.3, fresnel * 0.6); // rim glows mossy lit = mix(lit, vividAccent * 1.3, fresnel * 0.7); // rim glows vivid moss
lit += spec * vec3(1.0); // specular is white light lit += spec * vec3(1.0); // specular is white light
// Alpha: the backdrop opacity, lifted at the rim (Fresnel) so edges catch light, and // 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. // 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')); : parseColor(readVar(canvas, '--mud-palette-primary', '#17283f'));
const resolved: ResolvedTheme = { accent: moss, edge: navy }; 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; return resolved;
} }
@@ -446,3 +446,110 @@ h2, h3, h4, h5, h6,
text-align: center; text-align: center;
} }
} }
/* =============================================================================
CUT ALBUM DETAIL (/cuts/{id})
Header splits left-meta / right-cover; the cover carries an explicit theme
border (the new visual element vs. the borderless Session/Mix covers).
============================================================================= */
.cut-detail-header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 2rem;
margin: 2rem 0 1.5rem;
}
.cut-detail-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
flex: 1 1 auto;
}
.cut-detail-subline {
display: flex;
align-items: center;
gap: 0.5rem;
opacity: 0.75;
font-family: var(--deepdrft-font-mono);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.cut-detail-sep { opacity: 0.5; }
.cut-detail-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
/* Square cover with a framed theme border — the new visual element this page introduces. */
.cut-detail-cover {
aspect-ratio: 1 / 1;
width: 260px;
flex: 0 0 auto;
overflow: hidden;
border: 3px solid var(--mud-palette-secondary);
box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent);
}
.cut-detail-divider { margin: 1.5rem 0 0.5rem; }
.cut-detail-empty {
opacity: 0.7;
padding: 1rem 0;
}
.cut-detail-tracklist {
display: flex;
flex-direction: column;
}
.cut-detail-track-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
padding: 0.25rem 0;
border-bottom: 1px solid color-mix(in srgb, var(--mud-palette-text-secondary) 12%, transparent);
}
.cut-detail-track-row:last-child { border-bottom: none; }
.cut-detail-track-number {
width: 1.75rem;
text-align: right;
flex: 0 0 auto;
opacity: 0.55;
font-family: var(--deepdrft-font-mono);
font-size: 0.9rem;
}
.cut-detail-track-play { flex: 0 0 auto; }
.cut-detail-track-name {
flex: 1 1 auto;
min-width: 0;
}
/* Stack the header on narrow screens: cover above the meta column. */
@media (max-width: 599px) {
.cut-detail-header {
flex-direction: column-reverse;
align-items: stretch;
gap: 1.25rem;
}
.cut-detail-cover {
width: 100%;
max-width: 320px;
margin: 0 auto;
}
}
@@ -0,0 +1,152 @@
using Data.Data.Repositories;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Models.Common;
namespace DeepDrftTests;
/// <summary>
/// Backs the public read path that the /cuts/{id} album page consumes (Phase 11 §3a, §3.3).
/// <c>CutDetailViewModel.Load</c> fetches an album's tracks through the releaseId-filtered track page
/// sorted by "TrackNumber"; that maps to <see cref="TrackRepository.GetPagedFilteredAsync"/> with a
/// <see cref="TrackFilter.ReleaseId"/> predicate and an <c>OrderBy(t =&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> <ItemGroup>
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" /> <ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.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> </ItemGroup>
</Project> </Project>
+76
View File
@@ -161,6 +161,82 @@ public class MediumWritePathTests
Assert.That(dto.ReleaseType, Is.Null); 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 // 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. // directly to assert the WHERE release_id predicate in isolation.
[Test] [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.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. - **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. **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). **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).