Files
deepdrft/DeepDrftPublic.Client/Pages/TracksView.razor.cs
T

164 lines
6.8 KiB
C#

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;
}
}
}