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