Phase 9 Wave 4: ARCHIVE nav + Cuts/Sessions/Mixes pages + MixWaveformVisualizer

Replaces flat RELEASES/SESSIONS/MIXES nav with ARCHIVE dropdown (PageRoute.Children,
one-level cap, dual-role node). Adds /archive overview, /cuts (AlbumsView + medium
filter; /albums redirects), /sessions + /sessions/{id} (hero-dominant), /mixes +
/mixes/{id} (MixWaveformVisualizer full-page background). Extracts ReleaseDetailScaffold
from TrackDetail (invariant trio). PersistentComponentState bridge on all new pages.
Click-to-seek seam designed on MixWaveformVisualizer (inert until wired).
This commit is contained in:
daniel-c-harvey
2026-06-12 23:05:25 -04:00
parent 5f7eaed112
commit af724ce570
31 changed files with 1334 additions and 122 deletions
@@ -0,0 +1,101 @@
using DeepDrftModels.DTOs;
using Models.Common;
using NetBlocks.Models;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
namespace DeepDrftPublic.Client.Clients;
/// <summary>
/// HTTP client for the release read surface (Phase 9). Uses the named <c>"DeepDrft.API"</c>
/// client like <see cref="TrackClient"/>: on WASM it points at the public host and proxies
/// through <c>ReleaseProxyController</c>; on SSR prerender it points directly at DeepDrftAPI.
/// All routes are unauthenticated reads. Responses deserialize as bare DTOs (no ApiResultDto
/// envelope), matching the API's <c>Ok(value)</c> shape.
/// </summary>
public class ReleaseClient
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
public ReleaseClient(IHttpClientFactory httpClientFactory)
{
_http = httpClientFactory.CreateClient("DeepDrft.API");
}
public async Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
string? medium,
int page,
int pageSize,
string? sortColumn = null,
bool sortDescending = false)
{
var queryArgs = new Dictionary<string, string?>
{
["page"] = page.ToString(),
["pageSize"] = pageSize.ToString()
};
if (!string.IsNullOrEmpty(medium))
queryArgs["medium"] = medium;
if (!string.IsNullOrEmpty(sortColumn))
queryArgs["sortColumn"] = sortColumn;
if (sortDescending)
queryArgs["sortDescending"] = "true";
string query = QueryString.Create(queryArgs).ToString();
var response = await _http.GetAsync($"api/release{query}");
if (!response.IsSuccessStatusCode)
return ApiResult<PagedResult<ReleaseDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var paged = JsonSerializer.Deserialize<PagedResult<ReleaseDto>>(json, JsonOptions);
return paged is not null
? ApiResult<PagedResult<ReleaseDto>>.CreatePassResult(paged)
: ApiResult<PagedResult<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<ReleaseDto>> GetById(long id)
{
var response = await _http.GetAsync($"api/release/{id}");
if (!response.IsSuccessStatusCode)
return ApiResult<ReleaseDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var release = JsonSerializer.Deserialize<ReleaseDto>(json, JsonOptions);
return release is not null
? ApiResult<ReleaseDto>.CreatePassResult(release)
: ApiResult<ReleaseDto>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches the high-res waveform datum for a Mix release. A 404 means no datum is stored
/// (not yet generated, or not a Mix) — a valid state, so it returns a pass result with a
/// null value. Any other non-success status is a genuine failure.
/// </summary>
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id)
{
var response = await _http.GetAsync($"api/release/{id}/mix/waveform");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, JsonOptions);
return profile is not null
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
}
}
@@ -0,0 +1,35 @@
@namespace DeepDrftPublic.Client.Controls
@* Full-page background waveform for a Mix release. Deliberately NOT the player-bar peak-bar idiom
(SpectrumVisualizer / LevelMeterFab own that): this renders a single continuous mirrored
silhouette filling the viewport behind the detail content. Fetches its own datum from
api/release/{id}/mix/waveform. The played portion is washed with the progress overlay driven by
PlaybackPosition; the click-to-seek seam (OnSeek + bindable PlaybackPosition) is wired here for a
future wave even though click handling does not ship yet. *@
<div class="mix-waveform-bg @(_profile is null ? "mix-waveform-bg--empty" : null)">
@if (_profile is not null)
{
<svg class="mix-waveform-svg"
viewBox="0 0 @ViewBoxWidth 100"
preserveAspectRatio="none"
role="img"
aria-label="Waveform">
<defs>
<clipPath id="@_clipId">
<path d="@_silhouettePath" />
</clipPath>
</defs>
@* Base silhouette. *@
<path d="@_silhouettePath" class="mix-waveform-fill" />
@* Played-portion wash: a full-height rect clipped to the silhouette, width tracking
PlaybackPosition. Clamped to [0, 1]. *@
<rect x="0" y="0"
width="@(ClampedPosition * ViewBoxWidth)" height="100"
class="mix-waveform-played"
clip-path="url(#@_clipId)" />
</svg>
}
</div>
@@ -0,0 +1,132 @@
using System.Globalization;
using System.Text;
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Renders a Mix release's stored loudness profile as a full-page background silhouette. Standalone
/// and reusable: give it a <see cref="ReleaseId"/> and it fetches its own datum. Visually distinct
/// from the player-bar spectrum/level idiom by design — this is a single continuous mirrored wave,
/// not discrete peak bars.
/// </summary>
public partial class MixWaveformVisualizer : ComponentBase
{
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required ILogger<MixWaveformVisualizer> Logger { get; set; }
/// <summary>The Mix release whose waveform datum to fetch and render.</summary>
[Parameter] public required long ReleaseId { get; set; }
/// <summary>
/// Normalized playback head in [0, 1]. Two-way bindable so a future click-to-seek can write back
/// through it; today it is read-only input that drives the played-portion wash. The seam exists
/// now so wiring click-to-seek later is a pure addition, not a signature change.
/// </summary>
[Parameter] public double PlaybackPosition { get; set; }
[Parameter] public EventCallback<double> PlaybackPositionChanged { get; set; }
/// <summary>
/// Fired when the user seeks by interacting with the waveform. Unused until click-to-seek ships;
/// present now to lock the seek seam into the public contract.
/// </summary>
[Parameter] public EventCallback<double> OnSeek { get; set; }
// Fixed SVG coordinate width. The path is computed in this space, then stretched to the
// viewport via preserveAspectRatio="none".
private const int ViewBoxWidth = 1000;
private readonly string _clipId = $"mix-wf-clip-{Guid.NewGuid():N}";
private WaveformProfileDto? _profile;
private string _silhouettePath = string.Empty;
private long? _loadedReleaseId;
private double ClampedPosition => Math.Clamp(PlaybackPosition, 0d, 1d);
protected override async Task OnParametersSetAsync()
{
// ReleaseId is the only fetch input; fetch once per id. A PlaybackPosition update re-renders
// but must not refetch — and a release with no datum must not refetch either, so the guard
// keys on the fetched id, not on whether a profile came back.
if (_loadedReleaseId == ReleaseId)
return;
_loadedReleaseId = ReleaseId;
var result = await ReleaseData.GetMixWaveform(ReleaseId);
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0)
{
_profile = profile;
try
{
_silhouettePath = BuildSilhouettePath(profile);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "MixWaveformVisualizer: failed to decode waveform profile for release {ReleaseId}; rendering empty backdrop.", ReleaseId);
_profile = null;
_silhouettePath = string.Empty;
}
}
else
{
// No datum (not generated yet, or not a Mix) — leave the background empty; the detail
// page still renders its content over a plain backdrop.
_profile = null;
_silhouettePath = string.Empty;
}
}
// Builds a closed, vertically mirrored silhouette path across the buckets. Loudness bytes are
// [0, 255]; mapped to a half-height amplitude around the vertical midline (y=50). The top edge
// runs left-to-right, the bottom edge mirrors right-to-left, and the path closes — yielding a
// filled continuous wave shape rather than separate bars.
private static string BuildSilhouettePath(WaveformProfileDto profile)
{
var data = Convert.FromBase64String(profile.Data);
int n = data.Length;
if (n == 0) return string.Empty;
const double midline = 50d;
const double maxAmplitude = 48d; // leave a 2-unit margin top and bottom
double step = n > 1 ? (double)ViewBoxWidth / (n - 1) : ViewBoxWidth;
var sb = new StringBuilder();
// Top edge, left to right.
for (int i = 0; i < n; i++)
{
double x = i * step;
double amp = data[i] / 255d * maxAmplitude;
double y = midline - amp;
sb.Append(i == 0 ? 'M' : 'L');
AppendPoint(sb, x, y);
}
// Bottom edge, right to left (mirror).
for (int i = n - 1; i >= 0; i--)
{
double x = i * step;
double amp = data[i] / 255d * maxAmplitude;
double y = midline + amp;
sb.Append('L');
AppendPoint(sb, x, y);
}
sb.Append('Z');
return sb.ToString();
}
private static void AppendPoint(StringBuilder sb, double x, double y)
{
sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture));
sb.Append(' ');
sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture));
sb.Append(' ');
}
}
@@ -0,0 +1,31 @@
/* Full-viewport fixed backdrop. Sits behind page content (negative-ish z-index within the
detail layout) and never intercepts pointer events until click-to-seek ships. */
.mix-waveform-bg {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
display: flex;
align-items: center;
}
.mix-waveform-bg--empty {
/* No datum: nothing to draw. Kept as a hook for a future flat-line fallback. */
}
.mix-waveform-svg {
width: 100%;
height: 60vh;
margin: auto 0;
opacity: 0.18;
}
/* Native SVG elements — scoped CSS stamps these directly, no ::deep needed. */
.mix-waveform-fill {
fill: var(--mud-palette-text-secondary);
}
.mix-waveform-played {
fill: var(--mud-palette-primary);
}
@@ -0,0 +1,40 @@
@namespace DeepDrftPublic.Client.Controls
@* Invariant trio shared by every medium's detail page: a back link, a masthead (title + artist),
a play/share affordance row wired to the streaming player, and slots for the medium-specific
hero visual and metadata block. TrackDetail and the Session/Mix detail pages all compose this;
per-medium variance rides the Hero and MetaContent render fragments. *@
<div class="deepdrft-track-detail-container">
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; @BackLabel
</MudLink>
<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 + share only make sense once a playable track is resolved. *@
@if (Track is not null)
{
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<SharePopover EntryKey="@Track.EntryKey" />
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
</MudStack>
}
</MudStack>
@Hero
@if (MetaContent is not null && ShowMeta)
{
<MudDivider />
<div class="deepdrft-track-detail-meta">
@MetaContent
</div>
}
</div>
@@ -0,0 +1,56 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Shared detail-page chrome for any release medium: back link, masthead, play/share affordance,
/// and hero/meta slots. Owns the play-toggle wiring against the cascaded streaming player so each
/// detail page supplies only its data and medium-specific visuals. Extracted from the original
/// TrackDetail page, which is now a thin consumer of this scaffold.
/// </summary>
public partial class ReleaseDetailScaffold : ComponentBase
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Parameter] public required string Title { get; set; }
[Parameter] public string? Artist { get; set; }
// The playable track for this release. Null while unresolved (or when a release has no
// streamable track yet) — the play/share row is hidden in that case.
[Parameter] public TrackDto? Track { get; set; }
[Parameter] public string BackHref { get; set; } = "/archive";
[Parameter] public string BackLabel { get; set; } = "Archive";
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
[Parameter] public RenderFragment? Hero { get; set; }
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
[Parameter] public RenderFragment? MetaContent { get; set; }
/// <summary>
/// Gate for the metadata block. Lets a consumer supply a <see cref="MetaContent"/> fragment but
/// suppress the divider + block when its data is empty (slot fragments cannot be conditionally
/// attached inline). Defaults to shown.
/// </summary>
[Parameter] public bool ShowMeta { get; set; } = true;
private async Task PlayTrack()
{
if (Track is null || PlayerService is null) return;
// Toggle if this track is already active (playing or paused); otherwise start a fresh
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else
{
await PlayerService.SelectTrackStreaming(Track);
}
}
}
@@ -0,0 +1,75 @@
@namespace DeepDrftPublic.Client.Controls
@* Card grid of releases that open their own detail page (/{DetailRoute}/{id}). Shared by the
Sessions and Mixes browse pages. Cuts intentionally do not use this — they open the track
gallery filtered by album, a different navigation target. Fully controlled by the parent:
loading and item state are passed in. *@
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="release-gallery-container">
@if (Loading)
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="release-card-center">
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
</div>
</MudItem>
}
</MudGrid>
}
else if (Releases.Count == 0)
{
<div class="release-gallery-empty">
<MudText Typo="Typo.h6">@EmptyMessage</MudText>
</div>
}
else
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var release in Releases)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="release-card-center">
<a href="@($"/{DetailRoute}/{release.Id}")" class="release-card-link">
<div class="release-card">
@if (!string.IsNullOrEmpty(release.ImagePath))
{
<div class="release-card-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(release.ImagePath)');">
</div>
}
else
{
<div class="release-card-cover release-card-cover--fallback"></div>
}
<div class="release-card-body">
<MudText Typo="Typo.subtitle1" Class="release-card-title text-truncate">
@release.Title
</MudText>
<MudText Typo="Typo.caption" Class="release-card-artist text-truncate">
@release.Artist
</MudText>
</div>
</div>
</a>
</div>
</MudItem>
}
</MudGrid>
}
</MudContainer>
</div>
@code {
[Parameter] public required IReadOnlyList<DeepDrftModels.DTOs.ReleaseDto> Releases { get; set; }
[Parameter] public bool Loading { get; set; }
/// <summary>Route segment for a card's detail page; a card links to /{DetailRoute}/{id}.</summary>
[Parameter] public required string DetailRoute { get; set; }
[Parameter] public string EmptyMessage { get; set; } = "Nothing here yet";
}
@@ -0,0 +1,63 @@
.release-gallery-container {
padding-top: 16px;
}
.release-card-center {
display: flex;
justify-content: center;
width: 100%;
}
.release-card-link {
text-decoration: none;
color: inherit;
}
.release-card {
display: flex;
flex-direction: column;
width: 200px;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 120ms ease;
}
.release-card:hover {
transform: translateY(-4px);
}
.release-card-cover {
width: 200px;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.release-card-cover--fallback {
background-color: var(--mud-palette-dark, #1a2238);
}
.release-card-body {
padding: 8px 4px 0 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* release-card-title / release-card-artist ride on MudText (child Razor component); ::deep
pierces into its output since Blazor isolation does not scope-stamp child component roots. */
::deep .release-card-title {
font-weight: 600;
}
::deep .release-card-artist {
opacity: 0.7;
}
.release-gallery-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
+29 -17
View File
@@ -10,21 +10,33 @@
<ul class="dd-nav-links"> <ul class="dd-nav-links">
@foreach (var navPage in Pages.MenuPages) @foreach (var navPage in Pages.MenuPages)
{ {
<li> @if (navPage.HasChildren)
<a href="@navPage.Route" class="dd-nav-link">@navPage.Name</a> {
</li> @* Dual-role node: the parent anchor navigates to its own route on click,
while hover/focus reveals the child dropdown (pure CSS, no JS). *@
<li class="dd-nav-item-parent">
<a href="@navPage.Route" class="dd-nav-link">@navPage.Name</a>
<ul class="dd-nav-dropdown">
@foreach (var child in navPage.Children)
{
<li>
<a href="@child.Route" class="dd-nav-link dd-nav-dropdown-link">@child.Name</a>
</li>
}
</ul>
</li>
}
else
{
<li>
<a href="@navPage.Route" class="dd-nav-link">@navPage.Name</a>
</li>
}
} }
</ul> </ul>
<div class="dd-nav-actions"> <div class="dd-nav-actions">
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" /> <StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" />
@* <button type="button" *@
@* class="dd-nav-toggle" *@
@* aria-label="Toggle dark mode" *@
@* aria-pressed="@IsDarkMode.ToString().ToLowerInvariant()" *@
@* @onclick="DarkModeToggle"> *@
@* @((MarkupString)DarkLightModeIconSvg) *@
@* </button> *@
</div> </div>
</nav> </nav>
</div> </div>
@@ -35,13 +47,6 @@
<a class="dd-nav-brand" href="/">Deep DRFT</a> <a class="dd-nav-brand" href="/">Deep DRFT</a>
<div class="dd-nav-actions"> <div class="dd-nav-actions">
@* <button type="button" *@
@* class="dd-nav-toggle" *@
@* aria-label="Toggle dark mode" *@
@* aria-pressed="@IsDarkMode.ToString().ToLowerInvariant()" *@
@* @onclick="DarkModeToggle"> *@
@* @((MarkupString)DarkLightModeIconSvg) *@
@* </button> *@
<button type="button" <button type="button"
class="dd-nav-hamburger" class="dd-nav-hamburger"
aria-label="Toggle navigation" aria-label="Toggle navigation"
@@ -59,6 +64,13 @@
<li> <li>
<a href="@navPage.Route" class="dd-nav-link" @onclick="CloseMobileMenu">@navPage.Name</a> <a href="@navPage.Route" class="dd-nav-link" @onclick="CloseMobileMenu">@navPage.Name</a>
</li> </li>
@* One-level fan-out: render children as an indented sub-list under the parent link. *@
@foreach (var child in navPage.Children)
{
<li class="dd-nav-mobile-child">
<a href="@child.Route" class="dd-nav-link" @onclick="CloseMobileMenu">@child.Name</a>
</li>
}
} }
<li> <li>
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" OnStreamStarted="CloseMobileMenu" /> <StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" OnStreamStarted="CloseMobileMenu" />
@@ -82,6 +82,57 @@
color: var(--deepdrft-white); color: var(--deepdrft-white);
} }
/* Dual-role parent node: anchor for the parent route, positioning context for the dropdown. */
.dd-nav-item-parent {
position: relative;
}
/* Hover-revealed child dropdown. Pure CSS — hidden by default, shown when the parent item is
hovered or contains keyboard focus. Sits directly beneath the parent link, frosted to match
the bar. */
.dd-nav-dropdown {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%) translateY(0.25rem);
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
list-style: none;
margin: 0;
padding: 1rem 1.5rem;
min-width: 9rem;
background: rgba(250, 250, 248, 0.96);
border: 1px solid var(--deepdrft-border);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.18s ease, visibility 0.18s ease;
z-index: 101;
}
.dd-nav-dark .dd-nav-dropdown {
background: rgba(13, 13, 18, 0.95);
}
.dd-nav-item-parent:hover .dd-nav-dropdown,
.dd-nav-item-parent:focus-within .dd-nav-dropdown {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.dd-nav-dropdown-link {
white-space: nowrap;
}
/* Right-side cluster */ /* Right-side cluster */
.dd-nav-actions { .dd-nav-actions {
display: flex; display: flex;
@@ -207,6 +258,11 @@
padding: 0.6rem 0; padding: 0.6rem 0;
} }
/* Indented child rows under their parent link in the mobile drawer. */
.dd-nav-mobile-child {
padding-left: 1.25rem;
}
.dd-nav-links-mobile ::deep .dd-nav-cta { .dd-nav-links-mobile ::deep .dd-nav-cta {
margin-top: 0.5rem; margin-top: 0.5rem;
text-align: center; text-align: center;
+20 -6
View File
@@ -7,20 +7,34 @@ public class PageRoute
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string Route { get; set; } = string.Empty; public string Route { get; set; } = string.Empty;
public string? Icon { get; set; } = null; public string? Icon { get; set; } = null;
// Optional one-level fan-out. A node with children is a dual-role node: its own Route is a
// real destination (desktop click / mobile tap navigate to it), and Children render as a
// hover dropdown on desktop and an indented sub-list on mobile. Depth is capped at one level
// by convention — children are not themselves expected to carry children.
public IReadOnlyList<PageRoute> Children { get; set; } = [];
public bool HasChildren => Children.Count > 0;
} }
public static class Pages public static class Pages
{ {
public static readonly List<PageRoute> MenuPages = public static readonly List<PageRoute> MenuPages =
[ [
new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic }, new()
new() { Name = "Albums", Route = "/albums", Icon = Icons.Material.Filled.Album }, {
new() { Name = "Genres", Route = "/genres", Icon = Icons.Material.Filled.Category }, Name = "Archive", Route = "/archive", Icon = Icons.Material.Filled.Inventory2,
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Piano }, // TODO: placeholder until Sessions ships Children =
new() { Name = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships [
new() { Name = "Cuts", Route = "/cuts", Icon = Icons.Material.Filled.Album },
new() { Name = "Sessions", Route = "/sessions", Icon = Icons.Material.Filled.Piano },
new() { Name = "Mixes", Route = "/mixes", Icon = Icons.Material.Filled.GraphicEq },
],
},
new() { Name = "Genres", Route = "/genres", Icon = Icons.Material.Filled.Category },
]; ];
public static readonly List<PageRoute> AllPages = public static readonly List<PageRoute> AllPages =
new List<PageRoute> new List<PageRoute>
{ {
new() { Name = "Home", Route = "/", Icon = Icons.Material.Filled.Home } new() { Name = "Home", Route = "/", Icon = Icons.Material.Filled.Home }
@@ -0,0 +1,10 @@
@page "/albums"
@inject NavigationManager Navigation
@* Cuts replaced the standalone /albums route in Phase 9. Old links keep working via a
permanent redirect to /cuts rather than 404ing. *@
@code {
protected override void OnInitialized()
=> Navigation.NavigateTo("/cuts", forceLoad: false, replace: true);
}
+2 -2
View File
@@ -1,6 +1,6 @@
@page "/albums" @page "/cuts"
<PageTitle>DeepDrft Albums</PageTitle> <PageTitle>DeepDrft Cuts</PageTitle>
<div> <div>
<MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container"> <MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container">
@@ -1,26 +1,65 @@
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services; using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Models.Common;
namespace DeepDrftPublic.Client.Pages; namespace DeepDrftPublic.Client.Pages;
public partial class AlbumsView : ComponentBase /// <summary>
/// Medium-filtered release gallery. Routed at <c>/cuts</c> (Cut releases) and parameterized by
/// <see cref="Medium"/> so the same component can back any medium's card grid without a fork.
/// Cards open the track gallery filtered to that release's album title, preserving the original
/// /albums ergonomics.
/// </summary>
public partial class AlbumsView : ComponentBase, IDisposable
{ {
[Inject] public required ITrackDataService TrackData { get; set; } private const string PersistKeyPrefix = "albums-view-";
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required NavigationManager Navigation { get; set; } [Inject] public required NavigationManager Navigation { get; set; }
// The medium whose releases this grid shows. Defaults to Cut for the /cuts route; other media
// can reuse this component by passing a different value. Drives both the fetch filter and the
// per-medium persistence key so prerendered state never bleeds across media.
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
private bool _loading = true; private bool _loading = true;
private List<ReleaseDto> _albums = []; private List<ReleaseDto> _albums = [];
private PersistingComponentStateSubscription _persistingSubscription;
private string PersistKey => $"{PersistKeyPrefix}{Medium}";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var result = await TrackData.GetAlbums(); // Bridge the prerendered fetch across the prerender -> WASM seam (see TracksView). Without
if (result is { Success: true, Value: { } albums }) // this, the WASM pass re-fetches and replays the card entrance animations.
_albums = albums; _persistingSubscription = PersistentState.RegisterOnPersisting(PersistAlbums);
if (PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored) && restored is not null)
{
_albums = restored;
_loading = false;
return;
}
var result = await ReleaseData.GetPaged(Medium.ToString().ToLowerInvariant(), page: 1, pageSize: 100);
if (result is { Success: true, Value: { Items: { } items } })
_albums = items.ToList();
_loading = false; _loading = false;
} }
private Task PersistAlbums()
{
if (_albums.Count > 0)
PersistentState.PersistAsJson(PersistKey, _albums);
return Task.CompletedTask;
}
private void OpenAlbum(string album) private void OpenAlbum(string album)
=> Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}"); => Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}");
public void Dispose() => _persistingSubscription.Dispose();
} }
@@ -0,0 +1,31 @@
@page "/archive"
<PageTitle>DeepDrft Archive</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
<div class="archive-grid">
@foreach (var medium in _media)
{
<a href="@medium.Route" class="archive-card-link">
<div class="archive-card">
<MudIcon Icon="@medium.Icon" Class="archive-card-icon" />
<MudText Typo="Typo.h5" Class="archive-card-title">@medium.Title</MudText>
<MudText Typo="Typo.body2" Class="archive-card-blurb">@medium.Blurb</MudText>
</div>
</a>
}
</div>
</MudContainer>
</div>
@code {
private record MediumCard(string Title, string Blurb, string Route, string Icon);
private static readonly MediumCard[] _media =
[
new("Cuts", "Studio recordings — singles, EPs, and albums.", "/cuts", Icons.Material.Filled.Album),
new("Sessions", "Single live takes, each with its own hero image.", "/sessions", Icons.Material.Filled.Piano),
new("Mixes", "Long-form continuous mixes with high-resolution waveforms.", "/mixes", Icons.Material.Filled.GraphicEq),
];
}
@@ -0,0 +1,48 @@
.archive-view-container {
padding-top: 16px;
}
.archive-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 2rem;
margin-top: 1rem;
}
.archive-card-link {
text-decoration: none;
color: inherit;
}
.archive-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 2.5rem 1.5rem;
border: 1px solid var(--mud-palette-lines-default);
border-radius: 8px;
background-color: var(--mud-palette-surface);
transition: transform 120ms ease, box-shadow 120ms ease;
}
.archive-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 28px color-mix(in srgb, var(--mud-palette-text-secondary) 18%, transparent);
}
/* archive-card-icon rides on MudIcon (child Razor component); ::deep pierces its output. */
::deep .archive-card-icon {
font-size: 56px;
color: var(--mud-palette-primary);
}
/* archive-card-title / archive-card-blurb ride on MudText (child Razor component). */
::deep .archive-card-title {
font-weight: 600;
}
::deep .archive-card-blurb {
opacity: 0.7;
}
@@ -0,0 +1,55 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
/// <summary>
/// Shared fetch + prerender-bridge logic for the medium browse pages (Sessions, Mixes). Subclasses
/// supply only the <see cref="Medium"/> and <see cref="DetailRoute"/>; this base fetches the paged
/// releases and bridges the prerendered result across the prerender -> WASM seam so the WASM pass
/// does not re-fetch and replay the card animations (see the TracksView seam).
/// </summary>
public abstract class MediumBrowseBase : ComponentBase, IDisposable
{
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
/// <summary>The medium this page browses. Subclass-supplied constant.</summary>
protected abstract ReleaseMedium Medium { get; }
protected bool Loading { get; private set; } = true;
protected IReadOnlyList<ReleaseDto> Releases { get; private set; } = [];
private PersistingComponentStateSubscription _persistingSubscription;
private string PersistKey => $"medium-browse-{Medium}";
protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
if (PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored) && restored is not null)
{
Releases = restored;
Loading = false;
return;
}
var result = await ReleaseData.GetPaged(Medium.ToString().ToLowerInvariant(), page: 1, pageSize: 100);
if (result is { Success: true, Value: { Items: { } items } })
Releases = items.ToList();
Loading = false;
}
private Task Persist()
{
if (Releases.Count > 0)
PersistentState.PersistAsJson(PersistKey, Releases.ToList());
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
}
@@ -0,0 +1,71 @@
@page "/mixes/{Id:long}"
@using DeepDrftPublic.Client.Controls
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - 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">Mix not found.</MudText>
<div class="d-flex justify-center mt-4">
<MudButton Href="/mixes"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack">
All mixes
</MudButton>
</div>
</div>
</div>
}
else
{
var release = ViewModel.Release;
var hasGenre = release.Genre is not null;
var hasDate = release.ReleaseDate is not null;
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. *@
<MixWaveformVisualizer ReleaseId="@release.Id" />
<div class="mix-detail-foreground">
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@ViewModel.Track"
BackHref="/mixes"
BackLabel="All mixes"
ShowMeta="@(hasGenre || hasDate)">
<MetaContent>
@if (hasGenre)
{
<div>
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Tertiary" Class="deepdrft-genre-chip">
@release.Genre
</MudChip>
</div>
}
@if (hasDate)
{
<div>
<MudText Typo="Typo.overline">Released</MudText>
<MudText Typo="Typo.body1">@release.ReleaseDate!.Value.ToString("MMMM yyyy")</MudText>
</div>
}
</MetaContent>
</ReleaseDetailScaffold>
</div>
}
@code {
protected override string PersistKey => "mix-detail";
}
@@ -0,0 +1,5 @@
/* Lifts the detail content above the fixed waveform backdrop (z-index: 0). */
.mix-detail-foreground {
position: relative;
z-index: 1;
}
@@ -0,0 +1,15 @@
@page "/mixes"
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
<PageTitle>DeepDrft Mixes</PageTitle>
<ReleaseGallery Releases="@Releases"
Loading="@Loading"
DetailRoute="mixes"
EmptyMessage="No mixes yet" />
@code {
protected override DeepDrftModels.Enums.ReleaseMedium Medium => DeepDrftModels.Enums.ReleaseMedium.Mix;
protected string DetailRoute => "mixes";
}
@@ -0,0 +1,52 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
/// <summary>
/// Shared load + prerender-bridge logic for the single-release detail pages (Session, Mix).
/// Subclasses supply only their markup; this base loads the release through
/// <see cref="ReleaseDetailViewModel"/> and bridges the prerendered release across the prerender ->
/// WASM seam so the WASM pass does not re-fetch (see the TracksView seam). The playable track is
/// re-resolved on a restore miss only.
/// </summary>
public abstract class ReleaseDetailBase : ComponentBase, IDisposable
{
[Parameter] public long Id { get; set; }
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
// Distinct keys per medium so a Session restore never lands on a Mix page.
protected abstract string PersistKey { get; }
protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
// The bridged payload carries both the release and its resolved track so the interactive
// pass renders identically without a second round-trip.
if (PersistentState.TryTakeFromJson<BridgedDetail>(PersistKey, out var restored) && restored?.Release is not null)
{
ViewModel.Restore(restored.Release, restored.Track);
}
else
{
await ViewModel.Load(Id);
}
}
private Task Persist()
{
if (ViewModel.Release is not null)
PersistentState.PersistAsJson(PersistKey, new BridgedDetail(ViewModel.Release, ViewModel.Track));
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
protected sealed record BridgedDetail(ReleaseDto Release, TrackDto? Track);
}
@@ -0,0 +1,86 @@
@page "/sessions/{Id:long}"
@using DeepDrftPublic.Client.Controls
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
@if (ViewModel.IsLoading)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-cover">
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Width="100%" Height="320px" />
</div>
<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">Session not found.</MudText>
<div class="d-flex justify-center mt-4">
<MudButton Href="/sessions"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack">
All sessions
</MudButton>
</div>
</div>
</div>
}
else
{
var release = ViewModel.Release;
var heroKey = release.SessionMetadata?.HeroImageEntryKey;
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
var hasGenre = release.Genre is not null;
var hasDate = release.ReleaseDate is not null;
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@ViewModel.Track"
BackHref="/sessions"
BackLabel="All sessions"
ShowMeta="@(hasGenre || hasDate)">
<Hero>
<div class="session-detail-hero">
@if (!string.IsNullOrEmpty(heroImage))
{
<MudPaper Elevation="2" Class="session-detail-hero-img"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(heroImage)}');")" />
}
else
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@Icons.Material.Filled.Piano" Color="Color.Primary" />
</MudPaper>
}
</div>
</Hero>
<MetaContent>
@if (hasGenre)
{
<div>
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Tertiary" Class="deepdrft-genre-chip">
@release.Genre
</MudChip>
</div>
}
@if (hasDate)
{
<div>
<MudText Typo="Typo.overline">Released</MudText>
<MudText Typo="Typo.body1">@release.ReleaseDate!.Value.ToString("MMMM yyyy")</MudText>
</div>
}
</MetaContent>
</ReleaseDetailScaffold>
}
@code {
protected override string PersistKey => "session-detail";
}
@@ -0,0 +1,14 @@
/* Hero-dominant: a wide 16:9 image rather than the square cover used on track detail. */
.session-detail-hero {
margin: 0 auto 2rem;
overflow: hidden;
}
/* session-detail-hero-img rides on MudPaper (child Razor component); ::deep pierces its output. */
::deep .session-detail-hero-img {
width: 100%;
aspect-ratio: 16 / 9;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
@@ -0,0 +1,15 @@
@page "/sessions"
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
<PageTitle>DeepDrft Sessions</PageTitle>
<ReleaseGallery Releases="@Releases"
Loading="@Loading"
DetailRoute="sessions"
EmptyMessage="No sessions yet" />
@code {
protected override DeepDrftModels.Enums.ReleaseMedium Medium => DeepDrftModels.Enums.ReleaseMedium.Session;
protected string DetailRoute => "sessions";
}
+25 -42
View File
@@ -40,50 +40,34 @@ else if (ViewModel.NotFound)
else if (ViewModel.Track is not null) else if (ViewModel.Track is not null)
{ {
var track = ViewModel.Track; var track = ViewModel.Track;
var isThisTrackPlaying = PlayerService.CurrentTrack?.Id == track.Id
&& PlayerService.IsPlaying
&& !PlayerService.IsPaused;
var release = track.Release; var release = track.Release;
var hasMeta = release is not null var hasMeta = release is not null
&& (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null); && (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null);
<div class="deepdrft-track-detail-container"> <ReleaseDetailScaffold Title="@track.TrackName"
Artist="@release?.Artist"
<MudLink Href="/tracks" Typo="Typo.body2" Class="deepdrft-track-detail-back"> Track="@track"
&larr; All tracks BackHref="/tracks"
</MudLink> BackLabel="All tracks"
ShowMeta="@hasMeta">
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;"> <Hero>
<div class="deepdrft-track-detail-masthead"> <div class="deepdrft-track-detail-cover">
<MudText Typo="Typo.h3">@track.TrackName</MudText> @if (!string.IsNullOrEmpty(release?.ImagePath))
<MudText Typo="Typo.h6" Color="Color.Primary">@release?.Artist</MudText> {
<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>
</Hero>
<MudStack Row AlignItems="AlignItems.Center" Spacing="1"> <MetaContent>
<SharePopover EntryKey="@track.EntryKey" /> @if (hasMeta)
<PlayStateIcon Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack"/>
</MudStack>
</MudStack>
<div class="deepdrft-track-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>
@if (hasMeta)
{
<MudDivider />
<div class="deepdrft-track-detail-meta">
@if (release?.Title is not null) @if (release?.Title is not null)
{ {
<div> <div>
@@ -111,8 +95,7 @@ else if (ViewModel.Track is not null)
<MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText> <MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
</div> </div>
} }
</div> }
} </MetaContent>
</ReleaseDetailScaffold>
</div>
} }
@@ -1,5 +1,4 @@
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels; using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -12,9 +11,7 @@ public partial class TrackDetail : ComponentBase, IDisposable
[Parameter] public required string EntryKey { get; set; } [Parameter] public required string EntryKey { get; set; }
[Inject] public required TrackDetailViewModel ViewModel { get; set; } [Inject] public required TrackDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; } [Inject] public required PersistentComponentState PersistentState { get; set; }
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
private IStreamingPlayerService? _subscribedService;
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -36,24 +33,6 @@ public partial class TrackDetail : ComponentBase, IDisposable
} }
} }
protected override void OnParametersSet()
{
// The play button's icon reads off the player's live state (CurrentTrack /
// IsPlaying / IsPaused), which mutates outside this component's render path.
// The cascade is IsFixed, so the provider's re-render never reaches us —
// subscribe to the multicast side-channel and re-render on every state change.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
}
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private Task PersistTrack() private Task PersistTrack()
{ {
if (ViewModel.Track is not null) if (ViewModel.Track is not null)
@@ -63,33 +42,5 @@ public partial class TrackDetail : ComponentBase, IDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
private async Task PlayTrack() public void Dispose() => _persistingSubscription.Dispose();
{
if (ViewModel.Track is null) return;
var isThisTrack = PlayerService.CurrentTrack?.Id == ViewModel.Track.Id;
// Toggle play/pause if this track is already the active one (playing or paused);
// otherwise start a fresh stream. SelectTrackStreaming is the live entry point —
// the buffered SelectTrack path is dead.
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else
{
await PlayerService.SelectTrackStreaming(ViewModel.Track);
}
}
public void Dispose()
{
_persistingSubscription.Dispose();
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
}
} }
@@ -0,0 +1,31 @@
using DeepDrftModels.DTOs;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Release read abstraction (Phase 9). Both SSR and WASM renders are served by
/// <c>ReleaseClientDataService</c> in this assembly, which delegates to
/// <see cref="Clients.ReleaseClient"/> over HTTP. Components inject this single seam
/// so they do not branch on render mode — mirrors <see cref="ITrackDataService"/>.
/// </summary>
public interface IReleaseDataService
{
/// <summary>Paged releases, optionally filtered to one medium ("cut" | "session" | "mix").</summary>
Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
string? medium,
int page,
int pageSize,
string? sortColumn = null,
bool sortDescending = false);
/// <summary>Single release with both metadata satellites (nulls for non-matching media).</summary>
Task<ApiResult<ReleaseDto>> GetById(long id);
/// <summary>
/// The Mix waveform datum. Success with a value when present; success with a null value when
/// no datum is stored (a valid state, not a failure); failure on any other transport error.
/// </summary>
Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id);
}
@@ -0,0 +1,35 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// <see cref="IReleaseDataService"/> backed by <see cref="ReleaseClient"/> (HTTP to the
/// <c>DeepDrft.API</c> backend). Used on both the SSR prerender and WASM interactive passes —
/// the release read surface is HTTP-only, so there is no separate in-process implementation.
/// </summary>
public class ReleaseClientDataService : IReleaseDataService
{
private readonly ReleaseClient _releaseClient;
public ReleaseClientDataService(ReleaseClient releaseClient)
{
_releaseClient = releaseClient;
}
public Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
string? medium,
int page,
int pageSize,
string? sortColumn = null,
bool sortDescending = false)
=> _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending);
public Task<ApiResult<ReleaseDto>> GetById(long id)
=> _releaseClient.GetById(id);
public Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id)
=> _releaseClient.GetMixWaveform(id);
}
+6
View File
@@ -20,6 +20,12 @@ public static class Startup
services.AddScoped<ITrackDataService, TrackClientDataService>(); services.AddScoped<ITrackDataService, TrackClientDataService>();
services.AddScoped<TracksViewModel>(); services.AddScoped<TracksViewModel>();
services.AddScoped<TrackDetailViewModel>(); services.AddScoped<TrackDetailViewModel>();
// Release read surface (Phase 9). Same HTTP posture as the track client — both
// WASM and SSR prerender call DeepDrftAPI over the "DeepDrft.API" client.
services.AddScoped<ReleaseClient>();
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
services.AddScoped<ReleaseDetailViewModel>();
} }
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress) public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
@@ -0,0 +1,69 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
namespace DeepDrftPublic.Client.ViewModels;
/// <summary>
/// State for a single-release detail page (Session, Mix). Loads the release and resolves its
/// playable track. The release read surface exposes no track entry directly, so the playable track
/// is resolved through the existing track gallery filtered by the release's album title — for
/// Session/Mix that yields the single track. Scoped; reset every flag per <see cref="Load"/> so a
/// reused instance never bleeds across navigations (mirrors TrackDetailViewModel).
/// </summary>
public class ReleaseDetailViewModel
{
private readonly IReleaseDataService _releaseData;
private readonly ITrackDataService _trackData;
public ReleaseDto? Release { get; private set; }
public TrackDto? Track { get; private set; }
public bool IsLoading { get; private set; } = true;
public bool NotFound { get; private set; }
public ReleaseDetailViewModel(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, TrackDto? track)
{
Release = release;
Track = track;
NotFound = false;
IsLoading = false;
}
public async Task Load(long releaseId)
{
IsLoading = true;
NotFound = false;
Release = null;
Track = null;
try
{
var releaseResult = await _releaseData.GetById(releaseId);
if (releaseResult is not { Success: true, Value: { } release })
{
NotFound = true;
return;
}
Release = release;
// Resolve the playable track via the album-filtered track page. Session/Mix releases
// carry a single track; take the first. A release with no streamable track simply
// leaves Track null (the detail page hides the play affordance).
var trackResult = await _trackData.GetPage(
pageNumber: 1, pageSize: 1, album: release.Title);
if (trackResult is { Success: true, Value: { Items: { } items } })
Track = items.FirstOrDefault();
}
finally
{
IsLoading = false;
}
}
}
@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftPublic.Controllers;
/// <summary>
/// Proxies the public release read surface (Phase 9) to DeepDrftAPI so the browser never
/// makes a cross-origin request. Mirrors <see cref="TrackProxyController"/>: the WASM client
/// issues relative <c>api/release/*</c> requests against this host, which forwards them
/// upstream. SSR prerender calls DeepDrftAPI directly via the same named client — no proxy
/// hop on the server side. All forwarded routes are unauthenticated reads.
/// </summary>
[ApiController]
[Route("api/release")]
public class ReleaseProxyController : ControllerBase
{
private readonly HttpClient _upstream;
private readonly ILogger<ReleaseProxyController> _logger;
public ReleaseProxyController(IHttpClientFactory httpClientFactory, ILogger<ReleaseProxyController> logger)
{
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
_logger = logger;
}
/// <summary>Proxies the paged release list, forwarding the optional medium filter and sort params.</summary>
[HttpGet]
public async Task<ActionResult> GetReleases(
[FromQuery] string? medium = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false,
CancellationToken ct = default)
{
var query = $"api/release?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
if (!string.IsNullOrWhiteSpace(medium))
query += $"&medium={Uri.EscapeDataString(medium)}";
if (!string.IsNullOrWhiteSpace(sortColumn))
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
return await RelayJson(query, "release list");
}
/// <summary>Proxies the Mix waveform datum. A 404 (no datum stored) passes through verbatim.</summary>
[HttpGet("{id:long}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(long id, CancellationToken ct = default)
=> await RelayJson($"api/release/{id}/mix/waveform", $"release {id} mix waveform", ct);
/// <summary>Proxies a single release. A 404 (no such release) passes through verbatim.</summary>
[HttpGet("{id:long}")]
public async Task<ActionResult> GetReleaseById(long id, CancellationToken ct = default)
=> await RelayJson($"api/release/{id}", $"release {id}", ct);
// Small JSON payloads, buffered and relayed. Non-success statuses (notably 404) pass through
// so the client renders them as valid states rather than collapsing to a 502.
private async Task<ActionResult> RelayJson(string upstreamPath, string description, CancellationToken ct = default)
{
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(upstreamPath, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI {Description} failed", description);
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI {Description} returned {Status}", description, (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
}