Files
deepdrft/DeepDrftPublic.Client/Pages/CutDetail.razor
T

221 lines
11 KiB
Plaintext

@page "/cuts/{EntryKey}"
@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;
@* Full-screen content body (Phase 20 Wave 2 §1): the scaffold has no Class param, so a thin wrapper
carries the min-height. dd-detail-fill keeps the body >= viewport height (below the nav) so the
ambient visualizer reads full-screen and the site footer is pushed below the fold. *@
<div class="dd-detail-fill">
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@firstTrack"
BackHref="/cuts"
BackLabel="All cuts"
ShowShareRow="false">
<Ambient>
@* Ambient living waveform behind the album hero + track list (Phase 12 §3c/§3f mode B).
Cut is multi-track: anchor to the release's EntryKey and default to the first track by
TrackNumber. The bridge follows the live playing track within the release automatically
(keys on TrackId match OR shared ReleaseEntryKey), so the field re-renders to whichever
track the listener starts; TrackEntryKey is the at-rest datum before playback. *@
<WaveformVisualizer ReleaseEntryKey="@release.EntryKey"
TrackId="@firstTrack?.Id"
TrackEntryKey="@firstTrack?.EntryKey" />
</Ambient>
<TopRightAction>
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both are
controls over the experience, not release content, so both stay in Theater Mode (§4/OQ4).
Wrapped so they cluster on the right rather than spreading across the SpaceBetween row. *@
<div class="dd-detail-top-actions">
@* Theater toggle only appears when this Cut is the currently-playing release (Phase 20
Wave 2 §3). ShowTheaterToggle folds in the subsystem gate + the release-playing check. *@
<TheaterModeToggle Available="ShowTheaterToggle" />
@* Lava-lamp icon → popover panel (full parity, §3d-revised). Sits top-right across from the
back link, clear of the header's own Play/Share affordances below. *@
<WaveformVisualizerControlPopover />
</div>
</TopRightAction>
<Header>
@* Theater Mode (Phase 20 §4, Wave 2 §2): the release content stays mounted and eases out via
a collapsing wrapper so it does not pop — IsContentHidden collapses it to zero height when
Theater is on AND this Cut is the playing release. OFF eases it back to its normal layout. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Header split: meta + Play/Share on the LEFT, bordered cover on the RIGHT (spec §3.1). *@
<div class="cut-detail-header">
<div class="cut-detail-meta">
<MudText Typo="Typo.h3">@release.Title</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@release.Artist</MudText>
@if (hasGenre || hasYear)
{
<div class="cut-detail-subline">
@if (hasGenre)
{
<span class="cut-detail-genre">@release.Genre</span>
}
@if (hasGenre && hasYear)
{
<span class="cut-detail-sep">&middot;</span>
}
@if (hasYear)
{
<span class="cut-detail-year">@release.ReleaseDate!.Value.Year</span>
}
</div>
}
<div class="cut-detail-actions dd-accent-icon dd-accent-fill">
@* Header Play loads the full album into the queue at index 0 (§3.4 seam,
closed P11 W1). Disabled until at least one 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>
@* Append the whole album (TrackNumber order) to the queue — same ordered list
header Play uses. Append-only: does not start playback (AC7/AC8). *@
<AddToQueueButton ReleaseTracks="@ViewModel.Tracks" />
@* Release-mode share: copies the canonical /cuts/{entryKey} URL, not a single track (§3b). *@
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
</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>
</div>
</div>
</Header>
<BodyContent>
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Header region. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Blurb sits between the header and the track-list divider. *@
<ReleaseDescription Description="@release.Description" />
<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">
@for (var i = 0; i < ViewModel.Tracks.Count; i++)
{
var track = ViewModel.Tracks[i];
var index = i;
<div class="cut-detail-track-row dd-accent-icon">
<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, index))" />
</div>
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
@* Append this single track to the queue (append-only, does not play). *@
<AddToQueueButton Track="@track" />
<SharePopover EntryKey="@track.EntryKey" />
</div>
}
</div>
}
</div>
</div>
</BodyContent>
</ReleaseDetailScaffold>
</div>
}
@code {
// PlayerService is cascaded by CutDetailBase (used there for the Theater release-playing predicate).
[CascadingParameter] public IQueueService? Queue { get; set; }
// Header Play: load the full album into the queue starting at track 0.
private Task PlayAlbum()
{
if (ViewModel.Tracks.Count == 0) return Task.CompletedTask;
if (Queue is not null) return Queue.PlayRelease(ViewModel.Tracks, 0);
// Queue cascade absent (prerender or non-interactive): fall back to direct single-track play.
return PlayerService is not null
? PlayerService.SelectTrackStreaming(ViewModel.Tracks[0])
: Task.CompletedTask;
}
// Row play: toggle if this track is already playing/paused, otherwise load the album at this
// row's index so playback continues to the end from the chosen track.
private async Task PlayTrack(TrackDto track, int index)
{
if (PlayerService is null) return;
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
return;
}
if (Queue is not null)
{
await Queue.PlayRelease(ViewModel.Tracks, index);
}
else
{
// Queue cascade absent: fall back to direct single-track play.
await PlayerService.SelectTrackStreaming(track);
}
}
}