From c2554e6dae84e802500b73c22ca15604dc21bb76 Mon Sep 17 00:00:00 2001 From: David Federman Date: Tue, 17 Feb 2026 23:17:06 -0800 Subject: [PATCH] Add sort and filer option to Library page --- src/JellyBox/AppSettings.cs | 61 +++- src/JellyBox/Glyphs.cs | 4 + .../ViewModels/LibraryViewModel.Filtering.cs | 260 ++++++++++++++++++ src/JellyBox/ViewModels/LibraryViewModel.cs | 170 ++++++++++-- src/JellyBox/Views/Library.xaml | 188 ++++++++++++- src/JellyBox/Views/Library.xaml.cs | 8 + 6 files changed, 639 insertions(+), 52 deletions(-) create mode 100644 src/JellyBox/ViewModels/LibraryViewModel.Filtering.cs diff --git a/src/JellyBox/AppSettings.cs b/src/JellyBox/AppSettings.cs index c44f0b5..c200be1 100644 --- a/src/JellyBox/AppSettings.cs +++ b/src/JellyBox/AppSettings.cs @@ -6,24 +6,69 @@ namespace JellyBox; internal sealed class AppSettings #pragma warning disable CA1812 // Avoid uninstantiated internal classes { + private readonly ApplicationDataContainer _settings = ApplicationData.Current.LocalSettings; + public string? ServerUrl { - get => GetProperty(nameof(ServerUrl)); - set => SetProperty(nameof(ServerUrl), value); + get => _settings.GetProperty(nameof(ServerUrl)); + set => _settings.SetProperty(nameof(ServerUrl), value); } public string? AccessToken { - get => GetProperty(nameof(AccessToken)); - set => SetProperty(nameof(AccessToken), value); + get => _settings.GetProperty(nameof(AccessToken)); + set => _settings.SetProperty(nameof(AccessToken), value); } - private static void SetProperty(string propertyName, object? value) - => ApplicationData.Current.LocalSettings.Values[propertyName] = value; + public LibraryViewSettings GetLibraryViewSettings(Guid libraryId) + { + ApplicationDataContainer container = GetLibraryContainer(libraryId); + return new LibraryViewSettings( + container.GetProperty(nameof(LibraryViewSettings.SortBy)), + container.GetProperty(nameof(LibraryViewSettings.SortDescending)), + ParseList(container.GetProperty(nameof(LibraryViewSettings.StatusFilters))), + ParseList(container.GetProperty(nameof(LibraryViewSettings.GenreFilters))), + ParseList(container.GetProperty(nameof(LibraryViewSettings.YearFilters))), + ParseList(container.GetProperty(nameof(LibraryViewSettings.RatingFilters)))); + + static string[] ParseList(string? value) + => string.IsNullOrEmpty(value) ? [] : value.Split('\n'); + } - private static T? GetProperty(string propertyName, T? defaultValue = default) + public void SetLibraryViewSettings(Guid libraryId, LibraryViewSettings settings) { - object value = ApplicationData.Current.LocalSettings.Values[propertyName]; + ApplicationDataContainer container = GetLibraryContainer(libraryId); + container.SetProperty(nameof(LibraryViewSettings.SortBy), settings.SortBy); + container.SetProperty(nameof(LibraryViewSettings.SortDescending), settings.SortDescending); + container.SetProperty(nameof(LibraryViewSettings.StatusFilters), JoinList(settings.StatusFilters)); + container.SetProperty(nameof(LibraryViewSettings.GenreFilters), JoinList(settings.GenreFilters)); + container.SetProperty(nameof(LibraryViewSettings.YearFilters), JoinList(settings.YearFilters)); + container.SetProperty(nameof(LibraryViewSettings.RatingFilters), JoinList(settings.RatingFilters)); + + static string? JoinList(string[] values) + => values.Length > 0 ? string.Join('\n', values) : null; + } + + private ApplicationDataContainer GetLibraryContainer(Guid libraryId) + => _settings.CreateContainer($"Library_{libraryId}", ApplicationDataCreateDisposition.Always); +} + +file static class ApplicationDataContainerExtensions +{ + internal static void SetProperty(this ApplicationDataContainer container, string propertyName, object? value) + => container.Values[propertyName] = value; + + internal static T? GetProperty(this ApplicationDataContainer container, string propertyName, T? defaultValue = default) + { + object value = container.Values[propertyName]; return value is not null ? (T)value : defaultValue; } } + +internal sealed record LibraryViewSettings( + string? SortBy, + bool SortDescending, + string[] StatusFilters, + string[] GenreFilters, + string[] YearFilters, + string[] RatingFilters); diff --git a/src/JellyBox/Glyphs.cs b/src/JellyBox/Glyphs.cs index 0ae7463..8191016 100644 --- a/src/JellyBox/Glyphs.cs +++ b/src/JellyBox/Glyphs.cs @@ -26,6 +26,10 @@ internal static class Glyphs public const string Accept = "\uE8FB"; public const string More = "\uE712"; + // Sort + public const string SortAscending = "\uE74A"; + public const string SortDescending = "\uE74B"; + // Favorites public const string HeartOutline = "\uEB51"; public const string HeartFilled = "\uEB52"; diff --git a/src/JellyBox/ViewModels/LibraryViewModel.Filtering.cs b/src/JellyBox/ViewModels/LibraryViewModel.Filtering.cs new file mode 100644 index 0000000..85606c1 --- /dev/null +++ b/src/JellyBox/ViewModels/LibraryViewModel.Filtering.cs @@ -0,0 +1,260 @@ +using System.Diagnostics; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Jellyfin.Sdk.Generated.Models; +using Windows.UI.Xaml.Media; + +namespace JellyBox.ViewModels; + +#pragma warning disable CA1812 +internal sealed partial class LibraryViewModel +#pragma warning restore CA1812 +{ + // Sort state + [ObservableProperty] + public partial List SortOptions { get; set; } = []; + + [ObservableProperty] + public partial SortOption? SelectedSortOption { get; set; } + + [ObservableProperty] + public partial bool IsSortDescending { get; set; } + + // Filter state + [ObservableProperty] + public partial List StatusFilters { get; set; } = []; + + [ObservableProperty] + public partial List GenreFilters { get; set; } = []; + + [ObservableProperty] + public partial List YearFilters { get; set; } = []; + + [ObservableProperty] + public partial List RatingFilters { get; set; } = []; + + [ObservableProperty] + public partial bool HasActiveFilters { get; set; } + + partial void OnIsSortDescendingChanged(bool value) + { + SaveViewSettings(); + _ = RefreshItemsAsync(); + } + + [RelayCommand] + private void SelectSortOption(SortOption option) + { + SelectedSortOption = option; + SaveViewSettings(); + _ = RefreshItemsAsync(); + } + + [RelayCommand] + private void ToggleSortOrder() => IsSortDescending = !IsSortDescending; + + [RelayCommand] + private void ClearFilters() + { + _suppressRefresh = true; + try + { + UnselectAll(StatusFilters); + UnselectAll(GenreFilters); + UnselectAll(YearFilters); + UnselectAll(RatingFilters); + } + finally + { + _suppressRefresh = false; + } + + UpdateHasActiveFilters(); + SaveViewSettings(); + _ = RefreshItemsAsync(); + + static void UnselectAll(List filters) + { + foreach (FilterItem filter in filters) + { + filter.IsSelected = false; + } + } + } + + private void OnFilterChanged() + { + if (_suppressRefresh) + { + return; + } + + UpdateHasActiveFilters(); + SaveViewSettings(); + _ = RefreshItemsAsync(); + } + + private void UpdateHasActiveFilters() + { + HasActiveFilters = StatusFilters.Any(f => f.IsSelected) + || GenreFilters.Any(f => f.IsSelected) + || YearFilters.Any(f => f.IsSelected) + || RatingFilters.Any(f => f.IsSelected); + } + + private void InitializeStatusFilters() + { + StatusFilters = + [ + new FilterItem(OnFilterChanged, "Unplayed", ItemFilter.IsUnplayed), + new FilterItem(OnFilterChanged, "Played", ItemFilter.IsPlayed), + new FilterItem(OnFilterChanged, "Resumable", ItemFilter.IsResumable), + new FilterItem(OnFilterChanged, "Favorites", ItemFilter.IsFavorite), + ]; + } + + private async Task LoadFilterValuesAsync() + { + try + { + QueryFiltersLegacy? filters = await _jellyfinApiClient.Items.Filters.GetAsync(parameters => + { + parameters.QueryParameters.ParentId = _collectionItemId; + parameters.QueryParameters.IncludeItemTypes = [_itemKind]; + }); + + if (filters is not null) + { + _suppressRefresh = true; + + try + { + if (filters.Genres is not null) + { + GenreFilters = [.. filters.Genres.Where(g => g is not null).OrderBy(g => g, StringComparer.CurrentCulture).Select(g => new FilterItem(OnFilterChanged, g!))]; + } + + if (filters.Years is not null) + { + YearFilters = [.. filters.Years.Where(y => y.HasValue).OrderByDescending(y => y!.Value).Select(y => new FilterItem(OnFilterChanged, y!.Value.ToString()))]; + } + + if (filters.OfficialRatings is not null) + { + RatingFilters = [.. filters.OfficialRatings.Where(r => r is not null).Select(r => new FilterItem(OnFilterChanged, r!))]; + } + + // Restore persisted filter selections + if (_savedViewSettings is not null) + { + RestoreFilterSelections(GenreFilters, _savedViewSettings.GenreFilters); + RestoreFilterSelections(YearFilters, _savedViewSettings.YearFilters); + RestoreFilterSelections(RatingFilters, _savedViewSettings.RatingFilters); + _savedViewSettings = null; + } + } + finally + { + _suppressRefresh = false; + } + + UpdateHasActiveFilters(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error loading filter values: {ex}"); + } + } + + private static List GetSortOptions(BaseItemKind itemKind) => itemKind switch + { + BaseItemKind.Movie => + [ + new("Name", ItemSortBy.SortName), + new("Community Rating", ItemSortBy.CommunityRating), + new("Critic Rating", ItemSortBy.CriticRating), + new("Date Added", ItemSortBy.DateCreated), + new("Date Played", ItemSortBy.DatePlayed), + new("Parental Rating", ItemSortBy.OfficialRating), + new("Play Count", ItemSortBy.PlayCount), + new("Release Date", ItemSortBy.PremiereDate), + new("Runtime", ItemSortBy.Runtime), + ], + BaseItemKind.Series => + [ + new("Name", ItemSortBy.SortName), + new("Community Rating", ItemSortBy.CommunityRating), + new("Date Added", ItemSortBy.DateCreated), + new("Date Episode Added", ItemSortBy.DateLastContentAdded), + new("Date Played", ItemSortBy.SeriesDatePlayed), + new("Parental Rating", ItemSortBy.OfficialRating), + new("Release Date", ItemSortBy.PremiereDate), + ], + _ => + [ + new("Name", ItemSortBy.SortName), + new("Date Added", ItemSortBy.DateCreated), + new("Release Date", ItemSortBy.PremiereDate), + ], + }; + + private static void RestoreFilterSelections(List filters, string[] savedLabels) + { + if (savedLabels.Length == 0) + { + return; + } + + HashSet labelSet = new(savedLabels, StringComparer.Ordinal); + foreach (FilterItem filter in filters) + { + if (labelSet.Contains(filter.Label)) + { + filter.IsSelected = true; + } + } + } + + // x:Bind function binding helpers + public static string GetSortDirectionGlyph(bool isDescending) => isDescending ? Glyphs.SortDescending : Glyphs.SortAscending; + + public static string GetSortDirectionLabel(bool isDescending) => isDescending ? "Descending" : "Ascending"; + + public static Brush GetFilterBorderBrush(bool hasActiveFilters) + => hasActiveFilters + ? (Brush)Windows.UI.Xaml.Application.Current.Resources["AccentColor"] + : (Brush)Windows.UI.Xaml.Application.Current.Resources["BorderSubtle"]; + + public static Windows.UI.Xaml.DependencyObject GetFilterXYFocusRight( + bool hasActiveFilters, + Windows.UI.Xaml.DependencyObject filterButton, + Windows.UI.Xaml.DependencyObject clearFiltersButton) + => hasActiveFilters ? clearFiltersButton : filterButton; +} + +internal sealed record SortOption(string Label, ItemSortBy SortBy) +{ + public override string ToString() => Label; +} + +internal sealed partial class FilterItem : ObservableObject +{ + private readonly Action _onChanged; + + public FilterItem(Action onChanged, string label, ItemFilter? itemFilter = null) + { + _onChanged = onChanged; + Label = label; + ItemFilter = itemFilter; + } + + public string Label { get; } + + public ItemFilter? ItemFilter { get; } + + [ObservableProperty] + public partial bool IsSelected { get; set; } + + partial void OnIsSelectedChanged(bool value) => _onChanged(); +} diff --git a/src/JellyBox/ViewModels/LibraryViewModel.cs b/src/JellyBox/ViewModels/LibraryViewModel.cs index 28b5ca3..0098f2e 100644 --- a/src/JellyBox/ViewModels/LibraryViewModel.cs +++ b/src/JellyBox/ViewModels/LibraryViewModel.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using JellyBox.Models; using JellyBox.Views; @@ -10,9 +11,15 @@ namespace JellyBox.ViewModels; internal sealed partial class LibraryViewModel : ObservableObject, ILoadingViewModel #pragma warning restore CA1812 // Avoid uninstantiated internal classes { + private readonly AppSettings _appSettings; private readonly JellyfinApiClient _jellyfinApiClient; private readonly CardFactory _cardFactory; + private Guid _collectionItemId; + private BaseItemKind _itemKind; + private bool _suppressRefresh; + private LibraryViewSettings? _savedViewSettings; + [ObservableProperty] public partial bool IsLoading { get; set; } @@ -23,64 +30,167 @@ internal sealed partial class LibraryViewModel : ObservableObject, ILoadingViewM public partial IReadOnlyList? Items { get; set; } public LibraryViewModel( + AppSettings appSettings, JellyfinApiClient jellyfinApiClient, CardFactory cardFactory) { + _appSettings = appSettings; _jellyfinApiClient = jellyfinApiClient; _cardFactory = cardFactory; } public void HandleParameters(Library.Parameters parameters) { + _suppressRefresh = true; + Title = parameters.Title; + _collectionItemId = parameters.CollectionItemId; + _itemKind = parameters.ItemKind; Items = null; - _ = InitializeAsync(parameters.CollectionItemId, parameters.ItemKind); + + SortOptions = GetSortOptions(_itemKind); + + // Restore persisted view settings or use defaults + _savedViewSettings = _appSettings.GetLibraryViewSettings(_collectionItemId); + SortOption? restoredSort = _savedViewSettings.SortBy is not null + ? SortOptions.FirstOrDefault(o => o.SortBy.ToString() == _savedViewSettings.SortBy) + : null; + SelectedSortOption = restoredSort ?? SortOptions[0]; + IsSortDescending = _savedViewSettings.SortDescending; + + InitializeStatusFilters(); + RestoreFilterSelections(StatusFilters, _savedViewSettings.StatusFilters); + + _suppressRefresh = false; + _ = InitializeAsync(); } - private async Task InitializeAsync(Guid collectionItemId, BaseItemKind itemKind) + private async Task InitializeAsync() { IsLoading = true; try { - // TODO: Paginate? - BaseItemDtoQueryResult? result = await _jellyfinApiClient.Items.GetAsync(parameters => + // Load filter values first so saved selections are restored before querying items + await LoadFilterValuesAsync(); + await LoadItemsAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"Error in LibraryViewModel.InitializeAsync: {ex}"); + } + finally + { + IsLoading = false; + } + } + + private async Task RefreshItemsAsync() + { + // Don't refresh if we haven't initialized yet + if (SelectedSortOption is null) + { + return; + } + + IsLoading = true; + + try + { + await LoadItemsAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"Error in LibraryViewModel.RefreshItemsAsync: {ex}"); + } + finally + { + IsLoading = false; + } + } + + private async Task LoadItemsAsync() + { + string[] selectedGenres = [.. GenreFilters.Where(f => f.IsSelected).Select(f => f.Label)]; + int?[] selectedYears = [.. YearFilters.Where(f => f.IsSelected).Select(f => (int?)int.Parse(f.Label))]; + string[] selectedRatings = [.. RatingFilters.Where(f => f.IsSelected).Select(f => f.Label)]; + ItemFilter[] selectedStatusFilters = [.. StatusFilters.Where(f => f.IsSelected && f.ItemFilter.HasValue).Select(f => f.ItemFilter!.Value)]; + + ItemSortBy sortBy = SelectedSortOption?.SortBy ?? ItemSortBy.SortName; + SortOrder sortOrder = IsSortDescending ? SortOrder.Descending : SortOrder.Ascending; + + // TODO: Paginate? + BaseItemDtoQueryResult? result = await _jellyfinApiClient.Items.GetAsync(parameters => + { + parameters.QueryParameters.ParentId = _collectionItemId; + parameters.QueryParameters.SortBy = [sortBy]; + parameters.QueryParameters.SortOrder = [sortOrder]; + parameters.QueryParameters.IncludeItemTypes = [_itemKind]; + parameters.QueryParameters.Recursive = true; + parameters.QueryParameters.Fields = [ItemFields.PrimaryImageAspectRatio, ItemFields.MediaSourceCount]; + parameters.QueryParameters.ImageTypeLimit = 1; + parameters.QueryParameters.EnableImageTypes = [ImageType.Primary, ImageType.Backdrop, ImageType.Banner, ImageType.Thumb]; + + if (selectedGenres.Length > 0) { - parameters.QueryParameters.ParentId = collectionItemId; - parameters.QueryParameters.SortBy = itemKind == BaseItemKind.Movie - ? [ItemSortBy.SortName, ItemSortBy.ProductionYear] - : [ItemSortBy.SortName]; - parameters.QueryParameters.SortOrder = [SortOrder.Ascending]; - parameters.QueryParameters.IncludeItemTypes = [itemKind]; - parameters.QueryParameters.Recursive = itemKind == BaseItemKind.Series; - parameters.QueryParameters.Fields = [ItemFields.PrimaryImageAspectRatio, ItemFields.MediaSourceCount]; - parameters.QueryParameters.ImageTypeLimit = 1; - parameters.QueryParameters.EnableImageTypes = [ImageType.Primary, ImageType.Backdrop, ImageType.Banner, ImageType.Thumb]; - }); - - if (result?.Items is not null) + parameters.QueryParameters.Genres = selectedGenres; + } + + if (selectedYears.Length > 0) { - List items = new(result.Items.Count); - foreach (BaseItemDto item in result.Items) - { - if (!item.Id.HasValue) - { - continue; - } + parameters.QueryParameters.Years = selectedYears; + } + + if (selectedRatings.Length > 0) + { + parameters.QueryParameters.OfficialRatings = selectedRatings; + } - items.Add(_cardFactory.CreateFromItem(item, CardShape.Portrait, preferredImageType: null)); + if (selectedStatusFilters.Length > 0) + { + parameters.QueryParameters.Filters = selectedStatusFilters; + } + }); + + if (result?.Items is not null) + { + List items = new(result.Items.Count); + foreach (BaseItemDto item in result.Items) + { + if (!item.Id.HasValue) + { + continue; } - Items = items; + items.Add(_cardFactory.CreateFromItem(item, CardShape.Portrait, preferredImageType: null)); } + + Items = items; } - catch (Exception ex) + else { - System.Diagnostics.Debug.WriteLine($"Error in LibraryViewModel.InitializeAsync: {ex}"); + Items = []; } - finally + } + + private void SaveViewSettings() + { + if (_suppressRefresh || SelectedSortOption is null) { - IsLoading = false; + return; } + + _appSettings.SetLibraryViewSettings( + _collectionItemId, + new LibraryViewSettings( + SelectedSortOption.SortBy.ToString(), + IsSortDescending, + GetSelectedLabels(StatusFilters), + GetSelectedLabels(GenreFilters), + GetSelectedLabels(YearFilters), + GetSelectedLabels(RatingFilters))); + + static string[] GetSelectedLabels(List filters) + => [.. filters.Where(f => f.IsSelected).Select(f => f.Label)]; } } diff --git a/src/JellyBox/Views/Library.xaml b/src/JellyBox/Views/Library.xaml index d6d3f9f..acb4787 100644 --- a/src/JellyBox/Views/Library.xaml +++ b/src/JellyBox/Views/Library.xaml @@ -5,31 +5,191 @@ xmlns:Interactivity="using:Microsoft.Xaml.Interactivity" xmlns:Behaviors="using:JellyBox.Behaviors" xmlns:controls="using:JellyBox.Controls" + xmlns:viewmodels="using:JellyBox.ViewModels" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" Background="{StaticResource BackgroundBase}"> - - - - - - - - + + + + + + + - + Foreground="{StaticResource TextPrimary}" /> + + + + + + + + + + + + + + + + + + - + diff --git a/src/JellyBox/Views/Library.xaml.cs b/src/JellyBox/Views/Library.xaml.cs index 07c145c..9fe0b78 100644 --- a/src/JellyBox/Views/Library.xaml.cs +++ b/src/JellyBox/Views/Library.xaml.cs @@ -18,5 +18,13 @@ public Library() protected override void OnNavigatedTo(NavigationEventArgs e) => ViewModel.HandleParameters((Parameters)e.Parameter); + private void SortListView_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is SortOption sortOption) + { + ViewModel.SelectSortOptionCommand.Execute(sortOption); + } + } + internal sealed record Parameters(Guid CollectionItemId, BaseItemKind ItemKind, string Title); }