feat(public): add /cuts/{id} album-detail page
Compose ReleaseDetailScaffold via Header + BodyContent slots for the Cut album view: left meta + Play/Share, right theme-bordered cover, TrackNumber- ordered track list with per-row play. CutDetailBase carries the multi-track prerender bridge.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
@page "/cuts/{Id:long}"
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits CutDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
||||
<div class="d-flex justify-center mt-4">
|
||||
<MudButton Href="/cuts"
|
||||
Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack">
|
||||
All cuts
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var release = ViewModel.Release;
|
||||
var hasGenre = release.Genre is not null;
|
||||
var hasYear = release.ReleaseDate is not null;
|
||||
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
|
||||
|
||||
<ReleaseDetailScaffold Title="@release.Title"
|
||||
Artist="@release.Artist"
|
||||
Track="@firstTrack"
|
||||
BackHref="/cuts"
|
||||
BackLabel="All cuts"
|
||||
ShowShareRow="false">
|
||||
<Header>
|
||||
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
|
||||
<div class="cut-detail-header">
|
||||
<div class="cut-detail-meta">
|
||||
<MudText Typo="Typo.h3">@release.Title</MudText>
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@release.Artist</MudText>
|
||||
|
||||
@if (hasGenre || hasYear)
|
||||
{
|
||||
<div class="cut-detail-subline">
|
||||
@if (hasGenre)
|
||||
{
|
||||
<span class="cut-detail-genre">@release.Genre</span>
|
||||
}
|
||||
@if (hasGenre && hasYear)
|
||||
{
|
||||
<span class="cut-detail-sep">·</span>
|
||||
}
|
||||
@if (hasYear)
|
||||
{
|
||||
<span class="cut-detail-year">@release.ReleaseDate!.Value.Year</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cut-detail-actions">
|
||||
@* Header Play starts the album's first track. Wired to the single-slot player
|
||||
today; the §3.4 queue seam means a future swap to QueueService.PlayRelease
|
||||
is a one-line change inside PlayAlbum, not a markup edit. Disabled until a
|
||||
streamable track is resolved. *@
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.PlayArrow"
|
||||
Disabled="@(firstTrack is null || !RendererInfo.IsInteractive)"
|
||||
OnClick="@PlayAlbum">
|
||||
Play
|
||||
</MudButton>
|
||||
|
||||
@if (firstTrack is not null)
|
||||
{
|
||||
<SharePopover EntryKey="@firstTrack.EntryKey" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cut-detail-cover">
|
||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
|
||||
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
<BodyContent>
|
||||
<MudDivider Class="cut-detail-divider" />
|
||||
@if (ViewModel.Tracks.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="cut-detail-empty">No tracks in this cut yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cut-detail-tracklist">
|
||||
@foreach (var track in ViewModel.Tracks)
|
||||
{
|
||||
<div class="cut-detail-track-row">
|
||||
<span class="cut-detail-track-number">@track.TrackNumber</span>
|
||||
<div class="cut-detail-track-play">
|
||||
<PlayStateIcon Track="@track"
|
||||
Size="Size.Medium"
|
||||
Color="Color.Secondary"
|
||||
OnToggle="@(() => PlayTrack(track))" />
|
||||
</div>
|
||||
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</BodyContent>
|
||||
</ReleaseDetailScaffold>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
// Header Play: start the album's first track. The §3.4 queue seam lives here — swapping this body
|
||||
// to `Queue.PlayRelease(ViewModel.Tracks)` once IQueueService (track 11.F) lands is a one-line
|
||||
// change with no other edit to this page. The queue type is not referenced here because it does
|
||||
// not exist in this worktree.
|
||||
private Task PlayAlbum()
|
||||
{
|
||||
var first = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
|
||||
return first is null ? Task.CompletedTask : PlayTrack(first);
|
||||
}
|
||||
|
||||
// Row play: toggle if this track is already active, otherwise start a fresh stream. Mirrors the
|
||||
// scaffold's own PlayTrack wiring (SessionDetail uses the same idiom for its diverged layout).
|
||||
private async Task PlayTrack(TrackDto track)
|
||||
{
|
||||
if (PlayerService is null) return;
|
||||
|
||||
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
|
||||
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Load + prerender-bridge logic for the Cut album-detail page (/cuts/{id}). Mirrors
|
||||
/// <see cref="ReleaseDetailBase"/>'s discipline (id-addressed load in OnParametersSetAsync,
|
||||
/// PersistentComponentState bridge guarded on id) but carries the multi-track payload (release +
|
||||
/// ordered track list) the Cut page needs. Kept separate from the single-track base so neither
|
||||
/// grows a medium conditional — the two release shapes are genuinely different (one track vs many).
|
||||
/// </summary>
|
||||
public abstract class CutDetailBase : ComponentBase, IDisposable
|
||||
{
|
||||
private const string PersistKey = "cut-detail";
|
||||
|
||||
[Parameter] public long Id { get; set; }
|
||||
[Inject] public required CutDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
// The release id the ViewModel currently holds — tracks param-only navigations (e.g.
|
||||
// /cuts/5 -> /cuts/8) which reuse this component instance and fire OnParametersSet without
|
||||
// re-running OnInitialized. Without it the page would keep the prior album's tracks.
|
||||
private long _loadedId;
|
||||
private bool _loaded;
|
||||
|
||||
protected override void OnInitialized()
|
||||
=> _persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (_loaded && _loadedId == Id) return;
|
||||
|
||||
// Capture the id synchronously before any await so a re-entrant call (rapid navigation or a
|
||||
// re-render that changes Id while Load is in flight) sees the correct guard state.
|
||||
_loadedId = Id;
|
||||
_loaded = true;
|
||||
|
||||
// The bridged payload carries the release and its ordered tracks so the interactive pass
|
||||
// renders identically without a second round-trip. Guard on the id: a payload for a different
|
||||
// release must not seed this page (stale-bridge bleed across navigation).
|
||||
if (PersistentState.TryTakeFromJson<BridgedCut>(PersistKey, out var restored)
|
||||
&& restored?.Release is not null
|
||||
&& restored.Release.Id == Id)
|
||||
{
|
||||
ViewModel.Restore(restored.Release, restored.Tracks);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ViewModel.Load(Id);
|
||||
}
|
||||
}
|
||||
|
||||
private Task Persist()
|
||||
{
|
||||
if (ViewModel.Release is not null)
|
||||
PersistentState.PersistAsJson(PersistKey, new BridgedCut(ViewModel.Release, ViewModel.Tracks));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
|
||||
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
|
||||
protected sealed record BridgedCut(ReleaseDto Release, IReadOnlyList<TrackDto> Tracks);
|
||||
}
|
||||
Reference in New Issue
Block a user