refactor(public): retire track-cardinal stack, fold Archive/Cuts cards into ReleaseGallery (P11 W3 §4)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user