Skip to content

Commit ff3ade6

Browse files
authored
Add sort and filer option to Library page (#70)
1 parent 8357a04 commit ff3ade6

6 files changed

Lines changed: 639 additions & 52 deletions

File tree

src/JellyBox/AppSettings.cs

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,69 @@ namespace JellyBox;
66
internal sealed class AppSettings
77
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
88
{
9+
private readonly ApplicationDataContainer _settings = ApplicationData.Current.LocalSettings;
10+
911
public string? ServerUrl
1012
{
11-
get => GetProperty<string>(nameof(ServerUrl));
12-
set => SetProperty(nameof(ServerUrl), value);
13+
get => _settings.GetProperty<string>(nameof(ServerUrl));
14+
set => _settings.SetProperty(nameof(ServerUrl), value);
1315
}
1416

1517
public string? AccessToken
1618
{
17-
get => GetProperty<string>(nameof(AccessToken));
18-
set => SetProperty(nameof(AccessToken), value);
19+
get => _settings.GetProperty<string>(nameof(AccessToken));
20+
set => _settings.SetProperty(nameof(AccessToken), value);
1921
}
2022

21-
private static void SetProperty(string propertyName, object? value)
22-
=> ApplicationData.Current.LocalSettings.Values[propertyName] = value;
23+
public LibraryViewSettings GetLibraryViewSettings(Guid libraryId)
24+
{
25+
ApplicationDataContainer container = GetLibraryContainer(libraryId);
26+
return new LibraryViewSettings(
27+
container.GetProperty<string>(nameof(LibraryViewSettings.SortBy)),
28+
container.GetProperty<bool>(nameof(LibraryViewSettings.SortDescending)),
29+
ParseList(container.GetProperty<string>(nameof(LibraryViewSettings.StatusFilters))),
30+
ParseList(container.GetProperty<string>(nameof(LibraryViewSettings.GenreFilters))),
31+
ParseList(container.GetProperty<string>(nameof(LibraryViewSettings.YearFilters))),
32+
ParseList(container.GetProperty<string>(nameof(LibraryViewSettings.RatingFilters))));
33+
34+
static string[] ParseList(string? value)
35+
=> string.IsNullOrEmpty(value) ? [] : value.Split('\n');
36+
}
2337

24-
private static T? GetProperty<T>(string propertyName, T? defaultValue = default)
38+
public void SetLibraryViewSettings(Guid libraryId, LibraryViewSettings settings)
2539
{
26-
object value = ApplicationData.Current.LocalSettings.Values[propertyName];
40+
ApplicationDataContainer container = GetLibraryContainer(libraryId);
41+
container.SetProperty(nameof(LibraryViewSettings.SortBy), settings.SortBy);
42+
container.SetProperty(nameof(LibraryViewSettings.SortDescending), settings.SortDescending);
43+
container.SetProperty(nameof(LibraryViewSettings.StatusFilters), JoinList(settings.StatusFilters));
44+
container.SetProperty(nameof(LibraryViewSettings.GenreFilters), JoinList(settings.GenreFilters));
45+
container.SetProperty(nameof(LibraryViewSettings.YearFilters), JoinList(settings.YearFilters));
46+
container.SetProperty(nameof(LibraryViewSettings.RatingFilters), JoinList(settings.RatingFilters));
47+
48+
static string? JoinList(string[] values)
49+
=> values.Length > 0 ? string.Join('\n', values) : null;
50+
}
51+
52+
private ApplicationDataContainer GetLibraryContainer(Guid libraryId)
53+
=> _settings.CreateContainer($"Library_{libraryId}", ApplicationDataCreateDisposition.Always);
54+
}
55+
56+
file static class ApplicationDataContainerExtensions
57+
{
58+
internal static void SetProperty(this ApplicationDataContainer container, string propertyName, object? value)
59+
=> container.Values[propertyName] = value;
60+
61+
internal static T? GetProperty<T>(this ApplicationDataContainer container, string propertyName, T? defaultValue = default)
62+
{
63+
object value = container.Values[propertyName];
2764
return value is not null ? (T)value : defaultValue;
2865
}
2966
}
67+
68+
internal sealed record LibraryViewSettings(
69+
string? SortBy,
70+
bool SortDescending,
71+
string[] StatusFilters,
72+
string[] GenreFilters,
73+
string[] YearFilters,
74+
string[] RatingFilters);

src/JellyBox/Glyphs.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ internal static class Glyphs
2626
public const string Accept = "\uE8FB";
2727
public const string More = "\uE712";
2828

29+
// Sort
30+
public const string SortAscending = "\uE74A";
31+
public const string SortDescending = "\uE74B";
32+
2933
// Favorites
3034
public const string HeartOutline = "\uEB51";
3135
public const string HeartFilled = "\uEB52";
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
using System.Diagnostics;
2+
using CommunityToolkit.Mvvm.ComponentModel;
3+
using CommunityToolkit.Mvvm.Input;
4+
using Jellyfin.Sdk.Generated.Models;
5+
using Windows.UI.Xaml.Media;
6+
7+
namespace JellyBox.ViewModels;
8+
9+
#pragma warning disable CA1812
10+
internal sealed partial class LibraryViewModel
11+
#pragma warning restore CA1812
12+
{
13+
// Sort state
14+
[ObservableProperty]
15+
public partial List<SortOption> SortOptions { get; set; } = [];
16+
17+
[ObservableProperty]
18+
public partial SortOption? SelectedSortOption { get; set; }
19+
20+
[ObservableProperty]
21+
public partial bool IsSortDescending { get; set; }
22+
23+
// Filter state
24+
[ObservableProperty]
25+
public partial List<FilterItem> StatusFilters { get; set; } = [];
26+
27+
[ObservableProperty]
28+
public partial List<FilterItem> GenreFilters { get; set; } = [];
29+
30+
[ObservableProperty]
31+
public partial List<FilterItem> YearFilters { get; set; } = [];
32+
33+
[ObservableProperty]
34+
public partial List<FilterItem> RatingFilters { get; set; } = [];
35+
36+
[ObservableProperty]
37+
public partial bool HasActiveFilters { get; set; }
38+
39+
partial void OnIsSortDescendingChanged(bool value)
40+
{
41+
SaveViewSettings();
42+
_ = RefreshItemsAsync();
43+
}
44+
45+
[RelayCommand]
46+
private void SelectSortOption(SortOption option)
47+
{
48+
SelectedSortOption = option;
49+
SaveViewSettings();
50+
_ = RefreshItemsAsync();
51+
}
52+
53+
[RelayCommand]
54+
private void ToggleSortOrder() => IsSortDescending = !IsSortDescending;
55+
56+
[RelayCommand]
57+
private void ClearFilters()
58+
{
59+
_suppressRefresh = true;
60+
try
61+
{
62+
UnselectAll(StatusFilters);
63+
UnselectAll(GenreFilters);
64+
UnselectAll(YearFilters);
65+
UnselectAll(RatingFilters);
66+
}
67+
finally
68+
{
69+
_suppressRefresh = false;
70+
}
71+
72+
UpdateHasActiveFilters();
73+
SaveViewSettings();
74+
_ = RefreshItemsAsync();
75+
76+
static void UnselectAll(List<FilterItem> filters)
77+
{
78+
foreach (FilterItem filter in filters)
79+
{
80+
filter.IsSelected = false;
81+
}
82+
}
83+
}
84+
85+
private void OnFilterChanged()
86+
{
87+
if (_suppressRefresh)
88+
{
89+
return;
90+
}
91+
92+
UpdateHasActiveFilters();
93+
SaveViewSettings();
94+
_ = RefreshItemsAsync();
95+
}
96+
97+
private void UpdateHasActiveFilters()
98+
{
99+
HasActiveFilters = StatusFilters.Any(f => f.IsSelected)
100+
|| GenreFilters.Any(f => f.IsSelected)
101+
|| YearFilters.Any(f => f.IsSelected)
102+
|| RatingFilters.Any(f => f.IsSelected);
103+
}
104+
105+
private void InitializeStatusFilters()
106+
{
107+
StatusFilters =
108+
[
109+
new FilterItem(OnFilterChanged, "Unplayed", ItemFilter.IsUnplayed),
110+
new FilterItem(OnFilterChanged, "Played", ItemFilter.IsPlayed),
111+
new FilterItem(OnFilterChanged, "Resumable", ItemFilter.IsResumable),
112+
new FilterItem(OnFilterChanged, "Favorites", ItemFilter.IsFavorite),
113+
];
114+
}
115+
116+
private async Task LoadFilterValuesAsync()
117+
{
118+
try
119+
{
120+
QueryFiltersLegacy? filters = await _jellyfinApiClient.Items.Filters.GetAsync(parameters =>
121+
{
122+
parameters.QueryParameters.ParentId = _collectionItemId;
123+
parameters.QueryParameters.IncludeItemTypes = [_itemKind];
124+
});
125+
126+
if (filters is not null)
127+
{
128+
_suppressRefresh = true;
129+
130+
try
131+
{
132+
if (filters.Genres is not null)
133+
{
134+
GenreFilters = [.. filters.Genres.Where(g => g is not null).OrderBy(g => g, StringComparer.CurrentCulture).Select(g => new FilterItem(OnFilterChanged, g!))];
135+
}
136+
137+
if (filters.Years is not null)
138+
{
139+
YearFilters = [.. filters.Years.Where(y => y.HasValue).OrderByDescending(y => y!.Value).Select(y => new FilterItem(OnFilterChanged, y!.Value.ToString()))];
140+
}
141+
142+
if (filters.OfficialRatings is not null)
143+
{
144+
RatingFilters = [.. filters.OfficialRatings.Where(r => r is not null).Select(r => new FilterItem(OnFilterChanged, r!))];
145+
}
146+
147+
// Restore persisted filter selections
148+
if (_savedViewSettings is not null)
149+
{
150+
RestoreFilterSelections(GenreFilters, _savedViewSettings.GenreFilters);
151+
RestoreFilterSelections(YearFilters, _savedViewSettings.YearFilters);
152+
RestoreFilterSelections(RatingFilters, _savedViewSettings.RatingFilters);
153+
_savedViewSettings = null;
154+
}
155+
}
156+
finally
157+
{
158+
_suppressRefresh = false;
159+
}
160+
161+
UpdateHasActiveFilters();
162+
}
163+
}
164+
catch (Exception ex)
165+
{
166+
Debug.WriteLine($"Error loading filter values: {ex}");
167+
}
168+
}
169+
170+
private static List<SortOption> GetSortOptions(BaseItemKind itemKind) => itemKind switch
171+
{
172+
BaseItemKind.Movie =>
173+
[
174+
new("Name", ItemSortBy.SortName),
175+
new("Community Rating", ItemSortBy.CommunityRating),
176+
new("Critic Rating", ItemSortBy.CriticRating),
177+
new("Date Added", ItemSortBy.DateCreated),
178+
new("Date Played", ItemSortBy.DatePlayed),
179+
new("Parental Rating", ItemSortBy.OfficialRating),
180+
new("Play Count", ItemSortBy.PlayCount),
181+
new("Release Date", ItemSortBy.PremiereDate),
182+
new("Runtime", ItemSortBy.Runtime),
183+
],
184+
BaseItemKind.Series =>
185+
[
186+
new("Name", ItemSortBy.SortName),
187+
new("Community Rating", ItemSortBy.CommunityRating),
188+
new("Date Added", ItemSortBy.DateCreated),
189+
new("Date Episode Added", ItemSortBy.DateLastContentAdded),
190+
new("Date Played", ItemSortBy.SeriesDatePlayed),
191+
new("Parental Rating", ItemSortBy.OfficialRating),
192+
new("Release Date", ItemSortBy.PremiereDate),
193+
],
194+
_ =>
195+
[
196+
new("Name", ItemSortBy.SortName),
197+
new("Date Added", ItemSortBy.DateCreated),
198+
new("Release Date", ItemSortBy.PremiereDate),
199+
],
200+
};
201+
202+
private static void RestoreFilterSelections(List<FilterItem> filters, string[] savedLabels)
203+
{
204+
if (savedLabels.Length == 0)
205+
{
206+
return;
207+
}
208+
209+
HashSet<string> labelSet = new(savedLabels, StringComparer.Ordinal);
210+
foreach (FilterItem filter in filters)
211+
{
212+
if (labelSet.Contains(filter.Label))
213+
{
214+
filter.IsSelected = true;
215+
}
216+
}
217+
}
218+
219+
// x:Bind function binding helpers
220+
public static string GetSortDirectionGlyph(bool isDescending) => isDescending ? Glyphs.SortDescending : Glyphs.SortAscending;
221+
222+
public static string GetSortDirectionLabel(bool isDescending) => isDescending ? "Descending" : "Ascending";
223+
224+
public static Brush GetFilterBorderBrush(bool hasActiveFilters)
225+
=> hasActiveFilters
226+
? (Brush)Windows.UI.Xaml.Application.Current.Resources["AccentColor"]
227+
: (Brush)Windows.UI.Xaml.Application.Current.Resources["BorderSubtle"];
228+
229+
public static Windows.UI.Xaml.DependencyObject GetFilterXYFocusRight(
230+
bool hasActiveFilters,
231+
Windows.UI.Xaml.DependencyObject filterButton,
232+
Windows.UI.Xaml.DependencyObject clearFiltersButton)
233+
=> hasActiveFilters ? clearFiltersButton : filterButton;
234+
}
235+
236+
internal sealed record SortOption(string Label, ItemSortBy SortBy)
237+
{
238+
public override string ToString() => Label;
239+
}
240+
241+
internal sealed partial class FilterItem : ObservableObject
242+
{
243+
private readonly Action _onChanged;
244+
245+
public FilterItem(Action onChanged, string label, ItemFilter? itemFilter = null)
246+
{
247+
_onChanged = onChanged;
248+
Label = label;
249+
ItemFilter = itemFilter;
250+
}
251+
252+
public string Label { get; }
253+
254+
public ItemFilter? ItemFilter { get; }
255+
256+
[ObservableProperty]
257+
public partial bool IsSelected { get; set; }
258+
259+
partial void OnIsSelectedChanged(bool value) => _onChanged();
260+
}

0 commit comments

Comments
 (0)