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:
@@ -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">
|
||||
← @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;
|
||||
}
|
||||
@@ -10,21 +10,33 @@
|
||||
<ul class="dd-nav-links">
|
||||
@foreach (var navPage in Pages.MenuPages)
|
||||
{
|
||||
<li>
|
||||
<a href="@navPage.Route" class="dd-nav-link">@navPage.Name</a>
|
||||
</li>
|
||||
@if (navPage.HasChildren)
|
||||
{
|
||||
@* 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>
|
||||
|
||||
<div class="dd-nav-actions">
|
||||
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now ▶" />
|
||||
@* <button type="button" *@
|
||||
@* class="dd-nav-toggle" *@
|
||||
@* aria-label="Toggle dark mode" *@
|
||||
@* aria-pressed="@IsDarkMode.ToString().ToLowerInvariant()" *@
|
||||
@* @onclick="DarkModeToggle"> *@
|
||||
@* @((MarkupString)DarkLightModeIconSvg) *@
|
||||
@* </button> *@
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -35,13 +47,6 @@
|
||||
<a class="dd-nav-brand" href="/">Deep DRFT</a>
|
||||
|
||||
<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"
|
||||
class="dd-nav-hamburger"
|
||||
aria-label="Toggle navigation"
|
||||
@@ -59,6 +64,13 @@
|
||||
<li>
|
||||
<a href="@navPage.Route" class="dd-nav-link" @onclick="CloseMobileMenu">@navPage.Name</a>
|
||||
</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>
|
||||
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now ▶" OnStreamStarted="CloseMobileMenu" />
|
||||
|
||||
@@ -82,6 +82,57 @@
|
||||
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 */
|
||||
.dd-nav-actions {
|
||||
display: flex;
|
||||
@@ -207,6 +258,11 @@
|
||||
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 {
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
|
||||
@@ -7,20 +7,34 @@ public class PageRoute
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Route { get; set; } = string.Empty;
|
||||
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 readonly List<PageRoute> MenuPages =
|
||||
[
|
||||
new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic },
|
||||
new() { Name = "Albums", Route = "/albums", Icon = Icons.Material.Filled.Album },
|
||||
new() { Name = "Genres", Route = "/genres", Icon = Icons.Material.Filled.Category },
|
||||
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Piano }, // TODO: placeholder until Sessions ships
|
||||
new() { Name = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships
|
||||
new()
|
||||
{
|
||||
Name = "Archive", Route = "/archive", Icon = Icons.Material.Filled.Inventory2,
|
||||
Children =
|
||||
[
|
||||
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() { 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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@page "/albums"
|
||||
@page "/cuts"
|
||||
|
||||
<PageTitle>DeepDrft Albums</PageTitle>
|
||||
<PageTitle>DeepDrft Cuts</PageTitle>
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container">
|
||||
|
||||
@@ -1,26 +1,65 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Models.Common;
|
||||
|
||||
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; }
|
||||
|
||||
// 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 List<ReleaseDto> _albums = [];
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
private string PersistKey => $"{PersistKeyPrefix}{Medium}";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var result = await TrackData.GetAlbums();
|
||||
if (result is { Success: true, Value: { } albums })
|
||||
_albums = albums;
|
||||
// Bridge the prerendered fetch across the prerender -> WASM seam (see TracksView). Without
|
||||
// this, the WASM pass re-fetches and replays the card entrance animations.
|
||||
_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;
|
||||
}
|
||||
|
||||
private Task PersistAlbums()
|
||||
{
|
||||
if (_albums.Count > 0)
|
||||
PersistentState.PersistAsJson(PersistKey, _albums);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OpenAlbum(string 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";
|
||||
}
|
||||
@@ -40,50 +40,34 @@ else if (ViewModel.NotFound)
|
||||
else if (ViewModel.Track is not null)
|
||||
{
|
||||
var track = ViewModel.Track;
|
||||
var isThisTrackPlaying = PlayerService.CurrentTrack?.Id == track.Id
|
||||
&& PlayerService.IsPlaying
|
||||
&& !PlayerService.IsPaused;
|
||||
var release = track.Release;
|
||||
var hasMeta = release is not null
|
||||
&& (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null);
|
||||
|
||||
<div class="deepdrft-track-detail-container">
|
||||
|
||||
<MudLink Href="/tracks" Typo="Typo.body2" Class="deepdrft-track-detail-back">
|
||||
← All tracks
|
||||
</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">@track.TrackName</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@release?.Artist</MudText>
|
||||
<ReleaseDetailScaffold Title="@track.TrackName"
|
||||
Artist="@release?.Artist"
|
||||
Track="@track"
|
||||
BackHref="/tracks"
|
||||
BackLabel="All tracks"
|
||||
ShowMeta="@hasMeta">
|
||||
<Hero>
|
||||
<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>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<SharePopover EntryKey="@track.EntryKey" />
|
||||
<PlayStateIcon Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack"/>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
<div class="deepdrft-track-detail-cover">
|
||||
@if (!string.IsNullOrEmpty(release?.ImagePath))
|
||||
</Hero>
|
||||
<MetaContent>
|
||||
@if (hasMeta)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<div>
|
||||
@@ -111,8 +95,7 @@ else if (ViewModel.Track is not null)
|
||||
<MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
</MetaContent>
|
||||
</ReleaseDetailScaffold>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -12,9 +11,7 @@ public partial class TrackDetail : ComponentBase, IDisposable
|
||||
[Parameter] public required string EntryKey { get; set; }
|
||||
[Inject] public required TrackDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
|
||||
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
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()
|
||||
{
|
||||
if (ViewModel.Track is not null)
|
||||
@@ -63,33 +42,5 @@ public partial class TrackDetail : ComponentBase, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task PlayTrack()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -20,6 +20,12 @@ public static class Startup
|
||||
services.AddScoped<ITrackDataService, TrackClientDataService>();
|
||||
services.AddScoped<TracksViewModel>();
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user