refactor(public): retire track-cardinal stack, fold Archive/Cuts cards into ReleaseGallery (P11 W3 §4)

This commit is contained in:
daniel-c-harvey
2026-06-16 11:31:02 -04:00
parent d98ead97c3
commit ef6d21b94e
32 changed files with 70 additions and 1353 deletions
+8 -59
View File
@@ -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;
}
+9 -56
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}