Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 53 additions & 8 deletions src/JellyBox/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(nameof(ServerUrl));
set => SetProperty(nameof(ServerUrl), value);
get => _settings.GetProperty<string>(nameof(ServerUrl));
set => _settings.SetProperty(nameof(ServerUrl), value);
}

public string? AccessToken
{
get => GetProperty<string>(nameof(AccessToken));
set => SetProperty(nameof(AccessToken), value);
get => _settings.GetProperty<string>(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<string>(nameof(LibraryViewSettings.SortBy)),
container.GetProperty<bool>(nameof(LibraryViewSettings.SortDescending)),
ParseList(container.GetProperty<string>(nameof(LibraryViewSettings.StatusFilters))),
ParseList(container.GetProperty<string>(nameof(LibraryViewSettings.GenreFilters))),
ParseList(container.GetProperty<string>(nameof(LibraryViewSettings.YearFilters))),
ParseList(container.GetProperty<string>(nameof(LibraryViewSettings.RatingFilters))));

static string[] ParseList(string? value)
=> string.IsNullOrEmpty(value) ? [] : value.Split('\n');
}

private static T? GetProperty<T>(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<T>(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);
4 changes: 4 additions & 0 deletions src/JellyBox/Glyphs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
260 changes: 260 additions & 0 deletions src/JellyBox/ViewModels/LibraryViewModel.Filtering.cs
Original file line number Diff line number Diff line change
@@ -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<SortOption> SortOptions { get; set; } = [];

[ObservableProperty]
public partial SortOption? SelectedSortOption { get; set; }

[ObservableProperty]
public partial bool IsSortDescending { get; set; }

// Filter state
[ObservableProperty]
public partial List<FilterItem> StatusFilters { get; set; } = [];

[ObservableProperty]
public partial List<FilterItem> GenreFilters { get; set; } = [];

[ObservableProperty]
public partial List<FilterItem> YearFilters { get; set; } = [];

[ObservableProperty]
public partial List<FilterItem> 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<FilterItem> 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<SortOption> 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<FilterItem> filters, string[] savedLabels)
{
if (savedLabels.Length == 0)
{
return;
}

HashSet<string> 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();
}
Loading