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:
@@ -6,6 +6,38 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — Public Site Enhancements
|
||||
|
||||
### 11.A — `/cuts/{id}` album-detail page
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** New public Cut album detail page at `/cuts/{id}`. Composes `ReleaseDetailScaffold` via a generalized `Header` slot (left meta: name, artist, genre, year, Play + Share) and a `BodyContent` slot (right theme-bordered cover image; `TrackNumber`-ordered track list with per-row play). `CutDetailBase` carries the multi-track prerender bridge across the prerender→WASM seam (following the `ReleaseDetailBase` pattern); `CutDetailViewModel` holds the loaded state. Header Play and per-row play wire into the existing single-slot `IPlayerService` (`SelectTrackStreaming` / toggle). A `PlayAlbum` method contains a documented one-line seam for a future swap to `IQueueService.PlayRelease` — queue integration is a deferred follow-up, not live in this wave. Reuses the existing `GetById` release endpoint and the `releaseId`-filtered track page; no new API surface. Track ordinal (`TrackNumber`) was verified already built and consumed correctly — no new schema.
|
||||
- **Why:** Cuts (Studio releases) had no single-release detail page — `/cuts` cards navigated to `/tracks?album={title}` (a track-cardinal view). This makes the album the primary navigable unit on the public site for Cut releases, completing the per-medium detail page set alongside `/sessions/{id}` and `/mixes/{id}`.
|
||||
- **Shape:** New `CutDetail.razor` + `CutDetailBase.cs` + `CutDetailViewModel.cs` in `DeepDrftPublic.Client`. Composes `ReleaseDetailScaffold` with `Header` and `BodyContent` render fragments. Track list ordered by `TrackNumber`; per-row play binds to `IPlayerService` (`SelectTrackStreaming` / toggle). `PersistentComponentState` bridge is owned by `CutDetailBase` (keyed `"cut-tracks"`).
|
||||
|
||||
---
|
||||
|
||||
### 11.F — play-queue `IQueueService`
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** A separate `IQueueService` orchestrating album (ordered multi-track) playback above the single-slot player. Holds an ordered track list, a current index, and `Next()`/`Previous()` skip navigation wired into the player-bar controls (skip-forward gated on `HasNext`, skip-back gated on `HasPrevious`). Auto-advance via a new `IPlayerService.TrackEnded` event (raised only on organic end-of-stream): `OnTrackEnded` advances the queue only when `player.CurrentTrack.Id == queue.Current.Id` — an `Id`-equality cross-advance guard that prevents a superseding direct-play call from accidentally advancing the queue. `Attach(IStreamingPlayerService)` binds the queue to the player (called once by `AudioPlayerProvider`); loading a track list into the queue is a separate concern via `PlayRelease`. No detach-on-direct-Play mechanism. Provider-owned and cascaded — not DI-registered, by design. Surface members: `Items`, `CurrentIndex`, `Current`, `HasNext`, `HasPrevious`, `QueueChanged` event; methods `Attach(IStreamingPlayerService)`, `PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)`, `Next()`, `Previous()`, `Enqueue`, `EnqueueRange`, `Clear`.
|
||||
- **Why:** The player was single-slot only. The Cut album detail page (11.A) needs "play album" — an ordered queue that advances through tracks end-to-end. Absorbs the queue half of Phase 1 §1.3 (the preload half remains deferred). Prerequisite for a future `PlayAlbum` integration in 11.A; also exposes skip controls in the player bar.
|
||||
- **Shape:** New `IQueueService` interface + `QueueService` implementation in `DeepDrftPublic.Client`. `IPlayerService` gains `TrackEnded` event. Player bar gains skip-forward and skip-back controls bound to `IQueueService.Next()`/`Previous()`, gated on `HasNext`/`HasPrevious`. `Attach(IStreamingPlayerService)` wires the queue to the player without constructor growth; `PlayRelease(IEnumerable<TrackDto>, int)` loads an ordered track list and starts playback.
|
||||
|
||||
---
|
||||
|
||||
### 11.G — release Description schema slice
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** New nullable `ReleaseEntity.Description` column (plain text, max 4000 characters) on the base release table, mirrored in `ReleaseDto.Description`. `TrackConverter` round-trip updated. Write-path plumbing threaded wherever `Genre` is: `UpdateTrackMetadataRequest` + upload form fields + `UnifiedTrackService` + `TrackManager` update path. CMS `AlbumHeaderFields` gains a multiline `MudTextField` for Description input. Detail-page rendering deliberately deferred — Description degrades cleanly (null renders nothing) so schema and render can land in either order. EF migration `20260616035252_AddReleaseDescription` authored; **not yet applied** (Daniel-gated).
|
||||
- **Why:** Commitment 8 from the Phase 11 spec. No `Description` member existed on `ReleaseEntity` or `ReleaseDto` prior to this wave. A base-release free-text field (uniform across all media) lets admins describe a release context, inspiration, or credits. Lives on the base release, not a per-medium satellite (consistent with Phase 9's open/closed spine).
|
||||
- **Shape:** `ReleaseEntity.Description` nullable string in `DeepDrftData`. EF `ReleaseConfiguration` adds max-length annotation (4000). `ReleaseDto.Description` nullable string. `TrackConverter` updated to map the field on both read and write paths. `UpdateTrackMetadataRequest` gains `Description` field. Upload form (multipart) gains `description` form field. `AlbumHeaderFields.razor` gains a multiline `MudTextField`. Migration `20260616035252_AddReleaseDescription` authored but not applied.
|
||||
|
||||
---
|
||||
|
||||
## CMS Grid Refinements
|
||||
|
||||
### `CmsAlbumBrowser` special-action column promotion
|
||||
|
||||
@@ -150,7 +150,7 @@ Paged metadata list from SQL with optional filtering. Public browser data, same
|
||||
- **Query parameters**:
|
||||
- `page` (int, optional, default 1): 1-based page number.
|
||||
- `pageSize` (int, optional, default 20): tracks per page.
|
||||
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`.
|
||||
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`, `"TrackNumber"`. Defaults to `Id`.
|
||||
- `sortDescending` (bool, optional, default false): sort direction.
|
||||
- `q` (string, optional): search text filter (matches track name / artist).
|
||||
- `album` (string, optional): album title filter.
|
||||
|
||||
@@ -189,6 +189,7 @@ public class TrackController : ControllerBase
|
||||
[FromForm] string? artist,
|
||||
[FromForm] string? album,
|
||||
[FromForm] string? genre,
|
||||
[FromForm] string? description,
|
||||
[FromForm] string? releaseDate,
|
||||
[FromForm] string? originalFileName,
|
||||
[FromForm] long createdByUserId,
|
||||
@@ -283,6 +284,7 @@ public class TrackController : ControllerBase
|
||||
artist,
|
||||
string.IsNullOrWhiteSpace(album) ? null : album,
|
||||
string.IsNullOrWhiteSpace(genre) ? null : genre,
|
||||
string.IsNullOrWhiteSpace(description) ? null : description,
|
||||
parsedReleaseDate,
|
||||
createdByUserId,
|
||||
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
|
||||
@@ -412,6 +414,7 @@ public class TrackController : ControllerBase
|
||||
release.Artist = request.Artist;
|
||||
release.Title = request.Album ?? string.Empty;
|
||||
release.Genre = request.Genre;
|
||||
release.Description = request.Description;
|
||||
release.ReleaseDate = request.ReleaseDate;
|
||||
|
||||
// ImagePath is tri-state: null = no change, "" = clear, value = set.
|
||||
|
||||
@@ -16,6 +16,7 @@ public record UpdateTrackMetadataRequest(
|
||||
string Artist,
|
||||
string? Album,
|
||||
string? Genre,
|
||||
string? Description,
|
||||
DateOnly? ReleaseDate,
|
||||
string? ImagePath = null,
|
||||
ReleaseType? ReleaseType = null,
|
||||
|
||||
@@ -57,6 +57,7 @@ public class UnifiedTrackService
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
DateOnly? releaseDate,
|
||||
long createdByUserId,
|
||||
string? originalFileName,
|
||||
@@ -106,8 +107,8 @@ public class UnifiedTrackService
|
||||
|
||||
// Resolve the release FK before persisting the track. An upload with an album lands on the
|
||||
// shared release (created on first sighting); an upload without one stays a loose track with
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides
|
||||
// on the release, not the track.
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
|
||||
// rides on the release, not the track.
|
||||
long? releaseId = null;
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
@@ -116,6 +117,7 @@ public class UnifiedTrackService
|
||||
Title = album,
|
||||
Artist = artist,
|
||||
Genre = genre,
|
||||
Description = description,
|
||||
ReleaseDate = releaseDate,
|
||||
ReleaseType = releaseType,
|
||||
Medium = medium,
|
||||
|
||||
@@ -35,6 +35,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("genre");
|
||||
|
||||
// Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration).
|
||||
builder.Property(e => e.Description)
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnName("description");
|
||||
|
||||
builder.Property(e => e.ReleaseDate)
|
||||
.HasColumnName("release_date");
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260616035252_AddReleaseDescription")]
|
||||
partial class AddReleaseDescription
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseDescription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "description",
|
||||
table: "release",
|
||||
type: "character varying(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "description",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,11 @@ namespace DeepDrftData.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
|
||||
@@ -24,6 +24,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
Title = entity.Title,
|
||||
Artist = entity.Artist,
|
||||
Genre = entity.Genre,
|
||||
Description = entity.Description,
|
||||
ReleaseDate = entity.ReleaseDate,
|
||||
ImagePath = entity.ImagePath,
|
||||
Medium = entity.Medium,
|
||||
@@ -55,6 +56,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
Title = dto.Title,
|
||||
Artist = dto.Artist,
|
||||
Genre = dto.Genre,
|
||||
Description = dto.Description,
|
||||
ReleaseDate = dto.ReleaseDate,
|
||||
ImagePath = dto.ImagePath,
|
||||
Medium = dto.Medium,
|
||||
|
||||
@@ -281,6 +281,7 @@ public class TrackManager
|
||||
releaseEntity.Title = release.Title;
|
||||
releaseEntity.Artist = release.Artist;
|
||||
releaseEntity.Genre = release.Genre;
|
||||
releaseEntity.Description = release.Description;
|
||||
releaseEntity.ReleaseDate = release.ReleaseDate;
|
||||
releaseEntity.ImagePath = release.ImagePath;
|
||||
releaseEntity.Medium = release.Medium;
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudTextField Value="Description" ValueChanged="@((string v) => DescriptionChanged.InvokeAsync(v))"
|
||||
T="string" Label="Description" Lines="4" MaxLength="4000"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@@ -79,6 +84,8 @@
|
||||
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
|
||||
[Parameter] public string Genre { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> GenreChanged { get; set; }
|
||||
[Parameter] public string Description { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> DescriptionChanged { get; set; }
|
||||
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-Description="_description"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
@@ -137,6 +138,7 @@
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
@@ -211,6 +213,7 @@
|
||||
_albumName = albumName;
|
||||
_artist = release?.Artist ?? string.Empty;
|
||||
_genre = release?.Genre ?? string.Empty;
|
||||
_description = release?.Description ?? string.Empty;
|
||||
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
||||
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
|
||||
_medium = release?.Medium ?? ReleaseMedium.Cut;
|
||||
@@ -382,6 +385,7 @@
|
||||
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
|
||||
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
|
||||
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
|
||||
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
|
||||
|
||||
// For single-track media (Session/Mix) the track name is derived from the Release Name —
|
||||
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
|
||||
@@ -446,6 +450,7 @@
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
imagePathForUpdate,
|
||||
_releaseType,
|
||||
@@ -480,6 +485,7 @@
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
@@ -508,6 +514,7 @@
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
linkPath,
|
||||
_releaseType,
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-Description="_description"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
@@ -126,6 +127,7 @@
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
@@ -342,6 +344,7 @@
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_description) ? null : _description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
@@ -369,6 +372,7 @@
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_description) ? null : _description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath,
|
||||
_releaseType,
|
||||
|
||||
@@ -39,6 +39,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
@@ -58,6 +59,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
multipart.Add(new StringContent(artist), "artist");
|
||||
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
|
||||
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
|
||||
if (!string.IsNullOrWhiteSpace(description)) multipart.Add(new StringContent(description), "description");
|
||||
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
|
||||
// Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename.
|
||||
if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
|
||||
@@ -371,7 +373,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
|
||||
public async Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? album, string? genre, string? description, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
ReleaseMedium? medium = null,
|
||||
@@ -385,6 +387,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
imagePath,
|
||||
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
|
||||
|
||||
@@ -30,6 +30,7 @@ public interface ICmsTrackService
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
@@ -85,7 +86,7 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? album, string? genre, string? description, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
ReleaseMedium? medium = null,
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ReleaseDto : BaseModel
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Genre { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
|
||||
@@ -15,6 +15,10 @@ public class ReleaseEntity : BaseEntity, IEntity
|
||||
public required string Title { get; set; }
|
||||
public required string Artist { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
// Free-text prose blurb describing the release. Uniform across media (Cut/Session/Mix), so it
|
||||
// lives on the base table alongside Genre rather than in a per-medium satellite. Plain text,
|
||||
// max 4000 (configured in ReleaseConfiguration); nullable so existing rows migrate as NULL.
|
||||
public string? Description { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
@@ -21,6 +21,10 @@ else
|
||||
Fixed="Fixed"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"
|
||||
HasNext="HasNext"
|
||||
HasPrevious="HasPrevious"
|
||||
SkipNext="@SkipNext"
|
||||
SkipPrevious="@SkipPrevious"
|
||||
Class="transport-zone"/>
|
||||
|
||||
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? QueueService { get; set; }
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
|
||||
[Parameter] public EventCallback<bool> OnMinimized { get; set; }
|
||||
@@ -19,6 +20,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private bool _isSeeking = false;
|
||||
private double _seekPosition = 0;
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private IQueueService? _subscribedQueue;
|
||||
|
||||
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
|
||||
// spacer reserves its space. We mirror this element's live height into a CSS
|
||||
@@ -48,6 +50,11 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private double LoadProgress => PlayerService?.LoadProgress ?? 0;
|
||||
private string? ErrorMessage => PlayerService?.ErrorMessage;
|
||||
|
||||
// Skip affordances reflect live queue state. With no queue (null) or an empty queue both are
|
||||
// false, so the buttons sit disabled and the bar behaves exactly as it did before the queue.
|
||||
private bool HasNext => QueueService?.HasNext ?? false;
|
||||
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||
/// </summary>
|
||||
@@ -76,10 +83,35 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||
_subscribedService = PlayerService;
|
||||
}
|
||||
|
||||
// The queue cascade is also IsFixed, so re-render the skip affordances off its own
|
||||
// change signal — same posture as the player StateChanged subscription above.
|
||||
if (QueueService != null && !ReferenceEquals(QueueService, _subscribedQueue))
|
||||
{
|
||||
if (_subscribedQueue != null)
|
||||
_subscribedQueue.QueueChanged -= OnQueueChanged;
|
||||
|
||||
QueueService.QueueChanged += OnQueueChanged;
|
||||
_subscribedQueue = QueueService;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnQueueChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private async Task SkipNext()
|
||||
{
|
||||
if (QueueService == null) return;
|
||||
await QueueService.Next();
|
||||
}
|
||||
|
||||
private async Task SkipPrevious()
|
||||
{
|
||||
if (QueueService == null) return;
|
||||
await QueueService.Previous();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
// Only the docked, expanded shape needs a spacer: the Fixed embed is
|
||||
|
||||
@@ -2,12 +2,25 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipPrevious"
|
||||
Disabled="!HasPrevious"/>
|
||||
}
|
||||
<PlayStateIcon Size="Size.Large"
|
||||
Color="Color.Primary"
|
||||
Disabled="!CanPlay"
|
||||
OnToggle="@TogglePlayPause"/>
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipNext"
|
||||
Disabled="!HasNext"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
|
||||
@@ -16,4 +16,13 @@ public partial class PlayerControls : ComponentBase
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
[Parameter] public required EventCallback TogglePlayPause { get; set; }
|
||||
[Parameter] public required EventCallback Stop { get; set; }
|
||||
|
||||
/// <summary>Whether the queue has a track to skip forward to. Drives the skip-next affordance.</summary>
|
||||
[Parameter] public bool HasNext { get; set; }
|
||||
|
||||
/// <summary>Whether the queue has a track to step back to. Drives the skip-previous affordance.</summary>
|
||||
[Parameter] public bool HasPrevious { get; set; }
|
||||
|
||||
[Parameter] public EventCallback SkipNext { get; set; }
|
||||
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
CanPlay="CanPlay"
|
||||
Fixed="Fixed"
|
||||
TogglePlayPause="TogglePlayPause"
|
||||
Stop="Stop"/>
|
||||
Stop="Stop"
|
||||
HasNext="HasNext"
|
||||
HasPrevious="HasPrevious"
|
||||
SkipNext="SkipNext"
|
||||
SkipPrevious="SkipPrevious"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
|
||||
@@ -14,5 +14,9 @@ public partial class PlayerTransportZone : ComponentBase
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
[Parameter] public EventCallback TogglePlayPause { get; set; }
|
||||
[Parameter] public EventCallback Stop { get; set; }
|
||||
[Parameter] public bool HasNext { get; set; }
|
||||
[Parameter] public bool HasPrevious { get; set; }
|
||||
[Parameter] public EventCallback SkipNext { get; set; }
|
||||
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||
[Parameter] public string? Class { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
If instance swapping at runtime is ever needed, change IsFixed to false (adds subscription
|
||||
overhead on every parent re-render, but allows children to see the new reference). *@
|
||||
<CascadingValue Value="@(_audioPlayerService)" IsFixed="true">
|
||||
@ChildContent
|
||||
<CascadingValue Value="@(_queueService)" IsFixed="true">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
@@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
||||
|
||||
private IStreamingPlayerService? _audioPlayerService;
|
||||
private QueueService? _queueService;
|
||||
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
@@ -29,6 +30,13 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
// Children must not wrap or replace this callback.
|
||||
_audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged));
|
||||
// OnTrackSelected will be set by individual child components that need it
|
||||
|
||||
// The queue orchestrates above the single-slot player. The player is not DI-registered
|
||||
// (constructed here), so the queue binds to it via Attach rather than constructor injection —
|
||||
// no construction cycle, no IServiceProvider. Cascaded alongside the player so the bar and a
|
||||
// future up-next panel both read it.
|
||||
_queueService = new QueueService();
|
||||
_queueService.Attach(_audioPlayerService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,6 +46,11 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Dispose the queue first so it unsubscribes from the player's TrackEnded before the
|
||||
// player tears down.
|
||||
_queueService?.Dispose();
|
||||
_queueService = null;
|
||||
|
||||
if (_audioPlayerService is IAsyncDisposable disposable)
|
||||
{
|
||||
await disposable.DisposeAsync();
|
||||
|
||||
@@ -19,20 +19,30 @@
|
||||
|
||||
@TopContent
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h3">@Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
|
||||
</div>
|
||||
@* The header region. A composer that wants the default masthead+play row supplies nothing; one
|
||||
that needs a different arrangement (e.g. the Cut album's left-meta / right-cover split) supplies
|
||||
its own Header fragment. Layout variance rides this slot, never a boolean flag (Phase 9 §5.3). *@
|
||||
@if (Header is not null)
|
||||
{
|
||||
@Header
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h3">@Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
|
||||
</div>
|
||||
|
||||
@* Play only makes sense once a playable track is resolved. *@
|
||||
@if (Track is not null)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
@* Play only makes sense once a playable track is resolved. *@
|
||||
@if (Track is not null)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@Hero
|
||||
|
||||
@@ -44,7 +54,12 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Track is not null)
|
||||
@* Multi-track body region (the Cut album's track list). Single-track media leave it null. *@
|
||||
@BodyContent
|
||||
|
||||
@* The default share row is bound to the single resolved track. A composer that owns its own share
|
||||
affordance (the Cut header carries Play + Share inline) suppresses it via ShowShareRow. *@
|
||||
@if (Track is not null && ShowShareRow)
|
||||
{
|
||||
<div class="deepdrft-share-row">
|
||||
<SharePopover EntryKey="@Track.EntryKey" />
|
||||
|
||||
@@ -38,9 +38,24 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? TopRightAction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional replacement for the header region (masthead + play affordance). When null, the
|
||||
/// scaffold renders its default masthead+play row wired to <see cref="PlayTrack"/>. A composer
|
||||
/// that needs a different header arrangement (e.g. the Cut album's left-meta / right-cover split
|
||||
/// with its own Play/Share buttons) supplies this — layout variance rides the slot, never a
|
||||
/// boolean flag (Phase 9 §5.3).
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? Header { get; set; }
|
||||
|
||||
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
|
||||
[Parameter] public RenderFragment? Hero { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional body region rendered below the meta block — the Cut album's multi-track listing.
|
||||
/// Single-track media leave it null.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? BodyContent { get; set; }
|
||||
|
||||
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
|
||||
[Parameter] public RenderFragment? MetaContent { get; set; }
|
||||
|
||||
@@ -51,6 +66,13 @@ public partial class ReleaseDetailScaffold : ComponentBase
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowMeta { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gate for the default track-keyed share row at the foot of the scaffold. A composer that owns
|
||||
/// its own share affordance (the Cut header carries Play + Share inline) sets this false to
|
||||
/// suppress the duplicate. Defaults to shown.
|
||||
/// </summary>
|
||||
[Parameter] public bool ShowShareRow { get; set; } = true;
|
||||
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
if (Track is null || PlayerService is null) return;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
@page "/cuts/{Id:long}"
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits CutDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
||||
<div class="d-flex justify-center mt-4">
|
||||
<MudButton Href="/cuts"
|
||||
Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack">
|
||||
All cuts
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var release = ViewModel.Release;
|
||||
var hasGenre = release.Genre is not null;
|
||||
var hasYear = release.ReleaseDate is not null;
|
||||
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
|
||||
|
||||
<ReleaseDetailScaffold Title="@release.Title"
|
||||
Artist="@release.Artist"
|
||||
Track="@firstTrack"
|
||||
BackHref="/cuts"
|
||||
BackLabel="All cuts"
|
||||
ShowShareRow="false">
|
||||
<Header>
|
||||
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
|
||||
<div class="cut-detail-header">
|
||||
<div class="cut-detail-meta">
|
||||
<MudText Typo="Typo.h3">@release.Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@release.Artist</MudText>
|
||||
|
||||
@if (hasGenre || hasYear)
|
||||
{
|
||||
<div class="cut-detail-subline">
|
||||
@if (hasGenre)
|
||||
{
|
||||
<span class="cut-detail-genre">@release.Genre</span>
|
||||
}
|
||||
@if (hasGenre && hasYear)
|
||||
{
|
||||
<span class="cut-detail-sep">·</span>
|
||||
}
|
||||
@if (hasYear)
|
||||
{
|
||||
<span class="cut-detail-year">@release.ReleaseDate!.Value.Year</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cut-detail-actions">
|
||||
@* Header Play starts the album's first track. Wired to the single-slot player
|
||||
today; the §3.4 queue seam means a future swap to QueueService.PlayRelease
|
||||
is a one-line change inside PlayAlbum, not a markup edit. Disabled until a
|
||||
streamable track is resolved. *@
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.PlayArrow"
|
||||
Disabled="@(firstTrack is null || !RendererInfo.IsInteractive)"
|
||||
OnClick="@PlayAlbum">
|
||||
Play
|
||||
</MudButton>
|
||||
|
||||
@if (firstTrack is not null)
|
||||
{
|
||||
<SharePopover EntryKey="@firstTrack.EntryKey" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cut-detail-cover">
|
||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
|
||||
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
<BodyContent>
|
||||
<MudDivider Class="cut-detail-divider" />
|
||||
@if (ViewModel.Tracks.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="cut-detail-empty">No tracks in this cut yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cut-detail-tracklist">
|
||||
@foreach (var track in ViewModel.Tracks)
|
||||
{
|
||||
<div class="cut-detail-track-row">
|
||||
<span class="cut-detail-track-number">@track.TrackNumber</span>
|
||||
<div class="cut-detail-track-play">
|
||||
<PlayStateIcon Track="@track"
|
||||
Size="Size.Medium"
|
||||
Color="Color.Secondary"
|
||||
OnToggle="@(() => PlayTrack(track))" />
|
||||
</div>
|
||||
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</BodyContent>
|
||||
</ReleaseDetailScaffold>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
// Header Play: start the album's first track. The §3.4 queue seam lives here — swapping this body
|
||||
// to `Queue.PlayRelease(ViewModel.Tracks)` once IQueueService (track 11.F) lands is a one-line
|
||||
// change with no other edit to this page. The queue type is not referenced here because it does
|
||||
// not exist in this worktree.
|
||||
private Task PlayAlbum()
|
||||
{
|
||||
var first = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
|
||||
return first is null ? Task.CompletedTask : PlayTrack(first);
|
||||
}
|
||||
|
||||
// Row play: toggle if this track is already active, otherwise start a fresh stream. Mirrors the
|
||||
// scaffold's own PlayTrack wiring (SessionDetail uses the same idiom for its diverged layout).
|
||||
private async Task PlayTrack(TrackDto track)
|
||||
{
|
||||
if (PlayerService is null) return;
|
||||
|
||||
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
|
||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Load + prerender-bridge logic for the Cut album-detail page (/cuts/{id}). Mirrors
|
||||
/// <see cref="ReleaseDetailBase"/>'s discipline (id-addressed load in OnParametersSetAsync,
|
||||
/// PersistentComponentState bridge guarded on id) but carries the multi-track payload (release +
|
||||
/// ordered track list) the Cut page needs. Kept separate from the single-track base so neither
|
||||
/// grows a medium conditional — the two release shapes are genuinely different (one track vs many).
|
||||
/// </summary>
|
||||
public abstract class CutDetailBase : ComponentBase, IDisposable
|
||||
{
|
||||
private const string PersistKey = "cut-detail";
|
||||
|
||||
[Parameter] public long Id { get; set; }
|
||||
[Inject] public required CutDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
// The release id the ViewModel currently holds — tracks param-only navigations (e.g.
|
||||
// /cuts/5 -> /cuts/8) which reuse this component instance and fire OnParametersSet without
|
||||
// re-running OnInitialized. Without it the page would keep the prior album's tracks.
|
||||
private long _loadedId;
|
||||
private bool _loaded;
|
||||
|
||||
protected override void OnInitialized()
|
||||
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (_loaded && _loadedId == Id) return;
|
||||
|
||||
// Capture the id synchronously before any await so a re-entrant call (rapid navigation or a
|
||||
// re-render that changes Id while Load is in flight) sees the correct guard state.
|
||||
_loadedId = Id;
|
||||
_loaded = true;
|
||||
|
||||
// The bridged payload carries the release and its ordered tracks so the interactive pass
|
||||
// renders identically without a second round-trip. Guard on the id: a payload for a different
|
||||
// release must not seed this page (stale-bridge bleed across navigation).
|
||||
if (PersistentState.TryTakeFromJson<BridgedCut>(PersistKey, out var restored)
|
||||
&& restored?.Release is not null
|
||||
&& restored.Release.Id == Id)
|
||||
{
|
||||
ViewModel.Restore(restored.Release, restored.Tracks);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ViewModel.Load(Id);
|
||||
}
|
||||
}
|
||||
|
||||
private Task Persist()
|
||||
{
|
||||
if (ViewModel.Release is not null)
|
||||
PersistentState.PersistAsJson(PersistKey, new BridgedCut(ViewModel.Release, ViewModel.Tracks));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
|
||||
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
|
||||
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
|
||||
}
|
||||
@@ -43,6 +43,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
/// <inheritdoc />
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Action? TrackEnded;
|
||||
|
||||
protected AudioPlayerService(AudioInteropService audioInterop, TrackMediaClient trackMediaClient)
|
||||
{
|
||||
_audioInterop = audioInterop;
|
||||
@@ -268,6 +271,12 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
CurrentTime = 0;
|
||||
Duration = null;
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Fire AFTER the state notification so any queue orchestrator that advances on this
|
||||
// signal selects the next track against a fully-settled idle state. Raised only on
|
||||
// organic end-of-stream — stop/unload/track-switch go through ResetToIdle, which does
|
||||
// not raise this — so a subscriber can treat it unambiguously as "advance the queue."
|
||||
TrackEnded?.Invoke();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,15 @@ public interface IPlayerService
|
||||
/// <see cref="OnStateChanged"/> (throttled to ~10/s during streaming).
|
||||
/// </summary>
|
||||
event Action? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Raised once when the current track reaches its natural end of playback (the JS
|
||||
/// end-of-stream callback), distinct from a stop/unload/track-switch. This is the single
|
||||
/// hook the play-queue subscribes to in order to auto-advance to the next track. It does
|
||||
/// NOT fire when playback is stopped, the track is switched, or the player is unloaded —
|
||||
/// only on organic completion — so an orchestrator can treat it as "advance the queue."
|
||||
/// </summary>
|
||||
event Action? TrackEnded;
|
||||
|
||||
// Control methods
|
||||
Task InitializeAsync();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates ordered playback ("what plays next") <em>above</em> the single-slot
|
||||
/// <see cref="IStreamingPlayerService"/>. The player stays a single-track device; the queue owns the
|
||||
/// track list, the current position, skip-forward/back, and auto-advance on natural track end. It
|
||||
/// drives playback solely through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>
|
||||
/// — it adds no new playback semantics.
|
||||
///
|
||||
/// <para>
|
||||
/// Extension posture (open/closed): future shuffle, repeat modes, reordering, and persistence are
|
||||
/// expected. They are additive — a shuffle/repeat strategy slots in behind <see cref="Next"/>/
|
||||
/// <see cref="Previous"/> as the "which index is next" decision; reordering mutates <see cref="Items"/>
|
||||
/// and re-emits <see cref="QueueChanged"/>; persistence snapshots/restores <see cref="Items"/> +
|
||||
/// <see cref="CurrentIndex"/>. None of those require changing this interface's existing members, only
|
||||
/// adding new ones — so consumers written against today's surface keep working.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// With an empty queue (<see cref="CurrentIndex"/> == -1) the queue is dormant: it drives nothing and
|
||||
/// auto-advances nothing, so direct single-track play through the player behaves exactly as it did
|
||||
/// before the queue existed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IQueueService
|
||||
{
|
||||
/// <summary>The ordered tracks currently queued. Empty when nothing is enqueued.</summary>
|
||||
IReadOnlyList<TrackDto> Items { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Index into <see cref="Items"/> of the track the queue considers current, or -1 when the
|
||||
/// queue is empty. Always a valid index into <see cref="Items"/> when non-negative.
|
||||
/// </summary>
|
||||
int CurrentIndex { get; }
|
||||
|
||||
/// <summary>The current track, or null when the queue is empty.</summary>
|
||||
TrackDto? Current { get; }
|
||||
|
||||
/// <summary>True when there is a track after <see cref="CurrentIndex"/> to advance to.</summary>
|
||||
bool HasNext { get; }
|
||||
|
||||
/// <summary>True when there is a track before <see cref="CurrentIndex"/> to step back to.</summary>
|
||||
bool HasPrevious { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever the queue's contents or current position change. The player bar subscribes
|
||||
/// to re-render its skip-forward/back affordances. Fires on enqueue, advance, step-back, and clear.
|
||||
/// </summary>
|
||||
event Action? QueueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the queue with <paramref name="tracks"/> (in the order given) and begins streaming
|
||||
/// the track at <paramref name="startIndex"/>. This is the "play album" entry point the Cuts
|
||||
/// detail page consumes: pass the release's tracks in ordinal order. A header Play uses
|
||||
/// <c>startIndex: 0</c>; a mid-album row play passes that row's index so the queue continues to
|
||||
/// the end from there. No-op when <paramref name="tracks"/> is empty.
|
||||
/// </summary>
|
||||
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
|
||||
|
||||
/// <summary>Appends a track to the end of the queue without changing what is currently playing.</summary>
|
||||
void Enqueue(TrackDto track);
|
||||
|
||||
/// <summary>Appends tracks to the end of the queue without changing what is currently playing.</summary>
|
||||
void EnqueueRange(IEnumerable<TrackDto> tracks);
|
||||
|
||||
/// <summary>
|
||||
/// Advances to the next track and streams it. No-op when <see cref="HasNext"/> is false.
|
||||
/// </summary>
|
||||
Task Next();
|
||||
|
||||
/// <summary>
|
||||
/// Steps back to the previous track and streams it. No-op when <see cref="HasPrevious"/> is false.
|
||||
/// </summary>
|
||||
Task Previous();
|
||||
|
||||
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
|
||||
void Clear();
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IQueueService"/>: a single-slot orchestrator over an
|
||||
/// <see cref="IStreamingPlayerService"/>. Holds the ordered list and current index as pure state,
|
||||
/// drives playback through the player's existing <see cref="IStreamingPlayerService.SelectTrackStreaming"/>,
|
||||
/// and auto-advances on the player's <see cref="IPlayerService.TrackEnded"/> signal.
|
||||
///
|
||||
/// <para>
|
||||
/// The player instance is not DI-registered — <c>AudioPlayerProvider</c> constructs and cascades it.
|
||||
/// So the queue is bound to the player via <see cref="Attach"/> (called once by the provider after it
|
||||
/// creates the player) rather than constructor injection. This keeps the player single-slot, avoids a
|
||||
/// construction cycle between provider/player/queue, and needs no <c>IServiceProvider</c>. The queue's
|
||||
/// own constructor stays parameterless, so the queue logic is unit-testable against a fake player with
|
||||
/// no container.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class QueueService : IQueueService, IDisposable
|
||||
{
|
||||
private readonly List<TrackDto> _items = new();
|
||||
private IStreamingPlayerService? _player;
|
||||
|
||||
public IReadOnlyList<TrackDto> Items => _items;
|
||||
|
||||
public int CurrentIndex { get; private set; } = -1;
|
||||
|
||||
public TrackDto? Current =>
|
||||
CurrentIndex >= 0 && CurrentIndex < _items.Count ? _items[CurrentIndex] : null;
|
||||
|
||||
public bool HasNext => CurrentIndex >= 0 && CurrentIndex < _items.Count - 1;
|
||||
|
||||
public bool HasPrevious => CurrentIndex > 0;
|
||||
|
||||
public event Action? QueueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the queue to the player instance the provider owns, and subscribes to its track-ended
|
||||
/// signal so the queue auto-advances. Idempotent and re-bindable: re-attaching detaches the prior
|
||||
/// player first, so the queue never holds a stale subscription after a player swap. Owned by the
|
||||
/// provider's lifecycle; <see cref="Dispose"/> unsubscribes.
|
||||
/// </summary>
|
||||
public void Attach(IStreamingPlayerService player)
|
||||
{
|
||||
if (ReferenceEquals(_player, player)) return;
|
||||
|
||||
if (_player != null)
|
||||
_player.TrackEnded -= OnTrackEnded;
|
||||
|
||||
_player = player;
|
||||
_player.TrackEnded += OnTrackEnded;
|
||||
}
|
||||
|
||||
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
|
||||
{
|
||||
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||
if (list.Count == 0) return;
|
||||
|
||||
var start = Math.Clamp(startIndex, 0, list.Count - 1);
|
||||
|
||||
_items.Clear();
|
||||
_items.AddRange(list);
|
||||
CurrentIndex = start;
|
||||
QueueChanged?.Invoke();
|
||||
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public void Enqueue(TrackDto track)
|
||||
{
|
||||
_items.Add(track);
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void EnqueueRange(IEnumerable<TrackDto> tracks)
|
||||
{
|
||||
var before = _items.Count;
|
||||
_items.AddRange(tracks);
|
||||
if (_items.Count != before)
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public async Task Next()
|
||||
{
|
||||
if (!HasNext) return;
|
||||
CurrentIndex++;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public async Task Previous()
|
||||
{
|
||||
if (!HasPrevious) return;
|
||||
CurrentIndex--;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
if (_items.Count == 0 && CurrentIndex == -1) return;
|
||||
_items.Clear();
|
||||
CurrentIndex = -1;
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
// Advance on organic end-of-stream only. TrackEnded is not raised by stop/unload/track-switch,
|
||||
// so a manual stop or a fresh single-track selection elsewhere never spuriously advances the
|
||||
// queue. When the queue is past its last track, end-of-stream simply stops — nothing to advance.
|
||||
//
|
||||
// Guard: only advance when the track that just ended is the queue's own current item. Call sites
|
||||
// that stream a single track directly (SessionDetail, StreamNowButton, resume from AudioPlayerBar)
|
||||
// overwrite the player's CurrentTrack without touching the queue. If their track reaches natural
|
||||
// end, the player fires TrackEnded — but the queue's Current no longer matches the player's
|
||||
// CurrentTrack, so we must not advance. Id-based equality is used rather than ReferenceEquals
|
||||
// because DTO copies through serialisation are not reference-equal.
|
||||
private void OnTrackEnded()
|
||||
{
|
||||
if (!HasNext) return;
|
||||
if (_player?.CurrentTrack?.Id != Current?.Id) return;
|
||||
// Fire-and-forget is deliberate: TrackEnded is a synchronous event invoked from the player's
|
||||
// end-of-playback callback continuation; we must not block it. Advancing kicks off the next
|
||||
// stream, whose own failures surface through the player's ErrorMessage/state — the queue does
|
||||
// not own playback error handling.
|
||||
_ = Next();
|
||||
}
|
||||
|
||||
private async Task PlayCurrent()
|
||||
{
|
||||
var track = Current;
|
||||
if (track is null || _player is null) return;
|
||||
await _player.SelectTrackStreaming(track);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_player != null)
|
||||
{
|
||||
_player.TrackEnded -= OnTrackEnded;
|
||||
_player = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public static class Startup
|
||||
services.AddScoped<ReleaseClient>();
|
||||
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
|
||||
services.AddScoped<ReleaseDetailViewModel>();
|
||||
services.AddScoped<CutDetailViewModel>();
|
||||
|
||||
// Mix visualizer controls — scoped so the four slider positions persist across navigation
|
||||
// within a session and reset on a fresh page load (see MixVisualizerControlState).
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
|
||||
namespace DeepDrftPublic.Client.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// State for the Cut album-detail page (/cuts/{id}). Unlike <see cref="ReleaseDetailViewModel"/>
|
||||
/// (which resolves a single playable track for Session/Mix), a Cut is multi-track: it loads the
|
||||
/// release and the full ordered track list for that release. The list is fetched through the
|
||||
/// existing releaseId-filtered track page sorted by TrackNumber — the explicit 1-based ordinal
|
||||
/// (Phase 8) that the public read both sorts on and projects onto TrackDto. Scoped; every flag is
|
||||
/// reset per <see cref="Load"/> so a reused instance never bleeds across navigations.
|
||||
/// </summary>
|
||||
public class CutDetailViewModel
|
||||
{
|
||||
private readonly IReleaseDataService _releaseData;
|
||||
private readonly ITrackDataService _trackData;
|
||||
|
||||
// A Cut covers the whole album in one page. Matches the gallery's page-size convention; a single
|
||||
// album never approaches this ceiling (the API caps PageSize at 100 regardless).
|
||||
private const int AlbumPageSize = 100;
|
||||
|
||||
public ReleaseDto? Release { get; private set; }
|
||||
public IReadOnlyList<TrackDto> Tracks { get; private set; } = [];
|
||||
public bool IsLoading { get; private set; } = true;
|
||||
public bool NotFound { get; private set; }
|
||||
|
||||
public CutDetailViewModel(IReleaseDataService releaseData, ITrackDataService trackData)
|
||||
{
|
||||
_releaseData = releaseData;
|
||||
_trackData = trackData;
|
||||
}
|
||||
|
||||
/// <summary>Seed state directly from a bridged prerender payload — no fetch.</summary>
|
||||
public void Restore(ReleaseDto release, IReadOnlyList<TrackDto> tracks)
|
||||
{
|
||||
Release = release;
|
||||
Tracks = tracks;
|
||||
NotFound = false;
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
public async Task Load(long releaseId)
|
||||
{
|
||||
IsLoading = true;
|
||||
NotFound = false;
|
||||
Release = null;
|
||||
Tracks = [];
|
||||
|
||||
try
|
||||
{
|
||||
var releaseResult = await _releaseData.GetById(releaseId);
|
||||
if (releaseResult is not { Success: true, Value: { } release })
|
||||
{
|
||||
NotFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Release = release;
|
||||
|
||||
// The album's tracks via the releaseId-filtered page — an exact join, not a title string
|
||||
// (which collides across same-titled releases and breaks on rename). Sorted by TrackNumber
|
||||
// so rows render in saved order. A Cut with no streamable tracks simply leaves the list
|
||||
// empty (the page renders the header with no rows).
|
||||
var trackResult = await _trackData.GetPage(
|
||||
pageNumber: 1,
|
||||
pageSize: AlbumPageSize,
|
||||
sortColumn: "TrackNumber",
|
||||
releaseId: release.Id);
|
||||
if (trackResult is { Success: true, Value: { Items: { } items } })
|
||||
Tracks = items.ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,14 @@ export const DEFAULT_COLOR_SHIFT_SPEED = 0.3;
|
||||
*/
|
||||
const NOW_ANCHOR_FROM_TOP = 0.5;
|
||||
|
||||
/** Background opacity of the whole ribbon — keeps it a backdrop, not a chart. */
|
||||
const RIBBON_OPACITY = 0.22;
|
||||
/**
|
||||
* Background opacity of the whole ribbon. Raised from the parity 0.22 → 0.55 for the
|
||||
* Wave-3-rework "vivid/glassy" pass: at 0.22 the page (off-white / navy) showed through
|
||||
* ~78% of every pixel and washed the field toward grey. 0.55 lets the saturated navy/moss
|
||||
* read as real colour while still keeping it a translucent glass backdrop, not an opaque
|
||||
* chart. (The rim/Fresnel lift on top pushes edges higher.)
|
||||
*/
|
||||
const RIBBON_OPACITY = 0.55;
|
||||
|
||||
/**
|
||||
* Half-width of the ribbon at full loudness, as a fraction of half the canvas
|
||||
@@ -134,7 +140,10 @@ const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;
|
||||
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
|
||||
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
|
||||
// while the runtime fix is being verified through the browser.
|
||||
const DEBUG = false;
|
||||
// NOTE: ON for this visual-iteration pass (Phase 10 W3 rework). Daniel tests in-browser;
|
||||
// the resolved navy/moss RGB + FPS lines confirm the fixes. Flip back to false once the
|
||||
// look is approved.
|
||||
const DEBUG = true;
|
||||
|
||||
const TAG = '[MixVisualizer]';
|
||||
function debugLog(...args: unknown[]): void {
|
||||
@@ -378,30 +387,55 @@ const float RIBBON_HALF_WIDTH_FRAC = ${RIBBON_HALF_WIDTH_FRAC.toFixed(4)};
|
||||
// ── Wave 3 tuning constants (all in-shader; Daniel tunes by editing here). ──────────
|
||||
|
||||
// Colour-shift speed → cycle period (seconds). The slider is normalized [0,1]; we map
|
||||
// it onto a PERIOD that the field's time-axis phase cycles over. Spec §3a control 4:
|
||||
// ~60 s (barely-perceptible drift) at 0 → ~4 s (briskly morphing) near 1. We never let
|
||||
// the period go infinite, so even at 0 the field still drifts (spec §4b "never static").
|
||||
// Exponential interpolation gives perceptually even slider feel (a log control over
|
||||
// rate): period = 60 * (4/60)^speed. At speed 0 → 60 s, speed 0.3 (default) → ~22 s,
|
||||
// speed 1 → 4 s. Phase rate = 2π / period.
|
||||
const float COLORSHIFT_PERIOD_SLOW = 60.0; // s at slider 0 — slow drift, never frozen
|
||||
const float COLORSHIFT_PERIOD_FAST = 4.0; // s at slider 1 — brisk morph
|
||||
// it onto a PERIOD that the field's time-axis phase cycles over. Reworked range (W3
|
||||
// rework): the old 60 s slow end made even the default look frozen — Daniel reported the
|
||||
// slider "doesn't do anything." Narrowed to ~24 s (a perceptible slow drift) → ~2 s
|
||||
// (unmistakably brisk morph), so dragging the slider is obvious end to end. Exponential
|
||||
// map for perceptually even feel: period = 24 * (2/24)^speed. speed 0 → 24 s, speed 0.3
|
||||
// (default) → ~12 s, speed 1 → 2 s. Phase rate = 2π / period. Combined with the saturated
|
||||
// poles below, a full morph cycle now sweeps a visibly different colour, not grey→grey.
|
||||
const float COLORSHIFT_PERIOD_SLOW = 24.0; // s at slider 0 — slow but perceptible drift
|
||||
const float COLORSHIFT_PERIOD_FAST = 2.0; // s at slider 1 — unmistakably brisk morph
|
||||
|
||||
// Vividness (W3 rework). The raw theme tokens are muted UI colours (navy text / moss
|
||||
// secondary, both dark + low-saturation); a naive RGB lerp between them passes through a
|
||||
// muddy grey midpoint, which is exactly the "mostly grey" Daniel rejected. We mix the
|
||||
// field in HSL instead (hue/sat/lum interpolate independently, so the path between two
|
||||
// saturated colours stays saturated — no grey midpoint), and lift saturation + luminance
|
||||
// of the result so the field reads as rich glassy navy-blue ↔ vivid moss-green. These are
|
||||
// the punch dials.
|
||||
const float VIVID_SATURATION_FLOOR = 0.62; // min saturation of any field pixel [0,1]
|
||||
const float VIVID_SATURATION_BOOST = 0.30; // extra saturation pushed in on top of the lerp
|
||||
const float VIVID_LUMINANCE_LIFT = 0.14; // lifts the dark poles off black so colour reads
|
||||
|
||||
// Bubblyness: how far the metaball field spreads to neighbours at max bulge, as a
|
||||
// fraction of the half-window. Larger = more liquid coalescence between bars.
|
||||
const float BUBBLE_SMOOTHMIN_K = 0.18;
|
||||
|
||||
// Bubbling motion (W3 rework). Bubblyness used to only thicken the ribbon statically.
|
||||
// Now it also drives a time-varying swell of the ribbon surface (a lava-lamp roil): a
|
||||
// low-frequency noise displaces the bar half-width up and down over time, with amplitude
|
||||
// and churn rate growing with uBubblyness. At 0 the displacement is zero (flat parity
|
||||
// bars); rising = an increasingly active, undulating surface.
|
||||
const float BUBBLE_SWELL_AMPLITUDE = 0.35; // max half-width swell (xn units) at bubblyness 1
|
||||
const float BUBBLE_SWELL_RATE = 0.55; // churn speed (rad/s scale) of the swell noise
|
||||
const float BUBBLE_SWELL_FREQ = 2.2; // spatial frequency of the swell along the ribbon
|
||||
|
||||
// Detach: how many independent rising blobs we evaluate, and how far (in window
|
||||
// heights) a blob travels over its life before fading + recycling. Bounded so it reads
|
||||
// as a hypnotic drift, not a particle storm (spec §4e).
|
||||
const int DETACH_BLOB_COUNT = 7;
|
||||
const float DETACH_RISE_SPAN = 1.25; // window-heights a blob climbs across its life
|
||||
// as a hypnotic drift, not a particle storm (spec §4e). Reworked so blobs originate AT
|
||||
// the waveform surface (where loudness is) and pinch off from it, rather than spawning in
|
||||
// empty space — see ribbonField's detach block.
|
||||
const int DETACH_BLOB_COUNT = 6;
|
||||
const float DETACH_RISE_SPAN = 1.15; // window-heights a blob climbs across its life
|
||||
const float DETACH_BLOB_DRIFT = 0.05; // horizontal lava-lamp wobble amplitude (xn units)
|
||||
|
||||
// Glass: specular sharpness, Fresnel falloff, refraction warp strength. Pure aesthetic
|
||||
// (spec §4f open item) — these are the dials for "maximum style".
|
||||
const float GLASS_SPECULAR_POWER = 32.0; // higher = tighter hotspot
|
||||
const float GLASS_FRESNEL_POWER = 3.0; // higher = thinner rim glow
|
||||
const float GLASS_REFRACT_WARP = 0.06; // field-distortion amount at curved surfaces
|
||||
// (spec §4f open item) — these are the dials for "maximum style". Pushed up in the W3
|
||||
// rework for a stronger, wetter, more obviously-glassy read (Daniel wanted "glassy").
|
||||
const float GLASS_SPECULAR_POWER = 48.0; // higher = tighter, harder hotspot
|
||||
const float GLASS_FRESNEL_POWER = 2.2; // lower = broader, more visible rim glow
|
||||
const float GLASS_REFRACT_WARP = 0.10; // field-distortion amount at curved surfaces
|
||||
|
||||
// Fetch one raw sample by its linear index, mapping the 1-D index onto the 2-D
|
||||
// texture grid (col = i mod width, row = i / width). texelFetch ignores filtering
|
||||
@@ -469,6 +503,66 @@ float valueNoise(vec2 p) {
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
// ── HSL conversion (for the VIVID field — see VIVID_* consts). ──────────────────────
|
||||
// Mixing two saturated colours in linear RGB drags the midpoint through grey; mixing in
|
||||
// HSL keeps hue/sat/lum independent so the path between navy and moss stays colourful.
|
||||
// Standard branchless RGB↔HSL. h,s,l ∈ [0,1].
|
||||
vec3 rgb2hsl(vec3 c) {
|
||||
float mx = max(max(c.r, c.g), c.b);
|
||||
float mn = min(min(c.r, c.g), c.b);
|
||||
float l = (mx + mn) * 0.5;
|
||||
float d = mx - mn;
|
||||
float s = 0.0;
|
||||
float h = 0.0;
|
||||
if (d > 1e-5) {
|
||||
s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn);
|
||||
if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
||||
else if (mx == c.g) h = (c.b - c.r) / d + 2.0;
|
||||
else h = (c.r - c.g) / d + 4.0;
|
||||
h /= 6.0;
|
||||
}
|
||||
return vec3(h, s, l);
|
||||
}
|
||||
float hue2rgb(float p, float q, float t) {
|
||||
if (t < 0.0) t += 1.0;
|
||||
if (t > 1.0) t -= 1.0;
|
||||
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
||||
if (t < 1.0 / 2.0) return q;
|
||||
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||
return p;
|
||||
}
|
||||
vec3 hsl2rgb(vec3 hsl) {
|
||||
float h = hsl.x, s = hsl.y, l = hsl.z;
|
||||
if (s < 1e-5) return vec3(l);
|
||||
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
|
||||
float p = 2.0 * l - q;
|
||||
return vec3(hue2rgb(p, q, h + 1.0 / 3.0), hue2rgb(p, q, h), hue2rgb(p, q, h - 1.0 / 3.0));
|
||||
}
|
||||
// Interpolate two RGB colours through HSL, taking the SHORT way around the hue circle so
|
||||
// navy↔moss travels the rich teal/blue arc rather than wrapping through red. Returns the
|
||||
// result back in RGB with no extra vividness applied (the caller adds the punch).
|
||||
vec3 mixHsl(vec3 a, vec3 b, float t) {
|
||||
vec3 ha = rgb2hsl(a);
|
||||
vec3 hb = rgb2hsl(b);
|
||||
float dh = hb.x - ha.x;
|
||||
if (dh > 0.5) dh -= 1.0; // go the short way round the hue wheel
|
||||
if (dh < -0.5) dh += 1.0;
|
||||
float h = fract(ha.x + dh * t);
|
||||
float s = mix(ha.y, hb.y, t);
|
||||
float l = mix(ha.z, hb.z, t);
|
||||
return hsl2rgb(vec3(h, s, l));
|
||||
}
|
||||
// Push a colour toward vivid: raise saturation (with a floor) and lift luminance off
|
||||
// black so the dark theme poles actually read as colour rather than near-grey. amp ∈ [0,1]
|
||||
// (loudness) lifts a loud bar a little further for the "own living thing" read.
|
||||
vec3 vivify(vec3 rgb, float amp) {
|
||||
vec3 hsl = rgb2hsl(rgb);
|
||||
hsl.y = max(hsl.y, VIVID_SATURATION_FLOOR);
|
||||
hsl.y = clamp(hsl.y + VIVID_SATURATION_BOOST + amp * 0.10, 0.0, 1.0);
|
||||
hsl.z = clamp(hsl.z + VIVID_LUMINANCE_LIFT + amp * 0.06, 0.0, 0.92);
|
||||
return hsl2rgb(hsl);
|
||||
}
|
||||
|
||||
// ── Signed-distance primitives + smooth-min (the metaball machinery). ───────────────
|
||||
// Box SDF (centred at origin, half-extents b): negative inside, positive outside.
|
||||
float sdBox(vec2 p, vec2 b) {
|
||||
@@ -502,7 +596,7 @@ float smin(float a, float b, float k) {
|
||||
// edge; y is screen-row time as before. Loudness at this row sets the attached
|
||||
// half-width; loudness at neighbouring rows lets the metaball smooth-min coalesce
|
||||
// vertically into a continuous liquid column rather than discrete per-row slabs.
|
||||
float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth, float playheadFeed, out float ampOut) {
|
||||
float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth, out float ampOut) {
|
||||
float screenYTop = px.y;
|
||||
float screenX = px.x;
|
||||
|
||||
@@ -513,7 +607,20 @@ float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth
|
||||
|
||||
// Normalized horizontal coordinate: 0 at centre, ±1 at the ribbon's max half-width.
|
||||
float xn = (screenX - uResolution.x * 0.5) / maxHalfWidth;
|
||||
float halfWidthN = amp; // amp ∈ [0,1] already, so the box half-extent in xn units IS amp
|
||||
|
||||
// --- BUBBLING MOTION (§4d rework) -------------------------------------------------
|
||||
// Bubblyness now drives a real over-time roil, not just a thicker static ribbon. A
|
||||
// low-frequency noise sampled over (this row's mix-time, the wall clock) swells the
|
||||
// bar's half-width up and down continuously — the surface churns like a lava lamp's.
|
||||
// Amplitude AND churn rate both scale with uBubblyness, so at 0 the term vanishes
|
||||
// (flat parity bars) and rising = an increasingly active, undulating surface. We key
|
||||
// the noise to mix-time (not screen-Y) so the swell travels WITH the audio as it
|
||||
// scrolls, rather than sitting still in screen space. Only applied where there is
|
||||
// loudness (amp gates it) so silence stays flat.
|
||||
float swellNoise = valueNoise(vec2(t * BUBBLE_SWELL_FREQ,
|
||||
uTimeSeconds * BUBBLE_SWELL_RATE)) - 0.5; // ±0.5
|
||||
float swell = swellNoise * BUBBLE_SWELL_AMPLITUDE * uBubblyness * amp * 2.0;
|
||||
float halfWidthN = max(amp + swell, 0.0); // box half-extent in xn units, now animated
|
||||
|
||||
// --- ATTACHED SHAPE ---------------------------------------------------------------
|
||||
// At bubblyness 0: a thin vertical slab per row → reads as the parity rectangular
|
||||
@@ -554,48 +661,74 @@ float ribbonField(vec2 px, float nowY, float pixelsPerSecond, float maxHalfWidth
|
||||
attached = smin(attached, smin(up, dn, k), k);
|
||||
}
|
||||
|
||||
// --- DETACH: pinch off rising blobs (§4e) -----------------------------------------
|
||||
// Building on the attached field: as detach rises, a set of bounded metaballs lift
|
||||
// off and climb on the uTimeSeconds clock, fading near the top. We weaken the link
|
||||
// to the parent by reducing the smooth-min k as detach→1 (the liquid "neck" thins
|
||||
// and breaks), and add the free blobs as independent metaball centres.
|
||||
// --- DETACH: bubbles pinch off the surface and rise (§4e rework) ------------------
|
||||
// Reworked from the old "fixed-column blobs floating in empty space that vibrate" to
|
||||
// bubbles that EMANATE FROM the waveform: each bubble is born at the ribbon's edge
|
||||
// (where the loudness is) near the now-line, pinches off, and rises smoothly. Two
|
||||
// fixes for the rejected version:
|
||||
// 1. ORIGIN AT THE WAVEFORM. A bubble's birth column sits at ±(loudness) — the bar
|
||||
// EDGE at its birth time — not a hash-picked column in empty space. We sample the
|
||||
// datum at the birth time so a bubble only exists where there was actually sound,
|
||||
// and it starts attached to the surface there.
|
||||
// 2. NO VIBRATION. The vertical scale now matches the horizontal (xn) scale via the
|
||||
// screen aspect (yAspect below), so blobs are round, not squashed — the old code
|
||||
// normalised a vertical distance by maxHalfWidth (a HORIZONTAL scale), which
|
||||
// stretched blobs and made the SDF-gradient normal unstable → shimmer. Motion is
|
||||
// a single smooth fract(uTimeSeconds·rate); the only hash use is per-index
|
||||
// identity (time-invariant), so there is no per-frame jitter.
|
||||
float field = attached;
|
||||
if (uDetach > 0.001) {
|
||||
// Each blob has a stable per-index identity (its column, size, phase) so the set
|
||||
// is a calm, repeating drift rather than a random storm. We loop a fixed small
|
||||
// count (DETACH_BLOB_COUNT) — bounded cost, bounded visuals.
|
||||
// Map a vertical screen-pixel distance into the same xn units the SDF circle uses,
|
||||
// so a "circle of radius r" is actually round on screen. xn divides by maxHalfWidth
|
||||
// (≈ half the canvas width); to match, vertical must divide by the same, hence the
|
||||
// 1.0 here keeps both axes in maxHalfWidth units (screenY already in px like screenX).
|
||||
float yToXn = 1.0 / maxHalfWidth;
|
||||
for (int i = 0; i < DETACH_BLOB_COUNT; i++) {
|
||||
float fi = float(i);
|
||||
// Per-blob constants from a hash so blobs differ but are deterministic.
|
||||
float seed = hash21(vec2(fi, 7.0));
|
||||
// Spawn column: spread across the ribbon width, biased by loudness presence.
|
||||
float colX = (seed * 2.0 - 1.0) * 0.8;
|
||||
// Loudness feeding this blob's column at the now line — a louder mix sheds
|
||||
// bigger blobs. playheadFeed is pre-computed once in main() (fragment-invariant).
|
||||
float feed = playheadFeed;
|
||||
float radius = (0.05 + 0.10 * seed) * (0.4 + 0.6 * feed) * uDetach;
|
||||
// Rise phase: 0→1 over the blob's life, looping. Different phase offset per
|
||||
// blob so they don't pulse in unison. Speed scales mildly with detach.
|
||||
float life = fract(uTimeSeconds * (0.06 + 0.05 * seed) + seed);
|
||||
// Vertical position: starts near the zero-line, climbs DETACH_RISE_SPAN
|
||||
// window-heights upward (screen-up = decreasing screenYTop). Slight sinus
|
||||
// horizontal drift for the lava-lamp wobble.
|
||||
float riseN = life * DETACH_RISE_SPAN; // in window-heights
|
||||
float blobYTop = nowY - riseN * uResolution.y;
|
||||
float driftX = colX + 0.06 * sin(uTimeSeconds * 0.7 + seed * 6.28);
|
||||
// Blob centre offset into our (xn, yTop) eval frame. driftX is already in xn
|
||||
// units (it's a fraction of the ribbon half-width), so it subtracts directly.
|
||||
vec2 pBlob = vec2(xn - driftX,
|
||||
(screenYTop - blobYTop) / maxHalfWidth);
|
||||
// Per-blob identity from a hash — stable over time (no per-frame term), so the
|
||||
// blob set is a calm repeating drift, never a random storm.
|
||||
float seed = hash21(vec2(fi, 7.0));
|
||||
float seed2 = hash21(vec2(fi, 19.0));
|
||||
float side = seed2 < 0.5 ? -1.0 : 1.0; // which edge of the ribbon it peels off
|
||||
|
||||
// Life 0→1, looping, smooth and continuous on the wall clock. Per-blob phase
|
||||
// offset so they don't pulse in unison; rise rate scales gently with detach.
|
||||
float rate = (0.05 + 0.04 * seed) * (0.6 + 0.8 * uDetach);
|
||||
float life = fract(uTimeSeconds * rate + seed);
|
||||
|
||||
// Birth time: the mix-time at the now-line, nudged per blob so they're born at
|
||||
// staggered moments. The bubble emanates from the surface AS IT WAS at birth.
|
||||
float birthT = uPlayheadSeconds - seed * 0.15;
|
||||
float birthAmp = sampleAt(birthT);
|
||||
// No surface there (silence) → no bubble. This is what ties bubbles to the
|
||||
// waveform: they only appear where there was loudness to shed them.
|
||||
if (birthAmp < 0.02) continue;
|
||||
|
||||
// Birth column = the bar EDGE at birth (±loudness in xn), so the bubble starts
|
||||
// ON the surface. As it rises it drifts slightly inward/outward (lava wobble).
|
||||
float birthX = side * birthAmp;
|
||||
float driftX = birthX + side * DETACH_BLOB_DRIFT * sin(uTimeSeconds * 0.6 + seed * 6.28);
|
||||
|
||||
// Rise: starts at the now-line (the surface) and climbs upward (screen-up =
|
||||
// decreasing screenYTop), travelling DETACH_RISE_SPAN window-heights over life.
|
||||
float riseN = life * DETACH_RISE_SPAN; // window-heights climbed
|
||||
float blobYTop = nowY - riseN * uResolution.y; // screen Y of the blob centre
|
||||
|
||||
// Radius: bigger from a louder birth surface; grows then shrinks across life so
|
||||
// the bubble swells out of the surface and fades near the top — no hard pop.
|
||||
float envelope = smoothstep(0.0, 0.15, life) * (1.0 - smoothstep(0.80, 1.0, life));
|
||||
float radius = (0.04 + 0.07 * seed) * (0.5 + 0.5 * birthAmp) * uDetach * envelope;
|
||||
if (radius < 1e-4) continue; // fully faded — skip (also avoids a 0-radius SDF)
|
||||
|
||||
// Blob centre in the (xn, xn) eval frame. Both axes now in maxHalfWidth units
|
||||
// (driftX already in xn; vertical px scaled by yToXn) → the circle is round.
|
||||
vec2 pBlob = vec2(xn - driftX, (screenYTop - blobYTop) * yToXn);
|
||||
float blob = sdCircle(pBlob, radius);
|
||||
// Fade the blob in at birth and out near the top (life→1): scale its radius
|
||||
// envelope so it grows, holds, then shrinks away — no hard pop.
|
||||
float envelope = smoothstep(0.0, 0.12, life) * (1.0 - smoothstep(0.78, 1.0, life));
|
||||
blob += (1.0 - envelope) * 0.2; // shrink/erase by pushing the SDF positive
|
||||
// Link strength: blobs near the bar still smooth-min into it (the neck);
|
||||
// higher detach thins the neck. Free-risen blobs (high life) merge with the
|
||||
// overall field weakly so they read as separate.
|
||||
float neckK = BUBBLE_SMOOTHMIN_K * (1.0 - uDetach) * (1.0 - life);
|
||||
|
||||
// Pinch-off neck: while young (low life) and at low detach the bubble stays
|
||||
// linked to the parent surface via a fat smooth-min neck; as it rises (life→1)
|
||||
// or detach→1 the neck thins toward a hard union, so it reads as separated.
|
||||
float neckK = BUBBLE_SMOOTHMIN_K * (1.0 - life) * (1.0 - uDetach * 0.7);
|
||||
field = smin(field, blob, max(neckK, 0.004));
|
||||
}
|
||||
}
|
||||
@@ -621,25 +754,23 @@ void main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hoist the playhead-feed tap: sampleAt(uPlayheadSeconds) is fragment-invariant
|
||||
// (depends only on a uniform) and would otherwise run inside the 7-iteration detach
|
||||
// blob loop × 5 ribbonField calls = up to 35× per pixel. Compute it once here.
|
||||
float playheadFeed = sampleAt(uPlayheadSeconds);
|
||||
|
||||
// ── EFFECT 2+3 geometry: evaluate the liquid SDF + its gradient (surface normal). ──
|
||||
// The gradient of the SDF is the outward surface normal — we need it for the glass
|
||||
// (specular, Fresnel, refraction). Central differences cost 4 extra field evals; the
|
||||
// step is one device-pixel mapped into the field's xn/yTop frame.
|
||||
// (The old playhead-feed hoist was removed in the W3 rework: detach now samples a
|
||||
// per-blob birth-time loudness inside the loop, so there is no single shared tap to
|
||||
// lift out. The taps remain uniform-only expressions, the same order of cost as before.)
|
||||
vec2 px = vec2(screenX, screenYTop);
|
||||
float amp;
|
||||
float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, amp);
|
||||
float d = ribbonField(px, nowY, pixelsPerSecond, maxHalfWidth, amp);
|
||||
|
||||
float e = 1.0; // 1px central-difference step
|
||||
float ignore;
|
||||
float dRx = ribbonField(px + vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore);
|
||||
float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore);
|
||||
float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore);
|
||||
float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, playheadFeed, ignore);
|
||||
float dRx = ribbonField(px + vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore);
|
||||
float dLx = ribbonField(px - vec2(e, 0.0), nowY, pixelsPerSecond, maxHalfWidth, ignore);
|
||||
float dUy = ribbonField(px + vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore);
|
||||
float dDy = ribbonField(px - vec2(0.0, e), nowY, pixelsPerSecond, maxHalfWidth, ignore);
|
||||
// Surface normal in screen space (points OUT of the liquid). y flipped because our
|
||||
// field y is screen-down. Guard the zero-length case at flat interiors.
|
||||
vec2 grad = vec2(dRx - dLx, dUy - dDy);
|
||||
@@ -668,23 +799,33 @@ void main() {
|
||||
float tHere = uPlayheadSeconds + (screenYTop - nowY) / pixelsPerSecond;
|
||||
float xnAbs = clamp(abs((screenX - w * 0.5) / maxHalfWidth), 0.0, 1.0);
|
||||
|
||||
// (a) Base field: layered value-noise flowing in time → smooth navy↔moss blend in
|
||||
// [0,1]. Two octaves give organic, non-repeating morph without being busy.
|
||||
float base = valueNoise(vec2(tHere * 0.15, phase));
|
||||
base += 0.5 * valueNoise(vec2(tHere * 0.30 + 11.0, phase * 1.7 + 5.0));
|
||||
base = clamp(base / 1.5, 0.0, 1.0);
|
||||
// (a) Base field: a strong TIME-DRIVEN sweep plus layered value-noise. The explicit
|
||||
// sin(phase) term is what makes the colour-shift slider unmistakable — it sweeps
|
||||
// the whole field navy↔moss once per cycle, so dragging the slider visibly changes
|
||||
// how fast the field morphs (the old version relied on noise drifting through a
|
||||
// near-grey lerp, so the morph was invisible — Daniel's "slider does nothing").
|
||||
// The noise rides on top for organic, non-repeating variation across the window.
|
||||
float sweep = 0.5 + 0.5 * sin(phase); // 0→1, one cycle per period
|
||||
float drift = valueNoise(vec2(tHere * 0.15 + phase * 0.5, phase));
|
||||
drift += 0.5 * valueNoise(vec2(tHere * 0.30 + 11.0, phase * 1.7 + 5.0));
|
||||
drift = clamp(drift / 1.5, 0.0, 1.0);
|
||||
float base = clamp(sweep * 0.6 + drift * 0.4, 0.0, 1.0); // time-sweep dominant
|
||||
|
||||
// (b) Along-bar: blend more toward MOSS at the peak, NAVY near the zero-line — gives
|
||||
// each bar internal structure (spec §4b axis 1). Per-bar liveness: perturb by a
|
||||
// noise keyed to this bar's time so neighbours differ, and lift saturation with
|
||||
// loudness so a loud bar reads more vivid than a quiet one.
|
||||
// noise keyed to this bar's time so neighbours differ.
|
||||
float perBar = valueNoise(vec2(tHere * 4.0, phase * 0.5)) - 0.5; // ±0.5 local jitter
|
||||
float fieldMix = clamp(base * 0.55 + xnAbs * 0.45 + perBar * 0.20, 0.0, 1.0);
|
||||
float fieldMix = clamp(base * 0.55 + xnAbs * 0.30 + perBar * 0.20 + amp * 0.15, 0.0, 1.0);
|
||||
|
||||
// accent = MOSS, edge = NAVY. Peak/lively → moss; zero-line/calm → navy.
|
||||
vec3 baseColor = mix(uColorEdge, uColorAccent, fieldMix);
|
||||
// Loudness vivifies: louder bars push toward moss + brighten slightly ("own thing").
|
||||
baseColor = mix(baseColor, uColorAccent, amp * 0.25);
|
||||
// VIVID navy↔moss (§4b rework). The poles are mixed in HSL (mixHsl), not linear RGB,
|
||||
// so the path between them stays saturated instead of passing through the muddy grey
|
||||
// midpoint that made the field "mostly grey". vivify() then lifts saturation + luminance
|
||||
// off the dark UI tokens so it reads as rich glassy navy ↔ vivid moss. accent = MOSS
|
||||
// (peak/lively), edge = NAVY (zero-line/calm).
|
||||
vec3 baseColor = vivify(mixHsl(uColorEdge, uColorAccent, fieldMix), amp);
|
||||
// Pre-vivified accent for the glass rim/sheen below, so those highlights are vivid moss
|
||||
// rather than the dull raw token (the rim is the strongest glass cue — keep it punchy).
|
||||
vec3 vividAccent = vivify(uColorAccent, 1.0);
|
||||
|
||||
// ── EFFECT 4: glass (§4f) — specular + Fresnel + frosted + refraction, all in-shader.
|
||||
// Fixed virtual light from the upper-left; view direction is straight at the screen.
|
||||
@@ -700,7 +841,9 @@ void main() {
|
||||
float curvature = clamp(length(grad) * maxHalfWidth, 0.0, 1.0);
|
||||
vec2 warp = normal * GLASS_REFRACT_WARP * curvature;
|
||||
float warpMix = clamp(fieldMix + warp.x + warp.y, 0.0, 1.0);
|
||||
vec3 glassColor = mix(uColorEdge, uColorAccent, warpMix);
|
||||
// Warped read uses the same VIVID HSL mix as the straight read, so refraction bends a
|
||||
// saturated colour through the lens rather than revealing the dull raw lerp.
|
||||
vec3 glassColor = vivify(mixHsl(uColorEdge, uColorAccent, warpMix), amp);
|
||||
glassColor = mix(glassColor, baseColor, 0.5); // blend warped + straight read
|
||||
|
||||
// (2) Specular hotspot (Blinn-Phong) — the wet gloss. Sharp highlight where the
|
||||
@@ -717,11 +860,12 @@ void main() {
|
||||
float frost = 0.85 + 0.15 * valueNoise(vec2(screenX * 0.05, screenYTop * 0.05));
|
||||
|
||||
// Compose the lit glass colour: field base + warped refraction, lifted by sheen and
|
||||
// a Fresnel rim toward the moss accent, plus a white-hot specular dot.
|
||||
// a Fresnel rim toward the VIVID moss accent, plus a white-hot specular dot. Using the
|
||||
// vivified accent (not the dull raw token) keeps the glass cues punchy and glassy.
|
||||
vec3 lit = glassColor;
|
||||
lit += sheen * uColorAccent;
|
||||
lit = mix(lit, uColorAccent * 1.3, fresnel * 0.6); // rim glows mossy
|
||||
lit += spec * vec3(1.0); // specular is white light
|
||||
lit += sheen * vividAccent;
|
||||
lit = mix(lit, vividAccent * 1.3, fresnel * 0.7); // rim glows vivid moss
|
||||
lit += spec * vec3(1.0); // specular is white light
|
||||
|
||||
// Alpha: the backdrop opacity, lifted at the rim (Fresnel) so edges catch light, and
|
||||
// softened by the frost. Pre-multiplied output for the ONE/ONE_MINUS_SRC_ALPHA blend.
|
||||
@@ -937,7 +1081,13 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
: parseColor(readVar(canvas, '--mud-palette-primary', '#17283f'));
|
||||
|
||||
const resolved: ResolvedTheme = { accent: moss, edge: navy };
|
||||
debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — moss=[${moss.map((c) => c.toFixed(2)).join(', ')}], navy=[${navy.map((c) => c.toFixed(2)).join(', ')}].`);
|
||||
// Report BOTH poles the shader will actually use, as 0-255 RGB + relative luminance.
|
||||
// This is the line Daniel watches to confirm the "grey" cause: if the poles are dull
|
||||
// here (low luminance / low spread) the fix is the in-shader vivify(); if they look
|
||||
// saturated here the muddying was the old linear-RGB midpoint lerp (now HSL).
|
||||
const fmt = (c: [number, number, number]) =>
|
||||
`rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}) lum=${luminance(c).toFixed(2)}`;
|
||||
debugLog(`theme resolved (${isDark ? 'dark' : 'light'}) — MOSS(accent)=${fmt(moss)} NAVY(edge)=${fmt(navy)}.`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
|
||||
@@ -446,3 +446,110 @@ h2, h3, h4, h5, h6,
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
CUT ALBUM DETAIL (/cuts/{id})
|
||||
Header splits left-meta / right-cover; the cover carries an explicit theme
|
||||
border (the new visual element vs. the borderless Session/Mix covers).
|
||||
============================================================================= */
|
||||
|
||||
.cut-detail-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin: 2rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.cut-detail-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cut-detail-subline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
opacity: 0.75;
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cut-detail-sep { opacity: 0.5; }
|
||||
|
||||
.cut-detail-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Square cover with a framed theme border — the new visual element this page introduces. */
|
||||
.cut-detail-cover {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 260px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 3px solid var(--mud-palette-secondary);
|
||||
box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent);
|
||||
}
|
||||
|
||||
.cut-detail-divider { margin: 1.5rem 0 0.5rem; }
|
||||
|
||||
.cut-detail-empty {
|
||||
opacity: 0.7;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.cut-detail-tracklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cut-detail-track-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--mud-palette-text-secondary) 12%, transparent);
|
||||
}
|
||||
|
||||
.cut-detail-track-row:last-child { border-bottom: none; }
|
||||
|
||||
.cut-detail-track-number {
|
||||
width: 1.75rem;
|
||||
text-align: right;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.55;
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cut-detail-track-play { flex: 0 0 auto; }
|
||||
|
||||
.cut-detail-track-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Stack the header on narrow screens: cover above the meta column. */
|
||||
@media (max-width: 599px) {
|
||||
.cut-detail-header {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.cut-detail-cover {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using Data.Data.Repositories;
|
||||
using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Backs the public read path that the /cuts/{id} album page consumes (Phase 11 §3a, §3.3).
|
||||
/// <c>CutDetailViewModel.Load</c> fetches an album's tracks through the releaseId-filtered track page
|
||||
/// sorted by "TrackNumber"; that maps to <see cref="TrackRepository.GetPagedFilteredAsync"/> with a
|
||||
/// <see cref="TrackFilter.ReleaseId"/> predicate and an <c>OrderBy(t => 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);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
|
||||
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
|
||||
<!-- Referenced for the client-side queue orchestrator (QueueService / IQueueService).
|
||||
The queue is pure domain logic, unit-testable against a fake IStreamingPlayerService
|
||||
with no browser/JS. -->
|
||||
<ProjectReference Include="..\DeepDrftPublic.Client\DeepDrftPublic.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -161,6 +161,82 @@ public class MediumWritePathTests
|
||||
Assert.That(dto.ReleaseType, Is.Null);
|
||||
}
|
||||
|
||||
// 11.G — Description round-trips through both converter directions verbatim (no medium dance,
|
||||
// unlike ReleaseType): entity → DTO preserves the prose, and DTO → entity carries it back.
|
||||
[Test]
|
||||
public void Convert_Description_RoundTripsBothDirections()
|
||||
{
|
||||
const string prose = "A late-night set\nrecorded at the Vault.";
|
||||
var entity = new ReleaseEntity
|
||||
{
|
||||
Title = "Live at the Vault", Artist = "Artist A",
|
||||
Medium = ReleaseMedium.Session, Description = prose,
|
||||
};
|
||||
|
||||
var dto = TrackConverter.Convert(entity);
|
||||
Assert.That(dto.Description, Is.EqualTo(prose), "entity → DTO preserves Description");
|
||||
|
||||
var back = TrackConverter.Convert(dto);
|
||||
Assert.That(back.Description, Is.EqualTo(prose), "DTO → entity preserves Description");
|
||||
}
|
||||
|
||||
// 11.G — a null Description round-trips as null in both directions (existing rows migrate as NULL).
|
||||
[Test]
|
||||
public void Convert_NullDescription_RoundTripsAsNull()
|
||||
{
|
||||
var entity = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Description = null };
|
||||
|
||||
var dto = TrackConverter.Convert(entity);
|
||||
Assert.That(dto.Description, Is.Null);
|
||||
|
||||
var back = TrackConverter.Convert(dto);
|
||||
Assert.That(back.Description, Is.Null);
|
||||
}
|
||||
|
||||
// 11.G — Description rides the release-cardinal write channel onto the persisted release row,
|
||||
// exactly as Genre does. FindOrCreateRelease is the upload-path projection point.
|
||||
[Test]
|
||||
public async Task FindOrCreateRelease_NewRelease_PersistsDescription()
|
||||
{
|
||||
const string prose = "Three cuts pressed for the summer.";
|
||||
var manager = CreateManager(CreateRepository());
|
||||
|
||||
var data = ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut);
|
||||
data.Description = prose;
|
||||
|
||||
var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data);
|
||||
|
||||
Assert.That(result.Success, Is.True);
|
||||
Assert.That(result.Value!.Description, Is.EqualTo(prose));
|
||||
|
||||
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
|
||||
Assert.That(stored!.Description, Is.EqualTo(prose));
|
||||
}
|
||||
|
||||
// 11.C — editing a track's linked release sets the Description on the persisted release row,
|
||||
// mirroring the PUT api/track/meta apply (release.Description = request.Description).
|
||||
[Test]
|
||||
public async Task Update_SetsReleaseDescription_PersistsDescription()
|
||||
{
|
||||
const string prose = "Now with a proper blurb.";
|
||||
var repo = CreateRepository();
|
||||
ITrackService manager = CreateManager(repo);
|
||||
|
||||
var release = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut };
|
||||
var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release };
|
||||
_context.Tracks.Add(track);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var loaded = (await manager.GetById(track.Id)).Value!;
|
||||
loaded.Release!.Description = prose;
|
||||
|
||||
var result = await manager.Update(loaded);
|
||||
Assert.That(result.Success, Is.True);
|
||||
|
||||
var stored = await CreateRepository().GetReleaseByIdAsync(release.Id);
|
||||
Assert.That(stored!.Description, Is.EqualTo(prose));
|
||||
}
|
||||
|
||||
// 9.5.C — releaseId filter returns only the tracks of the given release. Built on the repository
|
||||
// directly to assert the WHERE release_id predicate in isolation.
|
||||
[Test]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,8 @@ Sequenced as **seven waves**; the critical path is `11.A → 11.B → 11.C`, wit
|
||||
- **11.F — queue model.** `IQueueService` above the single-slot player + one new player `TrackEnded` hook + player-bar skip controls. **Free-floating, can start cold day one.** Gates the Cuts "play album" affordance (11.A header Play). **Preload (§1.3 half b) stays OUT** — design the seam, defer the feature.
|
||||
- **11.G — release Description schema slice.** New `ReleaseEntity.Description` column + EF migration (**Daniel-gated apply**), `ReleaseDto` mirror, `TrackConverter` round-trip, write-path plumbing (`UpdateTrackMetadataRequest` + upload form + the unified services, threaded wherever `Genre` is), CMS `AlbumHeaderFields` multiline input (§3d). **Free-floating, can start cold day one** — the only gate is Daniel's migration go-ahead. The **detail-page render is NOT in this wave**: the Cut text block rides 11.A, the Session/Mix block is a small additive touch to those existing pages. Both degrade cleanly (null Description renders nothing), so render & schema can land in either order.
|
||||
|
||||
**Landed:** 11.A (2026-06-16); 11.F (2026-06-16); 11.G (2026-06-16). Migration `20260616035252_AddReleaseDescription` authored but not yet applied (Daniel-gated). Tracks 11.B, 11.C, 11.D, 11.E remain open.
|
||||
|
||||
**Dependency shape:** `11.A → 11.B → 11.C`; `11.B → 11.E`; **11.D, 11.F, 11.G parallel** (11.D coordinates with 11.C on `ArchiveView`; 11.F's "play album" is consumed by 11.A; 11.G's Description render rides 11.A + a Session/Mix touch, degrading on null). The cold-start items are **11.A**, **11.F**, and **11.G** — kick 11.A + 11.F off first so "play album" works on first ship of the Cut page; 11.G runs alongside on its own track.
|
||||
|
||||
**Resolved by Daniel (2026-06-15), kept visible per file convention:** player-bar title → release detail (was OQ1); track ordinal in scope **and already built** (was OQ4, reversed then found done); retire the **whole** track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred); release **Description** field in scope (commitment 8 — a real new column, lands as schema slice 11.G with the render on 11.A + a Session/Mix touch). **Still open (spec §7.2):** `/cuts/{id}` scaffold strategy (generalized `Header` slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate `IQueueService` — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); Description render plain-text vs. markdown (recommend plain text + preserved line breaks for v1) and column max-length (recommend 2000–4000); `/genres` fate (out of scope, flag as adjacent).
|
||||
|
||||
Reference in New Issue
Block a user