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 _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 _filteredPlaylistSongs = []; public ObservableCollection FilteredPlaylistSongs { get { return _filteredPlaylistSongs; } set { SetProperty(ref _filteredPlaylistSongs, value); } } private ObservableCollection _selectedPlaylistSongs = []; public ObservableCollection 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 SortAllCommand { get; } public IRelayCommand 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(SortAllSongs); SortSelectedCommand = new RelayCommand(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 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().FirstOrDefault(); _pendingMovedSongIndex = e.OldStartingIndex; break; case NotifyCollectionChangedAction.Add: PlaylistSong? addedSong = e.NewItems?.Cast().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 = _audioPlayer.Playlist; 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, EventArgs e) { PlayingSong = _audioPlayer.PlayingSong; 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 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 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 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 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 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 GetSongsFromClipboardAsync() { DataPackageView dataPackageView = Clipboard.GetContent(); string data = await dataPackageView.GetTextAsync(StandardDataFormats.Text); return JsonSerializer.Deserialize(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(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 SortOptions { get; set; } = []; }