Merge p11-w3-t1-retire-normalize into dev (P11 11.C: retire track-cardinal stack, fold Archive/Cuts cards into ReleaseGallery)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* Single space-between row under the waveform: identity on the left, accents on the right.
|
||||
Colours come from the MudBlazor theme (the dock surface is theme-aware), so unlike the
|
||||
always-dark TrackCard glass we do not hard-code green-accent overrides here. */
|
||||
Colours come from the MudBlazor theme (the dock surface is theme-aware), so we do not
|
||||
hard-code green-accent overrides here. */
|
||||
.track-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
<div class="hero-actions @AnimClass">
|
||||
<StreamNowButton ButtonClass="btn-primary" ButtonLabel="Start Streaming" />
|
||||
<a class="btn-ghost" href="/tracks">Browse Tracks</a>
|
||||
<a class="btn-ghost" href="/archive">Browse Tracks</a>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
public enum GalleryViewMode
|
||||
{
|
||||
Grid,
|
||||
List
|
||||
}
|
||||
@@ -38,7 +38,7 @@ public partial class PlayStateIcon : ComponentBase, IDisposable
|
||||
{
|
||||
// The cascade is IsFixed, so the provider's re-render never reaches us; subscribe to the
|
||||
// multicast side-channel to re-render on every player state change. Reference-guarded so
|
||||
// re-parametering is idempotent. Mirrors AudioPlayerBar / TracksView.
|
||||
// re-parametering is idempotent. Mirrors AudioPlayerBar.
|
||||
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
|
||||
{
|
||||
if (_subscribedService != null)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@* 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;
|
||||
hero visual and metadata block. The Cut/Session/Mix detail pages all compose this;
|
||||
per-medium variance rides the Hero and MetaContent render fragments. *@
|
||||
|
||||
<div class="deepdrft-track-detail-container">
|
||||
|
||||
@@ -7,8 +7,8 @@ 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.
|
||||
/// detail page supplies only its data and medium-specific visuals. Each medium's detail page is a
|
||||
/// thin consumer of this scaffold.
|
||||
/// </summary>
|
||||
public partial class ReleaseDetailScaffold : ComponentBase
|
||||
{
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
@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. *@
|
||||
@* The single release-card grid for every browse surface (Sessions, Mixes, Cuts, Archive). Cards
|
||||
open a detail page; how a card computes its href is the only real divergence across surfaces, so
|
||||
the parent supplies it one of two ways:
|
||||
- DetailRoute (the simple default): every card links /{DetailRoute}/{id} (Sessions, Mixes).
|
||||
- HrefResolver (per-card): each card links HrefResolver(release), so Archive routes each card by
|
||||
its own medium through the one ReleaseRoutes table, and Cuts routes to /cuts/{id}.
|
||||
HrefResolver wins when both are supplied. The card subtitle defaults to the artist; SubtitleResolver
|
||||
overrides it (Cuts shows a track count instead). Fully controlled by the parent: loading and item
|
||||
state are passed in. *@
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="release-gallery-container">
|
||||
@@ -33,7 +38,7 @@
|
||||
{
|
||||
<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">
|
||||
<a href="@CardHref(release)" class="release-card-link">
|
||||
<div class="release-card">
|
||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||
{
|
||||
@@ -51,7 +56,7 @@
|
||||
@release.Title
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="release-card-artist text-truncate">
|
||||
@release.Artist
|
||||
@(SubtitleResolver?.Invoke(release) ?? release.Artist)
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,8 +73,27 @@
|
||||
[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; }
|
||||
/// <summary>
|
||||
/// Route segment for a card's detail page; a card links to /{DetailRoute}/{id}. The simple
|
||||
/// fixed-route default used by Sessions/Mixes. Ignored when <see cref="HrefResolver"/> is supplied.
|
||||
/// </summary>
|
||||
[Parameter] public string? DetailRoute { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-card href resolver. When supplied, a card links to its result instead of the
|
||||
/// <see cref="DetailRoute"/>-based href, letting Archive route each card by its own medium and
|
||||
/// Cuts route to /cuts/{id} (both via <c>ReleaseRoutes.DetailHref</c>).
|
||||
/// </summary>
|
||||
[Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? HrefResolver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for a card's subtitle line (defaults to the release artist). Cuts pass a
|
||||
/// track-count label here.
|
||||
/// </summary>
|
||||
[Parameter] public Func<DeepDrftModels.DTOs.ReleaseDto, string>? SubtitleResolver { get; set; }
|
||||
|
||||
[Parameter] public string EmptyMessage { get; set; } = "Nothing here yet";
|
||||
|
||||
private string CardHref(DeepDrftModels.DTOs.ReleaseDto release)
|
||||
=> HrefResolver?.Invoke(release) ?? $"/{DetailRoute}/{release.Id}";
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
@{
|
||||
var hasLink = !string.IsNullOrEmpty(TrackModel?.EntryKey);
|
||||
var trackHref = hasLink ? $"/track/{TrackModel!.EntryKey}" : null;
|
||||
var hasArt = !string.IsNullOrEmpty(TrackModel?.Release?.ImagePath);
|
||||
}
|
||||
|
||||
@if (ViewMode == GalleryViewMode.Grid)
|
||||
{
|
||||
<div class="deepdrft-track-card-container @(hasArt ? "deepdrft-track-card-container--art" : "")">
|
||||
|
||||
@* Cover and title/artist link to the detail page; the play button (below, outside any
|
||||
anchor) stays the sole playback entry point. display:contents keeps the grid intact. *@
|
||||
@if (hasLink)
|
||||
{
|
||||
<a href="@trackHref" class="deepdrft-track-card-link">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-card-fallback"></div>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-card-bg" style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-card-fallback"></div>
|
||||
}
|
||||
|
||||
<div class="deepdrft-track-card-content">
|
||||
|
||||
@if (hasLink)
|
||||
{
|
||||
<a href="@trackHref" class="deepdrft-track-card-link">
|
||||
<div class="deepdrft-track-info-top">
|
||||
<MudText Typo="Typo.subtitle1"
|
||||
Class="deepdrft-track-title text-truncate mb-1">
|
||||
@TrackModel?.TrackName
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-artist text-truncate mb-2">
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-info-top">
|
||||
<MudText Typo="Typo.subtitle1"
|
||||
Class="deepdrft-track-title text-truncate mb-1">
|
||||
@TrackModel?.TrackName
|
||||
</MudText>
|
||||
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-artist text-truncate mb-2">
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="deepdrft-track-info-middle">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Title))
|
||||
{
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-meta text-truncate">
|
||||
@TrackModel.Release!.Title
|
||||
</MudText>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@TrackModel.Release!.Genre
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="deepdrft-track-info-bottom">
|
||||
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption"
|
||||
Class="deepdrft-track-meta">
|
||||
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div></div>
|
||||
}
|
||||
|
||||
<MudFab Color="Color.Tertiary"
|
||||
Size="Size.Medium"
|
||||
StartIcon="@PlayPauseIcon"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@PlayClick"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-row @(IsPlaying ? "deepdrft-track-row--playing" : "")">
|
||||
|
||||
<MudFab Color="Color.Tertiary"
|
||||
Size="Size.Medium"
|
||||
StartIcon="@PlayPauseIcon"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@PlayClick"
|
||||
Class="deepdrft-track-row-fab"/>
|
||||
|
||||
@if (hasLink)
|
||||
{
|
||||
<a href="@trackHref" class="deepdrft-track-row-link">
|
||||
@* art thumb *@
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb deepdrft-track-row-thumb--fallback"></div>
|
||||
}
|
||||
|
||||
@* text block *@
|
||||
<div class="deepdrft-track-row-text">
|
||||
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
||||
@TrackModel?.TrackName
|
||||
</MudText>
|
||||
</div>
|
||||
|
||||
@* right metadata *@
|
||||
<div class="deepdrft-track-row-meta">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@TrackModel.Release!.Genre
|
||||
</MudChip>
|
||||
}
|
||||
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
||||
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* same structure without anchor *@
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.ImagePath))
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(TrackModel.Release!.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-track-row-thumb deepdrft-track-row-thumb--fallback"></div>
|
||||
}
|
||||
<div class="deepdrft-track-row-text">
|
||||
<MudText Typo="Typo.subtitle2" Class="deepdrft-track-title text-truncate">
|
||||
@TrackModel?.Release?.Artist
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta text-truncate">
|
||||
@TrackModel?.TrackName
|
||||
</MudText>
|
||||
</div>
|
||||
<div class="deepdrft-track-row-meta">
|
||||
@if (!string.IsNullOrEmpty(TrackModel?.Release?.Genre))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@TrackModel.Release!.Genre
|
||||
</MudChip>
|
||||
}
|
||||
@if (TrackModel?.Release?.ReleaseDate.HasValue == true)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="deepdrft-track-meta">
|
||||
@TrackModel.Release!.ReleaseDate!.Value.Year
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
public partial class TrackCard : ComponentBase
|
||||
{
|
||||
[Parameter] public required TrackDto TrackModel { get; set; }
|
||||
[Parameter] public EventCallback<TrackDto> OnPlay { get; set; }
|
||||
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
|
||||
[Parameter] public bool IsPlaying { get; set; } = false;
|
||||
[Parameter] public bool IsPaused { get; set; } = false;
|
||||
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
|
||||
|
||||
// Pause only when actively playing; every other state (idle, paused) reads as "press to play".
|
||||
private bool IsActivelyPlaying => IsPlaying && !IsPaused;
|
||||
|
||||
private string PlayPauseIcon =>
|
||||
IsActivelyPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
|
||||
|
||||
private async Task PlayClick()
|
||||
{
|
||||
if (IsActivelyPlaying)
|
||||
{
|
||||
if (OnPause.HasDelegate)
|
||||
await OnPause.InvokeAsync(TrackModel);
|
||||
}
|
||||
else if (OnPlay.HasDelegate)
|
||||
{
|
||||
await OnPlay.InvokeAsync(TrackModel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/* Container — transparent so the absolute-positioned fallback panel or album art
|
||||
controls the card's background. Glass edge matches NowPlayingCard vocabulary. */
|
||||
.deepdrft-track-card-container {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
min-width: 250px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border: 2px solid var(--mud-palette-secondary);
|
||||
}
|
||||
|
||||
.deepdrft-track-card-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: brightness(0.7);
|
||||
}
|
||||
|
||||
.deepdrft-track-card-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: linear-gradient(to top,
|
||||
rgba(13, 27, 42, 0.75) 0%,
|
||||
rgba(13, 27, 42, 0.35) 45%,
|
||||
rgba(13, 27, 42, 0.00) 100%);
|
||||
}
|
||||
|
||||
/* Fallback panel — solid navy, opaque so the card reads correctly on both
|
||||
light and dark page backgrounds. Semi-transparent + blur washes out on white. */
|
||||
.deepdrft-track-card-fallback {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: var(--deepdrft-navy-mid, #162437);
|
||||
border: 1px solid rgba(250, 250, 248, 0.12);
|
||||
}
|
||||
|
||||
/* Title: off-white — matches .np-title.
|
||||
::deep required: MudText renders its own element, so Blazor isolation
|
||||
won't stamp b-{hash} on it; ::deep pierces into child component output. */
|
||||
::deep .deepdrft-track-title { color: var(--deepdrft-white, #FAFAF8); }
|
||||
|
||||
/* Artist: muted off-white — green reserved for interactive elements (FAB, chip). ::deep for same reason. */
|
||||
::deep .deepdrft-track-artist { color: rgba(250, 250, 248, 0.55); }
|
||||
|
||||
/* Meta: muted off-white — matches .np-sub. ::deep for same reason. */
|
||||
::deep .deepdrft-track-meta { color: rgba(250, 250, 248, 0.45); }
|
||||
|
||||
/* FAB always green-interactive — card is always dark glass regardless of page theme.
|
||||
.mud-button-filled-tertiary specificity (0,1,0) in MudBlazor; our (0,1,1) wins. */
|
||||
::deep .mud-button-filled-tertiary {
|
||||
background-color: var(--deepdrft-green-interactive, #3aa163);
|
||||
color: var(--deepdrft-white, #FAFAF8);
|
||||
}
|
||||
|
||||
/* Genre chip always green-accent outline/text on the dark glass card. */
|
||||
::deep .deepdrft-genre-chip.mud-chip-outlined {
|
||||
border-color: var(--deepdrft-green-accent, #3D7A68);
|
||||
color: var(--deepdrft-green-accent, #3D7A68);
|
||||
}
|
||||
::deep .deepdrft-genre-chip.mud-chip-color-tertiary {
|
||||
color: var(--deepdrft-green-accent, #3D7A68);
|
||||
}
|
||||
|
||||
.deepdrft-track-info-middle { margin: 8px 0; }
|
||||
|
||||
.deepdrft-track-info-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.deepdrft-track-card-container {
|
||||
min-width: 200px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mode A: hover-reveal overlay (art cards only) ──────────────────────── */
|
||||
|
||||
/* Gate the hidden-at-rest rule on (a) art present and (b) a hover-capable pointer.
|
||||
Fallback cards (no --art modifier) and touch devices always show the overlay. */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.deepdrft-track-card-container--art .deepdrft-track-card-content {
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
transition: opacity 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
.deepdrft-track-card-container--art:hover .deepdrft-track-card-content {
|
||||
opacity: 1;
|
||||
background: rgba(22, 36, 55, 0.82);
|
||||
transition: opacity 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mode B: list row ───────────────────────────────────────────────────── */
|
||||
|
||||
.deepdrft-track-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 80px;
|
||||
padding: 8px 16px;
|
||||
background: var(--mud-palette-surface);
|
||||
border: 1px solid var(--mud-palette-divider);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.deepdrft-track-row-link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::deep .deepdrft-track-row-fab {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.deepdrft-track-row-thumb {
|
||||
flex: 0 0 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.deepdrft-track-row-thumb--fallback {
|
||||
background: var(--deepdrft-navy-mid);
|
||||
border: 1px solid var(--mud-palette-divider);
|
||||
}
|
||||
|
||||
.deepdrft-track-row-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.deepdrft-track-row-meta {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.deepdrft-track-row {
|
||||
height: auto;
|
||||
min-height: 72px;
|
||||
padding: 8px 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.deepdrft-track-row-thumb {
|
||||
flex: 0 0 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.deepdrft-track-row--playing {
|
||||
border-left: 3px solid var(--deepdrft-green-interactive, #3aa163);
|
||||
}
|
||||
|
||||
/* ── Mode B text: theme-aware overrides (navy on light / off-white on dark) ─ */
|
||||
|
||||
/* The global ::deep rules above hard-code off-white for the dark glass grid cards.
|
||||
List rows use --mud-palette-surface as their background, so text must follow
|
||||
the theme. These selectors have higher specificity (.deepdrft-track-row[b-hash]
|
||||
vs plain [b-hash]) and win in the cascade. */
|
||||
.deepdrft-track-row ::deep .deepdrft-track-title,
|
||||
.deepdrft-track-row ::deep .deepdrft-track-artist,
|
||||
.deepdrft-track-row ::deep .deepdrft-track-meta {
|
||||
color: var(--mud-palette-text-primary);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
@if (ViewMode == GalleryViewMode.Grid)
|
||||
{
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="tracks-gallery-container">
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var track in Tracks)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="deepdrft-track-gallery-item-center">
|
||||
<TrackCard TrackModel="@track"
|
||||
ViewMode="@ViewMode"
|
||||
IsPlaying="@(IsPlaying && ActiveTrack?.Id == track.Id)"
|
||||
IsPaused="@(IsPaused && ActiveTrack?.Id == track.Id)"
|
||||
OnPlay="@HandlePlayClick"
|
||||
OnPause="@HandlePauseClick"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudContainer MaxWidth="MaxWidth.Large">
|
||||
<div class="deepdrft-track-list">
|
||||
@foreach (var track in Tracks)
|
||||
{
|
||||
<TrackCard TrackModel="@track"
|
||||
ViewMode="@ViewMode"
|
||||
IsPlaying="@(IsPlaying && ActiveTrack?.Id == track.Id)"
|
||||
IsPaused="@(IsPaused && ActiveTrack?.Id == track.Id)"
|
||||
OnPlay="@HandlePlayClick"
|
||||
OnPause="@HandlePauseClick"/>
|
||||
}
|
||||
</div>
|
||||
</MudContainer>
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
public partial class TracksGallery : ComponentBase
|
||||
{
|
||||
[Parameter] public IEnumerable<TrackDto> Tracks { get; set; } = [];
|
||||
|
||||
// Controlled play-state inputs: the parent owns playback truth (the player service)
|
||||
// and drives these. The gallery is presentational — it only matches by id to decide
|
||||
// which card reflects the active state.
|
||||
[Parameter] public TrackDto? ActiveTrack { get; set; }
|
||||
[Parameter] public bool IsPlaying { get; set; }
|
||||
[Parameter] public bool IsPaused { get; set; }
|
||||
[Parameter] public GalleryViewMode ViewMode { get; set; } = GalleryViewMode.Grid;
|
||||
|
||||
[Parameter] public EventCallback<TrackDto> OnPlay { get; set; }
|
||||
[Parameter] public EventCallback<TrackDto> OnPause { get; set; }
|
||||
|
||||
private Task HandlePlayClick(TrackDto track) =>
|
||||
OnPlay.HasDelegate ? OnPlay.InvokeAsync(track) : Task.CompletedTask;
|
||||
|
||||
private Task HandlePauseClick(TrackDto track) =>
|
||||
OnPause.HasDelegate ? OnPause.InvokeAsync(track) : Task.CompletedTask;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.tracks-gallery-container {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.deepdrft-track-gallery-item-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.deepdrft-track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -22,8 +22,8 @@ public static class Pages
|
||||
// ARCHIVE (→ the release-cardinal /archive browser) carries the three medium modes as Children.
|
||||
// Above the medium breakpoint the desktop nav flattens them into inline appbar links beside
|
||||
// ARCHIVE (no popover); below the breakpoint the mobile hamburger renders them as an indented
|
||||
// sub-list under ARCHIVE. /tracks and /genres are intentionally absent from the nav (8.I) —
|
||||
// their routes (TracksView, GenresView) remain reachable by direct URL.
|
||||
// sub-list under ARCHIVE. /genres is intentionally absent from the nav (8.I) — its route
|
||||
// (GenresView) remains reachable by direct URL.
|
||||
public static readonly List<PageRoute> MenuPages =
|
||||
[
|
||||
new()
|
||||
|
||||
@@ -1,63 +1,12 @@
|
||||
@page "/cuts"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<PageTitle>DeepDrft Cuts</PageTitle>
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-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="album-card-center">
|
||||
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
else if (_albums.Count == 0)
|
||||
{
|
||||
<div class="albums-empty">
|
||||
<MudText Typo="Typo.h6">No albums yet</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var album in _albums)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="album-card-center">
|
||||
<div class="album-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@onclick="@(() => OpenAlbum(album))">
|
||||
@if (!string.IsNullOrEmpty(album.ImagePath))
|
||||
{
|
||||
<div class="album-card-cover"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(album.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="album-card-cover album-card-cover--fallback"></div>
|
||||
}
|
||||
|
||||
<div class="album-card-body">
|
||||
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
|
||||
@album.Title
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="album-card-count">
|
||||
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
@* The shared release-card grid; each card routes to /cuts/{id} via the one ReleaseRoutes table.
|
||||
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
|
||||
<ReleaseGallery Releases="@_albums"
|
||||
Loading="@_loading"
|
||||
HrefResolver="@ReleaseRoutes.DetailHref"
|
||||
SubtitleResolver="@TrackCountLabel"
|
||||
EmptyMessage="No albums yet" />
|
||||
|
||||
@@ -19,7 +19,6 @@ public partial class AlbumsView : ComponentBase, IDisposable
|
||||
|
||||
[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
|
||||
@@ -34,8 +33,8 @@ public partial class AlbumsView : ComponentBase, IDisposable
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Bridge the prerendered fetch across the prerender -> WASM seam (see TracksView). Without
|
||||
// this, the WASM pass re-fetches and replays the card entrance animations.
|
||||
// Bridge the prerendered fetch across the prerender -> WASM seam (see MediumBrowseBase).
|
||||
// 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)
|
||||
@@ -59,8 +58,9 @@ public partial class AlbumsView : ComponentBase, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OpenAlbum(ReleaseDto album)
|
||||
=> Navigation.NavigateTo(ReleaseRoutes.DetailHref(album));
|
||||
// Cut cards show track count where the shared card otherwise shows the artist.
|
||||
private static string TrackCountLabel(ReleaseDto album)
|
||||
=> $"{album.TrackCount} {(album.TrackCount == 1 ? "track" : "tracks")}";
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
.albums-view-container {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.album-card-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.album-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.album-card-cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.album-card-cover--fallback {
|
||||
background-color: var(--mud-palette-dark, #1a2238);
|
||||
}
|
||||
|
||||
.album-card-body {
|
||||
padding: 8px 4px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* album-card-title / album-card-count ride on MudText, a child Razor component whose
|
||||
root Blazor isolation does not scope-stamp; ::deep pierces into its output. */
|
||||
::deep .album-card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::deep .album-card-count {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.albums-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
@page "/archive"
|
||||
@using DeepDrftModels.Enums
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<PageTitle>DeepDrft Archive</PageTitle>
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
||||
@* Search + filter affordances are interactive-only: the debounce timer and chip selection
|
||||
need WASM. During prerender/non-interactive they are hidden, matching TracksView's gate.
|
||||
need WASM. During prerender/non-interactive they are hidden behind the interactive gate.
|
||||
The release grid still prerenders so the archive is meaningful before hydration. *@
|
||||
@if (RendererInfo.IsInteractive)
|
||||
{
|
||||
@@ -59,60 +60,12 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@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="archive-card-center">
|
||||
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
else if (_releases.Count == 0)
|
||||
{
|
||||
<div class="archive-empty">
|
||||
<MudText Typo="Typo.h6">No releases found</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="archive-card-center">
|
||||
<a href="@ReleaseRoutes.DetailHref(release)" class="archive-card-link">
|
||||
<div class="archive-release-card">
|
||||
@if (!string.IsNullOrEmpty(release.ImagePath))
|
||||
{
|
||||
<div class="archive-release-cover"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(release.ImagePath)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="archive-release-cover archive-release-cover--fallback"></div>
|
||||
}
|
||||
|
||||
<div class="archive-release-body">
|
||||
<MudText Typo="Typo.subtitle1" Class="archive-release-title text-truncate">
|
||||
@release.Title
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="archive-release-artist text-truncate">
|
||||
@release.Artist
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@* The grid itself is the shared release-card component; each card routes by its own medium through
|
||||
the one ReleaseRoutes table. The Archive-specific search/filter chrome above stays here. *@
|
||||
<ReleaseGallery Releases="@_releases"
|
||||
Loading="@_loading"
|
||||
HrefResolver="@ReleaseRoutes.DetailHref"
|
||||
EmptyMessage="No releases found" />
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,9 @@ namespace DeepDrftPublic.Client.Pages;
|
||||
/// The public archive: a release-cardinal searchable browser over every release across all media
|
||||
/// (Phase 9 §8.H, decision H2). Replaces the former three-card medium overview. Search (Title /
|
||||
/// Artist), an enum-driven medium filter, and a genre filter narrow the release list; each card
|
||||
/// routes to its per-medium detail. Mirrors the <see cref="TracksView"/> seam: the unfiltered first
|
||||
/// page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor replays
|
||||
/// the card entrance animations.
|
||||
/// routes to its per-medium detail. Mirrors the <see cref="MediumBrowseBase"/> seam: the unfiltered
|
||||
/// first page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor
|
||||
/// replays the card entrance animations.
|
||||
/// </summary>
|
||||
public partial class ArchiveView : ComponentBase, IDisposable
|
||||
{
|
||||
|
||||
@@ -50,63 +50,3 @@
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.archive-card-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.archive-card-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.archive-release-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.archive-release-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.archive-release-cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.archive-release-cover--fallback {
|
||||
background-color: var(--mud-palette-dark, #1a2238);
|
||||
}
|
||||
|
||||
.archive-release-body {
|
||||
padding: 8px 4px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* archive-release-title / archive-release-artist ride on MudText (child Razor component); ::deep
|
||||
pierces into its output since Blazor isolation does not scope-stamp child component roots. */
|
||||
::deep .archive-release-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::deep .archive-release-artist {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.archive-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
<p class="cta-sub">Immerse yourself. The current is always running.</p>
|
||||
</div>
|
||||
<div class="cta-actions">
|
||||
<a class="btn-white" href="/tracks">Explore the Archive</a>
|
||||
<a class="btn-white" href="/archive">Explore the Archive</a>
|
||||
@* TODO: route to /schedule when live-session schedule page exists *@
|
||||
<button class="btn-outline-white" type="button">View Live Schedule</button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace DeepDrftPublic.Client.Pages;
|
||||
/// 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).
|
||||
/// does not re-fetch and replay the card animations.
|
||||
/// </summary>
|
||||
public abstract class MediumBrowseBase : ComponentBase, IDisposable
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace DeepDrftPublic.Client.Pages;
|
||||
/// 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
|
||||
/// WASM seam so the WASM pass does not re-fetch (see the MediumBrowseBase seam). The playable track is
|
||||
/// re-resolved on a restore miss only.
|
||||
/// </summary>
|
||||
public abstract class ReleaseDetailBase : ComponentBase, IDisposable
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
@page "/track/{EntryKey}"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<PageTitle>@(ViewModel.Track?.TrackName ?? "Track") - 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 class="deepdrft-track-detail-meta">
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="30%" Height="24px" />
|
||||
<MudSkeleton SkeletonType="SkeletonType.Text" Width="25%" Height="24px" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ViewModel.NotFound)
|
||||
{
|
||||
<div class="deepdrft-track-detail-container">
|
||||
<div class="deepdrft-track-detail-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Track not found.</MudText>
|
||||
<MudText Typo="Typo.body2" Align="Align.Center" Color="Color.Secondary">
|
||||
This track may have been moved or removed.
|
||||
</MudText>
|
||||
<div class="d-flex justify-center mt-4">
|
||||
<MudButton Href="/tracks"
|
||||
Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack">
|
||||
All tracks
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ViewModel.Track is not null)
|
||||
{
|
||||
var track = ViewModel.Track;
|
||||
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);
|
||||
|
||||
<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>
|
||||
</Hero>
|
||||
<MetaContent>
|
||||
@if (hasMeta)
|
||||
{
|
||||
@if (release?.Title is not null)
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.overline">Album</MudText>
|
||||
<MudText Typo="Typo.body1">@release.Title</MudText>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (release?.Genre is not null)
|
||||
{
|
||||
<div>
|
||||
<MudChip T="string"
|
||||
Variant="Variant.Outlined"
|
||||
Color="Color.Tertiary"
|
||||
Class="deepdrft-genre-chip">
|
||||
@release.Genre
|
||||
</MudChip>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (release?.ReleaseDate is not null)
|
||||
{
|
||||
<div>
|
||||
<MudText Typo="Typo.overline">Released</MudText>
|
||||
<MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</MetaContent>
|
||||
</ReleaseDetailScaffold>
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
public partial class TrackDetail : ComponentBase, IDisposable
|
||||
{
|
||||
private const string PersistKey = "track-detail";
|
||||
|
||||
[Parameter] public required string EntryKey { get; set; }
|
||||
[Inject] public required TrackDetailViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
// The entry key the ViewModel currently holds. Tracks param-only navigations
|
||||
// (e.g. /track/A -> /track/B) which reuse this component instance and fire
|
||||
// OnParametersSet without re-running OnInitialized — without this, the page keeps
|
||||
// the prior track and Play streams the wrong audio.
|
||||
private string? _loadedEntryKey;
|
||||
|
||||
protected override void OnInitialized()
|
||||
// Carry the prerendered track across the prerender -> interactive (WASM) seam.
|
||||
// Without this, the WASM pass gets a fresh scoped ViewModel, re-renders the
|
||||
// skeleton, and re-fetches. Mirror the TracksView bridge: persist on the way
|
||||
// out of prerender, restore on the interactive pass, and only fetch on a miss.
|
||||
=> _persistingSubscription = PersistentState.RegisterOnPersisting(PersistTrack);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Re-run whenever the route key changes. Component instances are reused across
|
||||
// same-template navigations, so the load decision must live here, not in
|
||||
// OnInitialized (which fires once per instance).
|
||||
if (_loadedEntryKey == EntryKey) return;
|
||||
|
||||
// Capture the key synchronously before any await so that a re-entrant call
|
||||
// (rapid navigation or a re-render that changes EntryKey while Load is in flight)
|
||||
// sees the correct guard state. Without this, a second OnParametersSetAsync
|
||||
// for the same EntryKey would bypass the guard above and start a second Load,
|
||||
// causing two ViewModel.Load calls to race on the single scoped instance.
|
||||
_loadedEntryKey = EntryKey;
|
||||
|
||||
// Guard the bridge on the key: a payload for a different track must not seed this
|
||||
// page (stale-bridge bleed across navigation).
|
||||
if (PersistentState.TryTakeFromJson<TrackDto>(PersistKey, out var restored)
|
||||
&& restored is not null
|
||||
&& restored.EntryKey == EntryKey)
|
||||
{
|
||||
ViewModel.Track = restored;
|
||||
ViewModel.NotFound = false;
|
||||
ViewModel.IsLoading = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
await ViewModel.Load(EntryKey);
|
||||
}
|
||||
}
|
||||
|
||||
private Task PersistTrack()
|
||||
{
|
||||
if (ViewModel.Track is not null)
|
||||
{
|
||||
PersistentState.PersistAsJson(PersistKey, ViewModel.Track);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _persistingSubscription.Dispose();
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
@page "/tracks"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<PageTitle>DeepDrft Track Gallery</PageTitle>
|
||||
|
||||
<div>
|
||||
<div class="tracks-view-container">
|
||||
@* Search + filter affordances are interactive-only: the debounce timer and pill clear
|
||||
need WASM. During prerender/non-interactive they are hidden, matching the view-mode
|
||||
toggle's interactivity gate. *@
|
||||
@if (RendererInfo.IsInteractive)
|
||||
{
|
||||
<div class="tracks-search-row">
|
||||
<MudTextField T="string"
|
||||
Value="@ViewModel.SearchText"
|
||||
ValueChanged="@OnSearchInput"
|
||||
Immediate="true"
|
||||
DebounceInterval="400"
|
||||
Placeholder="Search tracks, artists, albums"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Clearable="true"
|
||||
Class="tracks-search-field"/>
|
||||
</div>
|
||||
|
||||
@if (ViewModel.FilterAlbum is not null || ViewModel.FilterGenre is not null)
|
||||
{
|
||||
<div class="tracks-filter-pills">
|
||||
<MudChip T="string"
|
||||
Color="Color.Tertiary"
|
||||
Variant="Variant.Filled"
|
||||
OnClose="@(_ => ClearFilter())">
|
||||
@(ViewModel.FilterAlbum is not null
|
||||
? $"Album: {ViewModel.FilterAlbum}"
|
||||
: $"Genre: {ViewModel.FilterGenre}")
|
||||
</MudChip>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (ViewModel.Page != null)
|
||||
{
|
||||
<div class="tracks-view-header">
|
||||
<MudToggleGroup T="GalleryViewMode" @bind-Value="_viewMode" Disabled="@(!RendererInfo.IsInteractive)" Class="tracks-view-toggle">
|
||||
<MudToggleItem Value="GalleryViewMode.Grid">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ViewModule"/>
|
||||
</MudToggleItem>
|
||||
<MudToggleItem Value="GalleryViewMode.List">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ViewList"/>
|
||||
</MudToggleItem>
|
||||
</MudToggleGroup>
|
||||
</div>
|
||||
<div class="tracks-content">
|
||||
<TracksGallery Tracks="@ViewModel.Page.Items"
|
||||
ViewMode="@_viewMode"
|
||||
ActiveTrack="@PlayerService.CurrentTrack"
|
||||
IsPlaying="@PlayerService.IsPlaying"
|
||||
IsPaused="@PlayerService.IsPaused"
|
||||
OnPlay="@PlayTrack"
|
||||
OnPause="@PauseTrack"/>
|
||||
</div>
|
||||
<div class="tracks-footer py-4">
|
||||
<MudPagination Count="@ViewModel.Page.TotalPages"
|
||||
Selected="@ViewModel.Page.Page"
|
||||
SelectedChanged="@SetPage"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
BoundaryCount="2"
|
||||
MiddleCount="3"/>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="tracks-content">
|
||||
<MudGrid Spacing="3">
|
||||
@foreach (var i in Enumerable.Range(0, 12))
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="deepdrft-track-gallery-item-center">
|
||||
<MudSkeleton Width="240px" Height="240px" SkeletonType="SkeletonType.Rectangle"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</div>
|
||||
<div class="tracks-footer">
|
||||
<MudSkeleton Height="60px" Width="240px" Class="justify-center"/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,163 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Controls;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
public partial class TracksView : ComponentBase, IDisposable
|
||||
{
|
||||
private const string PersistKey = "tracks-page";
|
||||
|
||||
[Inject] public required TracksViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
|
||||
|
||||
// Filter params arrive on the URL: /tracks?album=X, /tracks?genre=Y, /tracks?q=Z. Copied into
|
||||
// the ViewModel on init before the first fetch so the gallery renders filtered on direct nav.
|
||||
[SupplyParameterFromQuery(Name = "album")] public string? AlbumQuery { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "genre")] public string? GenreQuery { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "q")] public string? SearchQuery { get; set; }
|
||||
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
// Ephemeral view-mode selection — presentation-only, not persisted across navigation.
|
||||
private GalleryViewMode _viewMode = GalleryViewMode.Grid;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Seed filter state from the URL before any fetch or restore decision.
|
||||
ViewModel.FilterAlbum = string.IsNullOrWhiteSpace(AlbumQuery) ? null : AlbumQuery;
|
||||
ViewModel.FilterGenre = string.IsNullOrWhiteSpace(GenreQuery) ? null : GenreQuery;
|
||||
ViewModel.SearchText = string.IsNullOrWhiteSpace(SearchQuery) ? null : SearchQuery;
|
||||
|
||||
// Carry the prerendered page across the prerender -> interactive (WASM) seam.
|
||||
// Without this, the WASM pass gets a fresh scoped ViewModel (Page == null),
|
||||
// re-renders the skeleton, re-fetches, and replaces the gallery DOM a few
|
||||
// seconds in — replaying TrackCard entrance animations. Mirror the dark-mode
|
||||
// PersistentComponentState bridge: persist on the way out of prerender,
|
||||
// restore on the interactive pass, and only fetch on a miss.
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistTracks);
|
||||
|
||||
// The prerendered page is always unfiltered. When the URL carries filter params, that
|
||||
// restored page is wrong for this view — skip the restore and fetch with the filter.
|
||||
if (!ViewModel.HasActiveFilter
|
||||
&& PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored)
|
||||
&& restored is not null)
|
||||
{
|
||||
ViewModel.Page = restored;
|
||||
ViewModel.PageNumber = restored.Page;
|
||||
}
|
||||
else
|
||||
{
|
||||
await SetPage(ViewModel.PageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// The gallery's per-card icons read off the player's live state (CurrentTrack /
|
||||
// IsPlaying / IsPaused), which mutates outside this component's render path:
|
||||
// the player bar's play/pause/stop/close all change it directly. 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 PersistTracks()
|
||||
{
|
||||
// Only persist the unfiltered page. A filtered page restored onto a later plain /tracks
|
||||
// visit would show the wrong results, so a filtered render leaves the cache untouched.
|
||||
if (ViewModel.Page is not null && !ViewModel.HasActiveFilter)
|
||||
{
|
||||
PersistentState.PersistAsJson(PersistKey, ViewModel.Page);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SetPage(int newPage)
|
||||
{
|
||||
var result = await ViewModel.TrackData.GetPage(
|
||||
newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending,
|
||||
ViewModel.SearchText, ViewModel.FilterAlbum, ViewModel.FilterGenre);
|
||||
|
||||
if (result is { Success: true, Value: PagedResult<TrackDto> pageResult })
|
||||
{
|
||||
ViewModel.Page = pageResult;
|
||||
ViewModel.PageSize = pageResult.PageSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
|
||||
// burst reaches here. Resets to page 1 since the result set changes, then re-fetches with the
|
||||
// active filter (search + any album/genre pill compose).
|
||||
private async Task OnSearchInput(string? value)
|
||||
{
|
||||
ViewModel.SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
ViewModel.PageNumber = 1;
|
||||
await SetPage(1);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Clears the album/genre pill and returns to the unfiltered gallery. Updates the URL (drops the
|
||||
// query param) and re-fetches in place. SearchText is intentionally left intact — the pill only
|
||||
// represents FilterAlbum/FilterGenre, not free-text search, so clearing it must not discard an
|
||||
// active search term. Blazor reuses the component on a same-route query change and does not
|
||||
// re-run OnInitializedAsync, so the state reset + refetch happen here explicitly rather than
|
||||
// relying on re-init.
|
||||
private async Task ClearFilter()
|
||||
{
|
||||
ViewModel.FilterAlbum = null;
|
||||
ViewModel.FilterGenre = null;
|
||||
ViewModel.PageNumber = 1;
|
||||
|
||||
Navigation.NavigateTo("/tracks");
|
||||
await SetPage(1);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task PlayTrack(TrackDto track)
|
||||
{
|
||||
// Resume the current track if it's merely paused; otherwise stream the new selection.
|
||||
if (PlayerService.CurrentTrack?.Id == track.Id && PlayerService.IsPaused)
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlayerService.SelectTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PauseTrack(TrackDto track)
|
||||
{
|
||||
if (PlayerService.CurrentTrack?.Id == track.Id && PlayerService.IsPlaying)
|
||||
{
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_persistingSubscription.Dispose();
|
||||
|
||||
if (_subscribedService != null)
|
||||
{
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
_subscribedService = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/* Layout for the tracks page.
|
||||
Dead flex/height rules removed — the sticky-footer intent they encoded required
|
||||
a height target that was never set; normal block flow is sufficient for a
|
||||
paginated gallery. Horizontal inset is owned by MudContainer in TracksGallery. */
|
||||
|
||||
.tracks-view-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tracks-content {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.tracks-footer {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tracks-view-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.tracks-search-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
|
||||
/* tracks-search-field rides on MudTextField, whose root is a child Razor component element.
|
||||
Blazor isolation does not stamp the scope attribute there, so ::deep is required. */
|
||||
::deep .tracks-search-field {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tracks-filter-pills {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
@@ -18,8 +18,6 @@ public static class Startup
|
||||
// prerender — both call DeepDrftAPI over the "DeepDrft.API" client.
|
||||
services.AddScoped<TrackClient>();
|
||||
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.
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace DeepDrftPublic.Client.ViewModels;
|
||||
/// 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 id (an exact join) — 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).
|
||||
/// reused instance never bleeds across navigations.
|
||||
/// </summary>
|
||||
public class ReleaseDetailViewModel
|
||||
{
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
|
||||
namespace DeepDrftPublic.Client.ViewModels;
|
||||
|
||||
public class TrackDetailViewModel
|
||||
{
|
||||
public ITrackDataService TrackData { get; }
|
||||
|
||||
public TrackDto? Track { get; set; }
|
||||
public bool IsLoading { get; set; } = true;
|
||||
public bool NotFound { get; set; }
|
||||
|
||||
public TrackDetailViewModel(ITrackDataService trackData)
|
||||
{
|
||||
TrackData = trackData;
|
||||
}
|
||||
|
||||
public async Task Load(string entryKey)
|
||||
{
|
||||
// Idempotent across navigations: the scoped instance may be reused, so reset
|
||||
// every flag before the fetch rather than relying on construction defaults.
|
||||
IsLoading = true;
|
||||
NotFound = false;
|
||||
Track = null;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await TrackData.GetTrack(entryKey);
|
||||
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
Track = result.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
NotFound = true;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftPublic.Client.ViewModels;
|
||||
|
||||
public class TracksViewModel
|
||||
{
|
||||
public ITrackDataService TrackData { get; }
|
||||
|
||||
public int PageNumber { get; set; } = 1;
|
||||
|
||||
public int PageSize
|
||||
{
|
||||
get => Page?.PageSize ?? 15;
|
||||
set
|
||||
{
|
||||
if (Page == null) return;
|
||||
if (value != Page.PageSize)
|
||||
{
|
||||
Page.PageSize = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
public string SortBy { get; set; } = string.Empty;
|
||||
public bool IsDescending { get; set; } = false;
|
||||
public PagedResult<TrackDto>? Page { get; set; } = null;
|
||||
|
||||
// Active gallery filters. Null/empty means "no filter on this dimension". SearchText is the
|
||||
// free-text query; FilterAlbum/FilterGenre are exact-match pills driven by the /albums and
|
||||
// /genres pages via query-string navigation.
|
||||
public string? SearchText { get; set; }
|
||||
public string? FilterAlbum { get; set; }
|
||||
public string? FilterGenre { get; set; }
|
||||
|
||||
public bool HasActiveFilter =>
|
||||
!string.IsNullOrWhiteSpace(SearchText)
|
||||
|| !string.IsNullOrWhiteSpace(FilterAlbum)
|
||||
|| !string.IsNullOrWhiteSpace(FilterGenre);
|
||||
|
||||
public TracksViewModel(ITrackDataService trackData)
|
||||
{
|
||||
TrackData = trackData;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user