f976af0f7c
Escape </>& in JSON-LD body to kill script-breakout; byArtist now uses the release artist; detail-page not-found branches emit noindex; default robots gated to Production via a PersistentState SeoEnvironment bridge.
227 lines
11 KiB
Plaintext
227 lines
11 KiB
Plaintext
@page "/cuts/{EntryKey}"
|
|
@using DeepDrftModels.DTOs
|
|
@using DeepDrftPublic.Client.Controls
|
|
@using DeepDrftPublic.Client.Services
|
|
@inherits CutDetailBase
|
|
@inject SeoOptions Seo
|
|
|
|
@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)
|
|
{
|
|
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
|
|
(mirrors the dedicated /404 NotFound page). *@
|
|
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
|
|
<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;
|
|
|
|
@* SEO head — fed from the same bridged release + ordered tracks, so the prerender and WASM passes
|
|
render identical tags (AC6). MusicAlbum/StudioAlbum with the ordered track list (§3.4). *@
|
|
<SeoHead Model="@SeoModel.ForRelease(Seo, release, ViewModel.Tracks)" />
|
|
|
|
@* 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">·</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);
|
|
}
|
|
}
|
|
}
|