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