feat: add search/album/genre filtering and /albums + /genres browse pages

This commit is contained in:
daniel-c-harvey
2026-06-10 10:54:56 -04:00
parent 1071ba7374
commit 5cae83b9ed
24 changed files with 940 additions and 15 deletions
+49 -1
View File
@@ -20,7 +20,10 @@ public class TrackClient
int pageNumber,
int pageSize,
string? sortColumn = null,
bool sortDescending = false)
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null)
{
var queryArgs = new Dictionary<string, string?>(){
["page"] = pageNumber.ToString(),
@@ -33,6 +36,15 @@ public class TrackClient
if (sortDescending)
queryArgs["sortDescending"] = "true";
if (!string.IsNullOrEmpty(searchText))
queryArgs["q"] = searchText;
if (!string.IsNullOrEmpty(album))
queryArgs["album"] = album;
if (!string.IsNullOrEmpty(genre))
queryArgs["genre"] = genre;
string query = QueryString.Create(queryArgs).ToString();
var response = await _http.GetAsync($"api/track/page{query}");
@@ -77,6 +89,42 @@ public class TrackClient
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
{
var response = await _http.GetAsync("api/track/albums");
if (!response.IsSuccessStatusCode)
return ApiResult<List<AlbumSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var albums = JsonSerializer.Deserialize<List<AlbumSummaryDto>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return albums is not null
? ApiResult<List<AlbumSummaryDto>>.CreatePassResult(albums)
: ApiResult<List<AlbumSummaryDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
{
var response = await _http.GetAsync("api/track/genres");
if (!response.IsSuccessStatusCode)
return ApiResult<List<GenreSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var genres = JsonSerializer.Deserialize<List<GenreSummaryDto>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return genres is not null
? ApiResult<List<GenreSummaryDto>>.CreatePassResult(genres)
: ApiResult<List<GenreSummaryDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
{
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
+5 -3
View File
@@ -13,9 +13,11 @@ public static class Pages
{
public static readonly List<PageRoute> MenuPages =
[
new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic },
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Piano }, // TODO: placeholder until Sessions ships
new() { Name = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships
new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic },
new() { Name = "Albums", Route = "/albums", Icon = Icons.Material.Filled.Album },
new() { Name = "Genres", Route = "/genres", Icon = Icons.Material.Filled.Category },
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Piano }, // TODO: placeholder until Sessions ships
new() { Name = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships
];
public static readonly List<PageRoute> AllPages =
@@ -0,0 +1,63 @@
@page "/albums"
<PageTitle>DeepDrft Albums</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.Album))">
@if (!string.IsNullOrEmpty(album.CoverImageKey))
{
<div class="album-card-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(album.CoverImageKey)');">
</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.Album
</MudText>
<MudText Typo="Typo.caption" Class="album-card-count">
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
</MudText>
</div>
</div>
</div>
</MudItem>
}
</MudGrid>
}
</MudContainer>
</div>
@@ -0,0 +1,26 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
public partial class AlbumsView : ComponentBase
{
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
private bool _loading = true;
private List<AlbumSummaryDto> _albums = [];
protected override async Task OnInitializedAsync()
{
var result = await TrackData.GetAlbums();
if (result is { Success: true, Value: { } albums })
_albums = albums;
_loading = false;
}
private void OpenAlbum(string album)
=> Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}");
}
@@ -0,0 +1,58 @@
.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;
}
@@ -0,0 +1,41 @@
@page "/genres"
<PageTitle>DeepDrft Genres</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Medium" Class="genres-view-container">
@if (_loading)
{
<div class="genres-list">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudSkeleton Height="48px" Width="100%" Class="mb-2"/>
}
</div>
}
else if (_genres.Count == 0)
{
<div class="genres-empty">
<MudText Typo="Typo.h6">No genres yet</MudText>
</div>
}
else
{
<MudList T="string" Class="genres-list">
@foreach (var genre in _genres)
{
<MudListItem T="string"
Icon="@Icons.Material.Filled.Category"
OnClick="@(() => OpenGenre(genre.Genre))">
<div class="genre-row">
<MudText Typo="Typo.subtitle1">@genre.Genre</MudText>
<MudText Typo="Typo.caption" Class="genre-count">
@genre.TrackCount @(genre.TrackCount == 1 ? "track" : "tracks")
</MudText>
</div>
</MudListItem>
}
</MudList>
}
</MudContainer>
</div>
@@ -0,0 +1,26 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
public partial class GenresView : ComponentBase
{
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
private bool _loading = true;
private List<GenreSummaryDto> _genres = [];
protected override async Task OnInitializedAsync()
{
var result = await TrackData.GetGenres();
if (result is { Success: true, Value: { } genres })
_genres = genres;
_loading = false;
}
private void OpenGenre(string genre)
=> Navigation.NavigateTo($"/tracks?genre={Uri.EscapeDataString(genre)}");
}
@@ -0,0 +1,28 @@
.genres-view-container {
padding-top: 16px;
}
/* genres-list rides on MudList, a child Razor component whose root Blazor isolation
does not scope-stamp; ::deep is required. */
::deep .genres-list {
width: 100%;
}
.genre-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
width: 100%;
}
/* genre-count rides on MudText (child Razor component); ::deep pierces into its output. */
::deep .genre-count {
opacity: 0.7;
}
.genres-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
@@ -5,6 +5,41 @@
<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">
@@ -13,8 +13,15 @@ public partial class TracksView : ComponentBase, IDisposable
[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;
@@ -23,6 +30,11 @@ public partial class TracksView : ComponentBase, IDisposable
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
@@ -31,7 +43,11 @@ public partial class TracksView : ComponentBase, IDisposable
// restore on the interactive pass, and only fetch on a miss.
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistTracks);
if (PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored) && restored is not null)
// 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;
@@ -63,7 +79,9 @@ public partial class TracksView : ComponentBase, IDisposable
private Task PersistTracks()
{
if (ViewModel.Page is not null)
// 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);
}
@@ -72,7 +90,9 @@ public partial class TracksView : ComponentBase, IDisposable
private async Task SetPage(int newPage)
{
var result = await ViewModel.TrackData.GetPage(newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending);
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 })
{
@@ -81,6 +101,34 @@ public partial class TracksView : ComponentBase, IDisposable
}
}
// 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.
@@ -24,3 +24,22 @@
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;
}
@@ -16,7 +16,16 @@ public interface ITrackDataService
int pageNumber,
int pageSize,
string? sortColumn = null,
bool sortDescending = false);
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null);
/// <summary>Distinct non-null albums with track counts and a representative cover key.</summary>
Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums();
/// <summary>Distinct non-null genres with track counts.</summary>
Task<ApiResult<List<GenreSummaryDto>>> GetGenres();
Task<ApiResult<TrackDto>> GetTrack(string trackId);
@@ -23,8 +23,17 @@ public class TrackClientDataService : ITrackDataService
int pageNumber,
int pageSize,
string? sortColumn = null,
bool sortDescending = false)
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending);
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null)
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre);
public Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
=> _trackClient.GetAlbums();
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
=> _trackClient.GetGenres();
public Task<ApiResult<TrackDto>> GetTrack(string trackId)
=> _trackClient.GetTrack(trackId);
@@ -26,6 +26,18 @@ public class TracksViewModel
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;