Files
harmonia/Harmonia.WinUI/ViewModels/PlaylistDetailViewModel.cs

801 lines
25 KiB
C#

using CommunityToolkit.Mvvm.Input;
using Harmonia.Core.Caching;
using Harmonia.Core.Engine;
using Harmonia.Core.Imaging;
using Harmonia.Core.Models;
using Harmonia.Core.Player;
using Harmonia.Core.Playlists;
using Harmonia.Core.Scanner;
using Harmonia.WinUI.Caching;
using Harmonia.WinUI.Messaging;
using Harmonia.WinUI.Storage;
using Microsoft.UI.Xaml.Media.Imaging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Windows.ApplicationModel.DataTransfer;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
namespace Harmonia.WinUI.ViewModels;
public partial class PlaylistDetailViewModel : ViewModelBase
{
private readonly IPlaylistManager _playlistManager;
private readonly IAudioPlayer _audioPlayer;
private readonly IAudioImageCache _audioImageCache;
private readonly IAudioBitmapImageCache _audioBitmapImageCache;
private readonly IAudioFileScanner _audioFileScanner;
private readonly IAudioEngine _audioEngine;
private readonly IStorageProvider _storageProvider;
private readonly IMessageService _messageService;
private readonly DispatcherQueue _dispatcherQueue;
private readonly ConcurrentDictionary<int, CancellationTokenSource> _imageCancellationTokens = [];
private DispatcherQueueTimer? _filterTimer;
private Playlist? _playlist;
public Playlist? Playlist
{
get => _playlist;
private set => SetProperty(ref _playlist, value);
}
private PlaylistSong? _playingSong;
public PlaylistSong? PlayingSong
{
get
{
return _playingSong;
}
set
{
SetProperty(ref _playingSong, value);
}
}
private string? _filter;
public string? Filter
{
get
{
return _filter;
}
set
{
SetProperty(ref _filter, value);
OnPropertyChanged(nameof(CanReorderSongs));
RestartFilterTimer();
}
}
public bool CanReorderSongs => string.IsNullOrWhiteSpace(Filter);
private ObservableCollection<PlaylistSong> _filteredPlaylistSongs = [];
public ObservableCollection<PlaylistSong> FilteredPlaylistSongs
{
get
{
return _filteredPlaylistSongs;
}
set
{
SetProperty(ref _filteredPlaylistSongs, value);
}
}
private ObservableCollection<PlaylistSong> _selectedPlaylistSongs = [];
public ObservableCollection<PlaylistSong> SelectedPlaylistSongs
{
get
{
return _selectedPlaylistSongs;
}
set
{
SetProperty(ref _selectedPlaylistSongs, value);
NotifySelectionDependentCommands();
}
}
public SortItem[] SortItems { get; } =
[
new SortItem
{
Name = "Album - Disc # - Track # - Title - File Name",
SortOptions =
[
new(nameof(Song.Album), ListSortDirection.Ascending),
new(nameof(Song.DiscNumber), ListSortDirection.Ascending),
new(nameof(Song.TrackNumber), ListSortDirection.Ascending),
new(nameof(Song.Title), ListSortDirection.Ascending),
new(nameof(Song.FileName), ListSortDirection.Ascending)
]
},
new SortItem
{
Name = "Title - File Name",
SortOptions =
[
new(nameof(Song.Title), ListSortDirection.Ascending),
new(nameof(Song.FileName), ListSortDirection.Ascending)
]
}
];
private bool _isPlaylistLocked;
public bool IsPlaylistLocked
{
get => _isPlaylistLocked;
private set => SetProperty(ref _isPlaylistLocked, value);
}
public IAsyncRelayCommand PlaySongCommand { get; }
public IAsyncRelayCommand NewPlaylistCommand { get; }
public IAsyncRelayCommand AddFilesCommand { get; }
public IAsyncRelayCommand AddFolderCommand { get; }
public IRelayCommand RemoveSongsCommand { get; }
public IRelayCommand CutSongsCommand { get; }
public IRelayCommand CopySongsCommand { get; }
public IAsyncRelayCommand PasteSongsCommand { get; }
public IRelayCommand OpenFileLocationCommand { get; }
public IAsyncRelayCommand RefreshTagsCommand { get; }
public IRelayCommand RemoveMissingSongsCommand { get; }
public IRelayCommand RemoveDuplicateSongsCommand { get; }
public IAsyncRelayCommand DeletePlaylistCommand { get; }
public IRelayCommand<SortItem> SortAllCommand { get; }
public IRelayCommand<SortItem> SortSelectedCommand { get; }
public IRelayCommand RandomizeAllCommand { get; }
public IRelayCommand RandomizeSelectedCommand { get; }
public IRelayCommand ReverseAllCommand { get; }
public IRelayCommand ReverseSelectedCommand { get; }
public IAsyncRelayCommand RenamePlaylistCommand { get; }
public IRelayCommand LockPlaylistCommand { get; }
public IRelayCommand UnlockPlaylistCommand { get; }
public bool IsUserUpdating { get; set; }
private bool _isUserInitiatingSongChange;
private PlaylistSong? _pendingMovedSong;
private int _pendingMovedSongIndex = -1;
public event EventHandler? PlayingSongChangedAutomatically;
public PlaylistDetailViewModel(
IAudioPlayer audioPlayer,
IAudioImageCache audioImageCache,
IAudioBitmapImageCache audioBitmapImageCache,
IAudioFileScanner audioFileScanner,
IAudioEngine audioEngine,
IStorageProvider storageProvider,
IPlaylistManager playlistManager,
IMessageService messageService)
{
_playlistManager = playlistManager;
_playlistManager.CurrentPlaylistChanged += OnPlaylistChanged;
_audioPlayer = audioPlayer;
_audioPlayer.PlaylistChanged += OnPlaylistChanged;
_audioPlayer.PlayingSongChanged += OnPlayingSongChanged;
_audioImageCache = audioImageCache;
_audioBitmapImageCache = audioBitmapImageCache;
_audioFileScanner = audioFileScanner;
_audioEngine = audioEngine;
_storageProvider = storageProvider;
_messageService = messageService;
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
PlaySongCommand = new AsyncRelayCommand(PlaySongAsync, AreSongsSelected);
NewPlaylistCommand = new AsyncRelayCommand(NewPlaylistAsync);
AddFilesCommand = new AsyncRelayCommand(AddFilesAsync);
AddFolderCommand = new AsyncRelayCommand(AddFolderAsync);
RemoveSongsCommand = new RelayCommand(RemoveSongs, AreSongsSelected);
CutSongsCommand = new RelayCommand(CutSongs, AreSongsSelected);
CopySongsCommand = new RelayCommand(CopySongs, AreSongsSelected);
PasteSongsCommand = new AsyncRelayCommand(PasteSongsAsync, CanPasteSongs);
OpenFileLocationCommand = new RelayCommand(OpenFileLocation, AreSongsSelected);
RefreshTagsCommand = new AsyncRelayCommand(RefreshTagsAsync);
RemoveMissingSongsCommand = new RelayCommand(RemoveMissingSongs);
RemoveDuplicateSongsCommand = new RelayCommand(RemoveDuplicateSongs);
DeletePlaylistCommand = new AsyncRelayCommand(DeletePlaylistAsync);
SortAllCommand = new RelayCommand<SortItem>(SortAllSongs);
SortSelectedCommand = new RelayCommand<SortItem>(SortSelectedSongs, _ => AreMultipleSongsSelected());
RandomizeAllCommand = new RelayCommand(RandomizeAllSongs);
RandomizeSelectedCommand = new RelayCommand(RandomizeSelectedSongs, AreMultipleSongsSelected);
ReverseAllCommand = new RelayCommand(ReverseAllSongs);
ReverseSelectedCommand = new RelayCommand(ReverseSelectedSongs, AreMultipleSongsSelected);
RenamePlaylistCommand = new AsyncRelayCommand(RenamePlaylist);
LockPlaylistCommand = new RelayCommand(LockPlaylist);
UnlockPlaylistCommand = new RelayCommand(UnlockPlaylist);
FilteredPlaylistSongs.CollectionChanged += OnFilteredPlaylistSongsCollectionChanged;
Playlist = _playlistManager.CurrentPlaylist; // Testing
PlayingSong = _audioPlayer.PlayingSong; // Testing
UpdateFilteredSongs();
}
private void NotifySelectionDependentCommands()
{
PlaySongCommand.NotifyCanExecuteChanged();
RemoveSongsCommand.NotifyCanExecuteChanged();
CutSongsCommand.NotifyCanExecuteChanged();
CopySongsCommand.NotifyCanExecuteChanged();
PasteSongsCommand.NotifyCanExecuteChanged();
OpenFileLocationCommand.NotifyCanExecuteChanged();
SortSelectedCommand.NotifyCanExecuteChanged();
RandomizeSelectedCommand.NotifyCanExecuteChanged();
ReverseSelectedCommand.NotifyCanExecuteChanged();
}
private void OnFilteredPlaylistSongsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (IsUserUpdating == false)
return;
// A drag-reorder in the ListView raises a Remove followed by an Add for the same item.
// Reordering is only enabled while unfiltered, so view indices map 1:1 to Playlist.Songs.
switch (e.Action)
{
case NotifyCollectionChangedAction.Remove:
_pendingMovedSong = e.OldItems?.Cast<PlaylistSong>().FirstOrDefault();
_pendingMovedSongIndex = e.OldStartingIndex;
break;
case NotifyCollectionChangedAction.Add:
PlaylistSong? addedSong = e.NewItems?.Cast<PlaylistSong>().FirstOrDefault();
if (addedSong != null && addedSong == _pendingMovedSong)
{
MoveSongInPlaylist(_pendingMovedSongIndex, e.NewStartingIndex);
}
_pendingMovedSong = null;
_pendingMovedSongIndex = -1;
break;
}
}
private void MoveSongInPlaylist(int oldIndex, int newIndex)
{
if (Playlist == null || oldIndex < 0 || oldIndex == newIndex)
return;
Playlist.MoveSong(oldIndex, newIndex);
}
private void OnPlaylistChanged(object? sender, EventArgs e)
{
Playlist?.PlaylistUpdated -= OnPlaylistUpdated;
Playlist = _playlistManager.CurrentPlaylist;
Playlist?.PlaylistUpdated += OnPlaylistUpdated;
IsPlaylistLocked = Playlist?.IsLocked ?? false;
UpdateFilteredSongs();
}
private void OnPlaylistUpdated(object? sender, PlaylistUpdatedEventArgs e)
{
if (IsUserUpdating)
return;
switch (e.Action)
{
case PlaylistUpdateAction.Add:
case PlaylistUpdateAction.Remove:
case PlaylistUpdateAction.Refresh:
_dispatcherQueue.TryEnqueue(UpdateFilteredSongs);
break;
case PlaylistUpdateAction.Move:
case PlaylistUpdateAction.Reset:
_dispatcherQueue.TryEnqueue(() => ApplyReorderedSongs(e.Songs));
break;
case PlaylistUpdateAction.Lock:
case PlaylistUpdateAction.Unlock:
IsPlaylistLocked = Playlist?.IsLocked ?? false;
break;
}
}
private void ApplyReorderedSongs(PlaylistSong[] playlistSongs)
{
// UpdateFilteredSongs assumes the projection order matches Playlist.Songs order,
// so remove reordered songs first to let them be reinserted at their new positions.
foreach (PlaylistSong playlistSong in playlistSongs)
{
FilteredPlaylistSongs.Remove(playlistSong);
}
UpdateFilteredSongs();
}
private void OnPlayingSongChanged(object? sender, PlayingSongChangedEventArgs e)
{
PlayingSong = e.NewSong;
if (_isUserInitiatingSongChange)
{
_isUserInitiatingSongChange = false;
}
else
{
PlayingSongChangedAutomatically?.Invoke(this, EventArgs.Empty);
}
}
public async Task PlaySongAsync(PlaylistSong playlistSong)
{
_isUserInitiatingSongChange = true;
await _audioPlayer.LoadAsync(playlistSong, PlaybackMode.LoadAndPlay);
}
public async Task<SongPictureInfo?> GetSongPictureInfoAsync(int hashCode, PlaylistSong playlistSong)
{
_imageCancellationTokens.TryGetValue(hashCode, out CancellationTokenSource? cancellationTokenSource);
cancellationTokenSource?.Cancel();
cancellationTokenSource = new();
_imageCancellationTokens.AddOrUpdate(hashCode, cancellationTokenSource, (_, _) => cancellationTokenSource);
return await _audioImageCache.GetAsync(playlistSong.Song, cancellationTokenSource.Token);
}
public async Task<BitmapImage?> GetBitmapImageAsync(int hashCode, PlaylistSong playlistSong)
{
_imageCancellationTokens.TryGetValue(hashCode, out CancellationTokenSource? cancellationTokenSource);
cancellationTokenSource?.Cancel();
cancellationTokenSource = new();
_imageCancellationTokens.AddOrUpdate(hashCode, cancellationTokenSource, (_, _) => cancellationTokenSource);
return await _audioBitmapImageCache.GetAsync(playlistSong.Song, cancellationTokenSource.Token);
}
public async Task<BitmapImage?> GetBitmapAsync(PlaylistSong playlistSong, CancellationToken cancellationToken)
{
return await _audioBitmapImageCache.GetAsync(playlistSong.Song, cancellationToken);
}
#region Filtering
private void RestartFilterTimer()
{
if (_filterTimer == null)
{
_filterTimer = _dispatcherQueue.CreateTimer();
_filterTimer.Interval = TimeSpan.FromMilliseconds(300);
_filterTimer.IsRepeating = false;
_filterTimer.Tick += OnFilterTimerTick;
}
_filterTimer.Stop();
_filterTimer.Start();
}
private void OnFilterTimerTick(DispatcherQueueTimer sender, object args)
{
UpdateFilteredSongs();
}
private void UpdateFilteredSongs()
{
if (Playlist == null)
return;
string? filter = Filter;
HashSet<PlaylistSong> updatedFilterSet = [.. Playlist.Songs.Where(playlistSong => IsFiltered(playlistSong.Song, filter))];
for (int i = FilteredPlaylistSongs.Count - 1; i >= 0; i--)
{
PlaylistSong playlistSong = FilteredPlaylistSongs[i];
bool inFilterSet = updatedFilterSet.Contains(playlistSong);
if (!inFilterSet)
FilteredPlaylistSongs.RemoveAt(i);
}
HashSet<PlaylistSong> currentSet = [.. FilteredPlaylistSongs];
int insertionIndex = 0;
foreach (PlaylistSong playlistSong in Playlist.Songs)
{
bool inFilterSet = updatedFilterSet.Contains(playlistSong);
bool inCurrentSet = currentSet.Contains(playlistSong);
if (!inFilterSet)
continue;
if (!inCurrentSet)
{
FilteredPlaylistSongs.Insert(insertionIndex, playlistSong);
}
insertionIndex++;
}
}
private static bool IsFiltered(Song song, string? filter)
{
if (string.IsNullOrWhiteSpace(filter))
return true;
var shortFileName = Path.GetFileName(song.FileName);
if (shortFileName.Contains(filter, StringComparison.OrdinalIgnoreCase))
return true;
if (string.IsNullOrWhiteSpace(song.Title) == false && song.Title.Contains(filter, StringComparison.OrdinalIgnoreCase))
return true;
if (string.IsNullOrWhiteSpace(song.Album) == false && song.Album.Contains(filter, StringComparison.OrdinalIgnoreCase))
return true;
if (song.AlbumArtists.Any(x => x.Contains(filter, StringComparison.OrdinalIgnoreCase)))
return true;
if (song.Artists.Any(x => x.Contains(filter, StringComparison.OrdinalIgnoreCase)))
return true;
return false;
}
#endregion
#region Commands
private async Task PlaySongAsync()
{
if (SelectedPlaylistSongs.Count == 0)
return;
await _audioPlayer.LoadAsync(SelectedPlaylistSongs[0], PlaybackMode.LoadAndPlay);
}
private bool AreSongsSelected()
{
return SelectedPlaylistSongs.Count > 0;
}
private bool AreMultipleSongsSelected()
{
return SelectedPlaylistSongs.Count > 1;
}
private async Task NewPlaylistAsync(CancellationToken cancellationToken)
{
string playlistName = await _messageService.InputTextAsync(
$"New Playlist",
"Enter a new name for the playlist:",
"New Playlist");
if (string.IsNullOrWhiteSpace(playlistName))
return;
Playlist playlist = await _playlistManager.AddPlaylistAsync();
playlist.SetName(playlistName);
_playlistManager.CurrentPlaylist = playlist;
}
private async Task AddFilesAsync(CancellationToken cancellationToken)
{
if (Playlist == null)
return;
FilePickerOptions filePickerOptions = new()
{
FileTypeFilter = [GetAudioFileTypes()],
};
string[] fileNames = await _storageProvider.GetFilesAsync(filePickerOptions);
Song[] songs = await _audioFileScanner.GetSongsAsync(fileNames, cancellationToken);
Playlist.AddSongs(songs);
}
private FilePickerFileType GetAudioFileTypes()
{
string[] patterns = [.. _audioEngine.SupportedFormats.Select(format => format.Replace("*", ""))];
return new()
{
Name = "Audio Files",
Patterns = patterns
};
}
private async Task AddFolderAsync(CancellationToken cancellationToken)
{
if (Playlist == null)
return;
string? path = await _storageProvider.GetPathAsync();
if (string.IsNullOrWhiteSpace(path))
return;
Song[] songs = await _audioFileScanner.GetSongsFromPathAsync(path, cancellationToken);
Playlist.AddSongs(songs);
}
public async Task AddFilesAsync(string[] fileNames, CancellationToken cancellationToken)
{
if (Playlist == null)
return;
Song[] songs = await _audioFileScanner.GetSongsAsync(fileNames, cancellationToken);
Playlist.AddSongs(songs);
}
public async Task AddFolderAsync(string path, CancellationToken cancellationToken)
{
if (Playlist == null)
return;
Song[] songs = await _audioFileScanner.GetSongsFromPathAsync(path, cancellationToken);
Playlist.AddSongs(songs);
}
private void RemoveSongs()
{
if (Playlist == null)
return;
if (SelectedPlaylistSongs.Count == 0)
return;
PlaylistSong[] playlistSongs = [.. SelectedPlaylistSongs];
Playlist.RemoveSongs(playlistSongs);
}
private void CutSongs()
{
if (SelectedPlaylistSongs.Count == 0)
return;
CopySelectedSongsToClipboard();
}
private void CopySongs()
{
if (SelectedPlaylistSongs.Count == 0)
return;
CopySelectedSongsToClipboard();
}
private void CopySelectedSongsToClipboard()
{
Song[] songs = [.. SelectedPlaylistSongs.Select(playlistSong => playlistSong.Song)];
DataPackage dataPackage = new()
{
RequestedOperation = DataPackageOperation.Copy
};
dataPackage.Properties.Add("Type", "SongList");
dataPackage.SetData(StandardDataFormats.Text, JsonSerializer.Serialize(songs));
Clipboard.SetContent(dataPackage);
}
private bool CanPasteSongs()
{
if (Playlist == null || SelectedPlaylistSongs.Count == 0)
return false;
DataPackageView dataPackageView = Clipboard.GetContent();
if (dataPackageView == null)
return false;
if (dataPackageView.Properties.ContainsKey("Type") == false)
return false;
return dataPackageView.Properties["Type"].ToString() == "SongList";
}
private async Task PasteSongsAsync()
{
if (Playlist == null || SelectedPlaylistSongs.Count == 0)
return;
int selectedPlaylistSongIndex = Playlist.IndexOf(SelectedPlaylistSongs[0]);
if (selectedPlaylistSongIndex == -1)
return;
Song[] songs = await GetSongsFromClipboardAsync();
Playlist.AddSongs(songs, selectedPlaylistSongIndex + 1);
}
private static async Task<Song[]> GetSongsFromClipboardAsync()
{
DataPackageView dataPackageView = Clipboard.GetContent();
string data = await dataPackageView.GetTextAsync(StandardDataFormats.Text);
return JsonSerializer.Deserialize<Song[]>(data) ?? [];
}
private void OpenFileLocation()
{
if (SelectedPlaylistSongs.Count == 0)
return;
string argument = "/select, \"" + SelectedPlaylistSongs[0].Song.FileName + "\"";
Process.Start("explorer.exe", argument);
}
private async Task RefreshTagsAsync(CancellationToken cancellationToken)
{
if (Playlist is null)
return;
// TODO: Have different method for selected songs versus all songs in the playlist
PlaylistSong[] targets = SelectedPlaylistSongs.Count > 0
? [.. SelectedPlaylistSongs]
: [.. Playlist.Songs];
string[] fileNames = [.. targets
.Select(ps => ps.Song.FileName)
.Distinct(StringComparer.OrdinalIgnoreCase)];
Song[] songs = await _audioFileScanner.GetSongsAsync(fileNames, cancellationToken);
Playlist.ImportTags(songs);
}
private void RemoveMissingSongs()
{
Playlist?.RemoveMissingSongs();
}
private void RemoveDuplicateSongs()
{
Playlist?.RemoveDuplicateSongs();
}
private async Task DeletePlaylistAsync()
{
if (Playlist == null)
return;
bool confirmed = await _messageService.ConfirmAsync($"Delete Playlist - {Playlist.Name}", "Are you sure you want to delete this playlist?");
if (!confirmed)
return;
_playlistManager.RemovePlaylist(Playlist);
if (_playlistManager.CurrentPlaylist == Playlist)
{
if (_playlistManager.Playlists.Count > 0)
{
_playlistManager.CurrentPlaylist = _playlistManager.Playlists.ElementAt(0);
}
else
{
_playlistManager.CurrentPlaylist = null;
}
}
}
private void SortAllSongs(SortItem? sortItem)
{
if (Playlist == null || sortItem == null)
return;
Playlist.SortSongs([.. Playlist.Songs], [.. sortItem.SortOptions]);
}
private void SortSelectedSongs(SortItem? sortItem)
{
if (Playlist == null || sortItem == null)
return;
if (SelectedPlaylistSongs.Count < 2)
return;
string[] selectedPlaylistSongIds = [.. SelectedPlaylistSongs.Select(x => x.UID)];
Playlist.SortSongs([.. SelectedPlaylistSongs], [.. sortItem.SortOptions]);
RestoreSelectedSongs(selectedPlaylistSongIds);
}
private void RestoreSelectedSongs(string[] playlistSongIds)
{
_dispatcherQueue.TryEnqueue(() =>
{
PlaylistSong[] selectedPlaylistSongs = [.. Playlist?.Songs.Where(x => playlistSongIds.Contains(x.UID)) ?? []];
SelectedPlaylistSongs = new ObservableCollection<PlaylistSong>(selectedPlaylistSongs);
});
}
private void RandomizeAllSongs()
{
if (Playlist == null || Playlist.Songs.Count < 2)
return;
Playlist.Randomize([.. Playlist.Songs]);
}
private void RandomizeSelectedSongs()
{
if (Playlist == null || SelectedPlaylistSongs.Count < 2)
return;
string[] selectedPlaylistSongIds = [.. SelectedPlaylistSongs.Select(x => x.UID)];
Playlist.Randomize([.. SelectedPlaylistSongs]);
RestoreSelectedSongs(selectedPlaylistSongIds);
}
private void ReverseAllSongs()
{
if (Playlist == null || Playlist.Songs.Count < 2)
return;
Playlist.Reverse([.. Playlist.Songs]);
}
private void ReverseSelectedSongs()
{
if (Playlist == null || SelectedPlaylistSongs.Count < 2)
return;
string[] selectedPlaylistSongIds = [.. SelectedPlaylistSongs.Select(x => x.UID)];
Playlist.Reverse([.. SelectedPlaylistSongs]);
RestoreSelectedSongs(selectedPlaylistSongIds);
}
private void LockPlaylist()
{
Playlist?.Lock();
}
private void UnlockPlaylist()
{
Playlist?.Unlock();
}
private async Task RenamePlaylist()
{
if (Playlist == null)
return;
string newName = await _messageService.InputTextAsync(
$"Rename Playlist - {Playlist.Name}",
"Enter a new name for the playlist:",
Playlist.Name ?? string.Empty);
if (string.IsNullOrWhiteSpace(newName))
return;
Playlist.SetName(newName);
}
#endregion
}
public class SortItem
{
public string Name { get; set; } = string.Empty;
public IReadOnlyCollection<SortOption> SortOptions { get; set; } = [];
}