feat: add search/album/genre filtering and /albums + /genres browse pages
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user