Files
daniel-c-harvey f976af0f7c fix(seo): escape inline JSON-LD, per-release byArtist, soft-404 + env-gated noindex
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.
2026-06-23 06:10:03 -04:00

157 lines
8.2 KiB
Plaintext

@page "/mixes/{EntryKey}"
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase
@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">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 mixTracks = ViewModel.Track is not null ? new[] { ViewModel.Track } : null;
@* SEO head — fed from the same bridged release + single track, so prerender and WASM render identical
tags (AC6). MusicRecording with ISO-8601 duration from the track (§3.4). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release, mixTracks)" />
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
playback only when the player is on this mix's track; TrackEntryKey is the datum to render at rest
(before playback) — the mix's single track, so the lava shows immediately on page load (§4). *@
<WaveformVisualizer ReleaseEntryKey="@release.EntryKey"
TrackId="@ViewModel.Track?.Id"
TrackEntryKey="@ViewModel.Track?.EntryKey" />
<div class="mix-detail-foreground dd-detail-fill">
<MudContainer MaxWidth="MaxWidth.Large" Class="mix-detail-container">
@* Mix keeps the scaffold solely for the Phase 10 top row (back link | controls | lava-lamp).
Title/artist/genre/date/share/play all move into the overlaid hero, so the scaffold's
default header and meta regions are suppressed (ShowHeader/ShowMeta=false) and the share
row stays off (ShowShareRow=false). *@
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@ViewModel.Track"
BackHref="/mixes"
BackLabel="All mixes"
ShowHeader="false"
ShowMeta="false"
ShowShareRow="false">
<TopRightAction>
@* Theater toggle sits immediately LEFT of the lava-lamp popover (Phase 20 §3). Both stay
visible in Theater Mode — controls over the experience, not release content (§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 Mix is the currently-playing release
(Phase 20 Wave 2 §3). ShowTheaterToggle folds in the subsystem + release-playing gate. *@
<TheaterModeToggle Available="ShowTheaterToggle" />
@* Lava-lamp icon → popover panel, top-right across from the back link (Phase 12
§3d-revised). Replaces the former inline TopContent knob-bar: the icon IS the toggle
and the popover IS the panel. Mix takes the cleanest anchor case (§8e) — the popover's
default bottom-right anchor opens down over the full-bleed field. *@
<WaveformVisualizerControlPopover />
</div>
</TopRightAction>
<Hero>
@* Theater Mode (Phase 20 §4, Wave 2 §2): the hero overlay stays mounted and eases out via
a collapsing wrapper so it does not pop — collapsed to zero height when Theater is on AND
this Mix is the playing release. OFF eases the full-bleed visualizer back behind the hero. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Cover-as-background hero with all metadata overlaid, square `mix-hero` sizing. The
cover art IS the background, so no separate cover thumbnail (CoverThumbKey defaults
to null). Share and play ride in as slots, matching Sessions. *@
<ReleaseHeroOverlay Class="mix-hero"
HeroImageKey="@release.ImagePath"
PlaceholderIcon="@Icons.Material.Filled.Album"
Title="@release.Title"
Artist="@release.Artist"
Genre="@release.Genre"
ReleaseDate="@release.ReleaseDate">
<ShareContent>
@* Release-mode share: copies the canonical /mixes/{entryKey} URL, not a single track (§3b). *@
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
</ShareContent>
<PlayContent>
@if (ViewModel.Track is not null)
{
<PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
@* Append-only: queues the mix's single track without starting playback. *@
<AddToQueueButton Track="@ViewModel.Track" Size="Size.Large" />
}
</PlayContent>
</ReleaseHeroOverlay>
</div>
</div>
</Hero>
<BodyContent>
@* Theater Mode (Wave 2 §2): eased collapse, mirroring the Hero region. *@
<div class="dd-theater-collapsible @(IsContentHidden ? "dd-theater-collapsed" : null)">
<div class="dd-theater-collapsible-inner">
@* Blurb sits below the hero, inside the scaffold's foreground stacking context. *@
<ReleaseDescription Description="@release.Description" />
</div>
</div>
</BodyContent>
</ReleaseDetailScaffold>
</MudContainer>
</div>
}
@code {
protected override string PersistKey => "mix-detail";
// PlayerService is cascaded by ReleaseDetailBase (used there for the Theater release-playing predicate).
[CascadingParameter] public IQueueService? Queue { get; set; }
// The hero now carries the play affordance (the scaffold's header is suppressed), so the
// play-toggle is wired here directly — mirroring SessionDetail. Toggle if this track is already
// active, otherwise PLAY it: prepend to the queue's front (deque PLAY semantics) so it becomes
// current and the existing queue stays intact behind it. Falls back to a direct stream when the
// queue cascade is absent (prerender / non-interactive).
private async Task PlayTrack()
{
var track = ViewModel.Track;
if (track is null || PlayerService is null) return;
var isThisTrack = PlayerService.CurrentTrack?.Id == track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(track);
}
else
{
await PlayerService.SelectTrackStreaming(track);
}
}
}