diff --git a/README.md b/README.md index 7332cd59..5ac9c1d8 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ You can easily contribute translations to Stylophone! To help translate, follow ## Screenshots -![Screen1](Screenshots/Screen1.png) -![Screen2](Screenshots/Screen2.png) -![Screen3](Screenshots/Screen3.png) +![Screen1](Screenshots/Screen1.jpg) +![Screen2](Screenshots/Screen2.jpg) +![Screen3](Screenshots/Screen3.jpg) ![Screen4](Screenshots/Screen4.jpg) ![Screen5](Screenshots/Screen5.jpg) ![Screen6](Screenshots/Screen6.jpg) diff --git a/Screenshots/Screen1.jpg b/Screenshots/Screen1.jpg new file mode 100644 index 00000000..560885c4 Binary files /dev/null and b/Screenshots/Screen1.jpg differ diff --git a/Screenshots/Screen1.png b/Screenshots/Screen1.png deleted file mode 100644 index 259c14e9..00000000 Binary files a/Screenshots/Screen1.png and /dev/null differ diff --git a/Screenshots/Screen2.jpg b/Screenshots/Screen2.jpg new file mode 100644 index 00000000..306e48e0 Binary files /dev/null and b/Screenshots/Screen2.jpg differ diff --git a/Screenshots/Screen2.png b/Screenshots/Screen2.png deleted file mode 100644 index c5dbc990..00000000 Binary files a/Screenshots/Screen2.png and /dev/null differ diff --git a/Screenshots/Screen3.jpg b/Screenshots/Screen3.jpg new file mode 100644 index 00000000..c66e030d Binary files /dev/null and b/Screenshots/Screen3.jpg differ diff --git a/Screenshots/Screen3.png b/Screenshots/Screen3.png deleted file mode 100644 index e3d1ee05..00000000 Binary files a/Screenshots/Screen3.png and /dev/null differ diff --git a/Screenshots/Screen4.jpg b/Screenshots/Screen4.jpg index 2e55a77c..2d5b794c 100644 Binary files a/Screenshots/Screen4.jpg and b/Screenshots/Screen4.jpg differ diff --git a/Screenshots/Screen5.jpg b/Screenshots/Screen5.jpg index a78be6d3..cb266ab4 100644 Binary files a/Screenshots/Screen5.jpg and b/Screenshots/Screen5.jpg differ diff --git a/Screenshots/Screen6.jpg b/Screenshots/Screen6.jpg index 2b932863..6d85de30 100644 Binary files a/Screenshots/Screen6.jpg and b/Screenshots/Screen6.jpg differ diff --git a/Sources/FluentMPC/App.xaml.cs b/Sources/FluentMPC/App.xaml.cs index b621a2b2..81d306d4 100644 --- a/Sources/FluentMPC/App.xaml.cs +++ b/Sources/FluentMPC/App.xaml.cs @@ -32,6 +32,9 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) Windows.ApplicationModel.Core.CoreApplication.GetCurrentView().TitleBar.ExtendViewIntoTitleBar = true; Windows.ApplicationModel.Core.CoreApplication.EnablePrelaunch(true); + // https://docs.microsoft.com/en-us/windows/uwp/design/devices/designing-for-tv#custom-visual-state-trigger-for-xbox + ApplicationView.GetForCurrentView().SetDesiredBoundsMode(ApplicationViewBoundsMode.UseCoreWindow); + // Compact sizing var isCompactEnabled = await ApplicationData.Current.LocalSettings.ReadAsync("IsCompactSizing"); if (isCompactEnabled) diff --git a/Sources/FluentMPC/FluentMPC.csproj b/Sources/FluentMPC/FluentMPC.csproj index 6a7ead80..70e566c6 100644 --- a/Sources/FluentMPC/FluentMPC.csproj +++ b/Sources/FluentMPC/FluentMPC.csproj @@ -31,6 +31,14 @@ B531E204B87F721D074590440536659EA5743070 True + + + true + + true + + false + true bin\x86\Debug\ @@ -197,6 +205,7 @@ + @@ -218,6 +227,9 @@ FoldersPage.xaml + + SearchResultsPage.xaml + LibraryDetailPage.xaml @@ -285,6 +297,10 @@ MSBuild:Compile Designer + + MSBuild:Compile + Designer + MSBuild:Compile Designer @@ -322,11 +338,6 @@ Designer - - - Designer - - @@ -378,6 +389,17 @@ + + Designer + TextTemplatingFileGenerator + Package.appxmanifest + + + True + True + Package.tt + Designer + @@ -402,10 +424,20 @@ MpcNET + + + 14.0 + + + $(Configuration) + false + + + + + + + + + + + + diff --git a/Sources/FluentMPC/ViewModels/AlbumDetailViewModel.cs b/Sources/FluentMPC/ViewModels/AlbumDetailViewModel.cs index 85a1f2b3..7679aff6 100644 --- a/Sources/FluentMPC/ViewModels/AlbumDetailViewModel.cs +++ b/Sources/FluentMPC/ViewModels/AlbumDetailViewModel.cs @@ -11,6 +11,8 @@ using Microsoft.Toolkit.Uwp.Helpers; using MpcNET.Commands.Playback; using MpcNET.Commands.Playlist; +using MpcNET.Commands.Queue; +using MpcNET.Commands.Reflection; using MpcNET.Types; namespace FluentMPC.ViewModels @@ -37,6 +39,57 @@ private set public ObservableCollection Source { get; } = new ObservableCollection(); + private ICommand _addToQueueCommand; + public ICommand AddToQueueCommand => _addToQueueCommand ?? (_addToQueueCommand = new RelayCommand>(QueueTrack)); + + private async void QueueTrack(object list) + { + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var commandList = new CommandList(); + + foreach (var f in selectedTracks) + { + var trackVM = f as TrackViewModel; + commandList.Add(new AddIdCommand(trackVM.File.Path)); + } + + var r = await MPDConnectionService.SafelySendCommandAsync(commandList); + if (r != null) + NotificationService.ShowInAppNotification("AddedToQueueText".GetLocalized()); + } + } + + private ICommand _addToPlaylistCommand; + public ICommand AddToPlayListCommand => _addToPlaylistCommand ?? (_addToPlaylistCommand = new RelayCommand>(AddToPlaylist)); + + private async void AddToPlaylist(object list) + { + var playlistName = await DialogService.ShowAddToPlaylistDialog(); + if (playlistName == null) return; + + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var commandList = new CommandList(); + + foreach (var f in selectedTracks) + { + var trackVM = f as TrackViewModel; + commandList.Add(new PlaylistAddCommand(playlistName, trackVM.File.Path)); + } + + var req = await MPDConnectionService.SafelySendCommandAsync(commandList); + + if (req != null) + NotificationService.ShowInAppNotification(string.Format("AddedToPlaylistText".GetLocalized(), playlistName)); + } + } + + public AlbumDetailViewModel() { } diff --git a/Sources/FluentMPC/ViewModels/Items/AlbumViewModel.cs b/Sources/FluentMPC/ViewModels/Items/AlbumViewModel.cs index eea931fb..ed31470e 100644 --- a/Sources/FluentMPC/ViewModels/Items/AlbumViewModel.cs +++ b/Sources/FluentMPC/ViewModels/Items/AlbumViewModel.cs @@ -122,10 +122,6 @@ private async void AddToPlaylist() var playlistName = await DialogService.ShowAddToPlaylistDialog(); if (playlistName == null || Files.Count == 0) return; - // Adding a file to a playlist somehow triggers the server's "playlist" event, which is normally used for the queue... - // We disable queue events temporarily in order to avoid UI jitter by a refreshed queue. - MPDConnectionService.DisableQueueEvents = true; - var commandList = new CommandList(); foreach (var f in Files) @@ -137,8 +133,6 @@ private async void AddToPlaylist() { NotificationService.ShowInAppNotification(string.Format("AddedToPlaylistText".GetLocalized(), playlistName)); } - - MPDConnectionService.DisableQueueEvents = false; } private ICommand _addToQueueCommand; @@ -209,7 +203,11 @@ public async Task LoadAlbumDataAsync(MpcConnection c) if (!findReq.IsResponseValid) return; - Files.AddRange(findReq.Response.Content); + // If files were already added, don't re-add them. + // This can occasionally happen if the server is a bit overloaded when we look at an album, since AlbumDetailViewModel can call this method a second time. + if (Files.Count == 0) + Files.AddRange(findReq.Response.Content); + Artist = Files.Select(f => f.Artist).Distinct().Aggregate((f1, f2) => $"{f1}, {f2}"); // If we've already generated album art, don't use the queue and directly grab it diff --git a/Sources/FluentMPC/ViewModels/Items/FilePathViewModel.cs b/Sources/FluentMPC/ViewModels/Items/FilePathViewModel.cs index eaed8f84..30c98851 100644 --- a/Sources/FluentMPC/ViewModels/Items/FilePathViewModel.cs +++ b/Sources/FluentMPC/ViewModels/Items/FilePathViewModel.cs @@ -121,16 +121,10 @@ private async void AddToPlaylist() var playlistName = await DialogService.ShowAddToPlaylistDialog(); if (playlistName == null) return; - // Adding a file to a playlist somehow triggers the server's "playlist" event, which is normally used for the queue... - // We disable queue events temporarily in order to avoid UI jitter by a refreshed queue. - MPDConnectionService.DisableQueueEvents = true; - var response = await MPDConnectionService.SafelySendCommandAsync(new PlaylistAddCommand(playlistName, Path)); if (response != null) NotificationService.ShowInAppNotification(string.Format("AddedToPlaylistText".GetLocalized(), playlistName)); - - MPDConnectionService.DisableQueueEvents = false; } public FilePathViewModel(IMpdFilePath file) diff --git a/Sources/FluentMPC/ViewModels/Items/TrackViewModel.cs b/Sources/FluentMPC/ViewModels/Items/TrackViewModel.cs index 3c559b73..b3198933 100644 --- a/Sources/FluentMPC/ViewModels/Items/TrackViewModel.cs +++ b/Sources/FluentMPC/ViewModels/Items/TrackViewModel.cs @@ -94,16 +94,10 @@ private async void AddToPlaylist(IMpdFile file) var playlistName = await DialogService.ShowAddToPlaylistDialog(); if (playlistName == null) return; - // Adding a file to a playlist somehow triggers the server's "playlist" event, which is normally used for the queue... - // We disable queue events temporarily in order to avoid UI jitter by a refreshed queue. - MPDConnectionService.DisableQueueEvents = true; - var req = await MPDConnectionService.SafelySendCommandAsync(new PlaylistAddCommand(playlistName, file.Path)); if (req != null) NotificationService.ShowInAppNotification(string.Format("AddedToPlaylistText".GetLocalized(), playlistName)); - - MPDConnectionService.DisableQueueEvents = false; } private ICommand _viewAlbumCommand; diff --git a/Sources/FluentMPC/ViewModels/Playback/PlaybackViewModel.cs b/Sources/FluentMPC/ViewModels/Playback/PlaybackViewModel.cs index 7d4cda0f..d02f9e88 100644 --- a/Sources/FluentMPC/ViewModels/Playback/PlaybackViewModel.cs +++ b/Sources/FluentMPC/ViewModels/Playback/PlaybackViewModel.cs @@ -27,7 +27,6 @@ namespace FluentMPC.ViewModels.Playback public class PlaybackViewModel : Observable { private readonly CoreDispatcher _currentUiDispatcher; - private readonly VisualizationType _hostType; private CancellationTokenSource _albumArtCancellationSource = new CancellationTokenSource(); @@ -66,6 +65,24 @@ public TrackViewModel NextTrack public bool HasNextTrack => NextTrack != null; + private VisualizationType _hostType; + public VisualizationType HostType + { + get => _hostType; + set { + Set(ref _hostType, value); + + Task.Run(async () => + { + _albumArtCancellationSource.Cancel(); + _albumArtCancellationSource = new CancellationTokenSource(); + + // Reload CurrentTrack to take into account the new VisualizationType + CurrentTrack = new TrackViewModel(CurrentTrack.File, true, HostType, _currentUiDispatcher, _albumArtCancellationSource.Token); + }); + } + } + /// /// The amount of time spent listening to the track /// @@ -289,7 +306,7 @@ public PlaybackViewModel() : this(CoreApplication.MainView.Dispatcher, Visualiza public PlaybackViewModel(CoreDispatcher uiDispatcher, VisualizationType hostType) { _currentUiDispatcher = uiDispatcher; - _hostType = hostType; + _hostType = hostType; // Bind the methods that we need MPDConnectionService.StatusChanged += OnStateChange; @@ -346,6 +363,9 @@ private async void UpdateInformation(object sender, object e) if (CurrentTrack == null) return; + if (!HasNextTrack) + UpdateUpNextAsync(); + await _currentUiDispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { // Set the current time value - if the user isn't scrobbling the slider @@ -378,16 +398,10 @@ public async void SaveQueue() var playlistName = await DialogService.ShowAddToPlaylistDialog(false); if (playlistName == null) return; - // Adding a file to a playlist somehow triggers the server's "playlist" event, which is normally used for the queue... - // We disable queue events temporarily in order to avoid UI jitter by a refreshed queue. - MPDConnectionService.DisableQueueEvents = true; - var req = await MPDConnectionService.SafelySendCommandAsync(new SaveCommand(playlistName), _currentUiDispatcher); if (req != null) NotificationService.ShowInAppNotification(string.Format("AddedToPlaylistText".GetLocalized(), playlistName)); - - MPDConnectionService.DisableQueueEvents = false; } /// @@ -472,7 +486,7 @@ public void ToggleMute() public void NavigateNowPlaying() { - NavigationService.Navigate(typeof(PlaybackView)); + NavigationService.Navigate(typeof(PlaybackView), this); } #endregion Track Control Methods @@ -572,16 +586,8 @@ await _currentUiDispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { if (response != null) { - // Disable album art loading if this PlaybackViewModel doesn't belong to a view that shows it. - // This boolean doesn't do much for now, see below TODO - var loadAlbumArt = _hostType.IsOneOf(VisualizationType.FullScreenPlayback, VisualizationType.OverlayPlayback, VisualizationType.NowPlayingBar); - - // TODO: To avoid concurrent art loading, also disable it in NowPlaying when fullscreen is enabled. - // This breaks dominantcolor loading in its current state... - // || (_hostType == VisualizationType.NowPlayingBar && ShowTrackName); - // Set the new current track, updating the UI with the correct Dispatcher - CurrentTrack = new TrackViewModel(response, loadAlbumArt, _hostType, _currentUiDispatcher, _albumArtCancellationSource.Token); + CurrentTrack = new TrackViewModel(response, true, HostType, _currentUiDispatcher, _albumArtCancellationSource.Token); } else { diff --git a/Sources/FluentMPC/ViewModels/PlaylistViewModel.cs b/Sources/FluentMPC/ViewModels/PlaylistViewModel.cs index 89f3abb8..e60beb64 100644 --- a/Sources/FluentMPC/ViewModels/PlaylistViewModel.cs +++ b/Sources/FluentMPC/ViewModels/PlaylistViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Linq; @@ -29,6 +30,16 @@ public class PlaylistViewModel : Observable public bool IsSourceEmpty => Source.Count == 0; + #region Commands + + private bool IsSingleTrackSelected(object list) + { + // Cast the received __ComObject + var selectedTracks = (IList)list; + + return (selectedTracks?.Count == 1); + } + private ICommand _deletePlaylistCommand; public ICommand RemovePlaylistCommand => _deletePlaylistCommand ?? (_deletePlaylistCommand = new RelayCommand(DeletePlaylist)); private async void DeletePlaylist() @@ -53,9 +64,9 @@ private async void DeletePlaylist() } } - private ICommand _addToQueueCommand; - public ICommand AddPlaylistCommand => _addToQueueCommand ?? (_addToQueueCommand = new RelayCommand(AddToQueue)); - private async void AddToQueue() + private ICommand _loadPlaylistCommand; + public ICommand LoadPlaylistCommand => _loadPlaylistCommand ?? (_loadPlaylistCommand = new RelayCommand(LoadPlaylist)); + private async void LoadPlaylist() { var res = await MPDConnectionService.SafelySendCommandAsync(new LoadCommand(Name)); @@ -76,16 +87,72 @@ private async void PlayPlaylist() } } + private ICommand _addToQueueCommand; + public ICommand AddToQueueCommand => _addToQueueCommand ?? (_addToQueueCommand = new RelayCommand>(QueueTrack)); + + private async void QueueTrack(object list) + { + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var commandList = new CommandList(); + + foreach (var f in selectedTracks) + { + var trackVM = f as TrackViewModel; + commandList.Add(new AddIdCommand(trackVM.File.Path)); + } + + var r = await MPDConnectionService.SafelySendCommandAsync(commandList); + if (r != null) + NotificationService.ShowInAppNotification("AddedToQueueText".GetLocalized()); + } + } + + private ICommand _viewAlbumCommand; + public ICommand ViewAlbumCommand => _viewAlbumCommand ?? (_viewAlbumCommand = new RelayCommand>(ViewAlbum, IsSingleTrackSelected)); + + private void ViewAlbum(object list) + { + // Cast the received __ComObject + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var trackVM = selectedTracks.First() as TrackViewModel; + trackVM.ViewAlbumCommand.Execute(trackVM.File); + } + } + private ICommand _removeTrackCommand; - public ICommand RemoveTrackFromPlaylistCommand => _removeTrackCommand ?? (_removeTrackCommand = new RelayCommand(RemoveTrack)); - private async void RemoveTrack(TrackViewModel track) + public ICommand RemoveTrackFromPlaylistCommand => _removeTrackCommand ?? (_removeTrackCommand = new RelayCommand> (RemoveTrack)); + + private async void RemoveTrack(object list) { - var trackPos = Source.IndexOf(track); - var r = await MPDConnectionService.SafelySendCommandAsync(new PlaylistDeleteCommand(Name, trackPos)); + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var commandList = new CommandList(); + + // We can't batch PlaylistDeleteCommands cleanly, since they're index-based and logically, said indexes will shift as we remove stuff from the playlist. + // To simulate this behavior, we copy our Source list and incrementally remove the affected tracks from it to get the valid indexes as we move down the commandList. + IList copy = Source.ToList(); - if (r != null) // Reload playlist - await LoadDataAsync(Name); + foreach (var f in selectedTracks) + { + var trackVM = f as TrackViewModel; + commandList.Add(new PlaylistDeleteCommand(Name, copy.IndexOf(trackVM))); + copy.Remove(trackVM); + } + + var r = await MPDConnectionService.SafelySendCommandAsync(commandList); + if (r != null) // Reload playlist + await LoadDataAsync(Name); + } } + #endregion public PlaylistViewModel() { diff --git a/Sources/FluentMPC/ViewModels/QueueViewModel.cs b/Sources/FluentMPC/ViewModels/QueueViewModel.cs index f6e71512..21d0de46 100644 --- a/Sources/FluentMPC/ViewModels/QueueViewModel.cs +++ b/Sources/FluentMPC/ViewModels/QueueViewModel.cs @@ -12,7 +12,10 @@ using Microsoft.Toolkit.Uwp.Helpers; using MpcNET; using MpcNET.Commands.Playback; +using MpcNET.Commands.Playlist; using MpcNET.Commands.Queue; +using MpcNET.Commands.Reflection; +using MpcNET.Commands.Status; using MpcNET.Types; using Sundew.Base.Collections; @@ -22,6 +25,95 @@ public class QueueViewModel : Observable { private NotifyCollectionChangedAction _previousAction; private int _oldId; + private int _playlistVersion; + + private bool IsSingleTrackSelected(object list) + { + // Cast the received __ComObject + var selectedTracks = (IList)list; + + return (selectedTracks?.Count == 1); + } + + private ICommand _playCommand; + public ICommand PlayTrackCommand => _playCommand ?? (_playCommand = new RelayCommand>(PlayTrack, IsSingleTrackSelected)); + + private async void PlayTrack(object list) + { + // Cast the received __ComObject + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var trackVM = selectedTracks.First() as TrackViewModel; + await MPDConnectionService.SafelySendCommandAsync(new PlayIdCommand(trackVM.File.Id)); + } + } + + private ICommand _viewAlbumCommand; + public ICommand ViewAlbumCommand => _viewAlbumCommand ?? (_viewAlbumCommand = new RelayCommand>(ViewAlbum, IsSingleTrackSelected)); + + private void ViewAlbum(object list) + { + // Cast the received __ComObject + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var trackVM = selectedTracks.First() as TrackViewModel; + trackVM.ViewAlbumCommand.Execute(trackVM.File); + } + } + + private ICommand _removeCommand; + public ICommand RemoveFromQueueCommand => _removeCommand ?? (_removeCommand = new RelayCommand>(RemoveTrack)); + + private async void RemoveTrack(object list) + { + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var commandList = new CommandList(); + + foreach (var f in selectedTracks) + { + var trackVM = f as TrackViewModel; + commandList.Add(new DeleteIdCommand(trackVM.File.Id)); + } + // The delete command is fired twice -- Make sure the deleted tracks are unselected to avoid sending a second DeleteIdCommand. + selectedTracks.Clear(); + + await MPDConnectionService.SafelySendCommandAsync(commandList); + } + } + + private ICommand _addToPlaylistCommand; + public ICommand AddToPlayListCommand => _addToPlaylistCommand ?? (_addToPlaylistCommand = new RelayCommand>(AddToPlaylist)); + + private async void AddToPlaylist(object list) + { + var playlistName = await DialogService.ShowAddToPlaylistDialog(); + if (playlistName == null) return; + + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var commandList = new CommandList(); + + foreach (var f in selectedTracks) + { + var trackVM = f as TrackViewModel; + commandList.Add(new PlaylistAddCommand(playlistName, trackVM.File.Path)); + } + + var req = await MPDConnectionService.SafelySendCommandAsync(commandList); + + if (req != null) + NotificationService.ShowInAppNotification(string.Format("AddedToPlaylistText".GetLocalized(), playlistName)); + } + } public QueueViewModel() { @@ -31,11 +123,16 @@ public QueueViewModel() Source.CollectionChanged += (s, e) => OnPropertyChanged(nameof(IsSourceEmpty)); } + /// + /// This method is only fired if the source is changed outside of MPD Events. + /// So basically, only for reordering right now! + /// When reordering multiple items, the ListView sends events in a remove->add->remove->add fashion. + /// private async void Source_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Add && _previousAction == NotifyCollectionChangedAction.Remove) { - // User reordered tracks, send matching command + // One Remove->Add Pair means one MoveIdCommand await MPDConnectionService.SafelySendCommandAsync(new MoveIdCommand(_oldId, e.NewStartingIndex)); _previousAction = NotifyCollectionChangedAction.Move; } @@ -49,23 +146,66 @@ private async void Source_CollectionChanged(object sender, NotifyCollectionChang private void MPDConnectionService_ConnectionChanged(object sender, EventArgs e) { if (MPDConnectionService.IsConnected) - Task.Run(async () => await LoadDataAsync()); + Task.Run(async () => await LoadInitialDataAsync()); } - private void MPDConnectionService_QueueChanged(object sender, EventArgs e) + private async void MPDConnectionService_QueueChanged(object sender, EventArgs e) { - // scrolling is handled in code-behind - Task.Run(async () => await LoadDataAsync()); + // Ask for a new status ourselves as the shared ConnectionService one might not be up to date yet + var status = await MPDConnectionService.SafelySendCommandAsync(new StatusCommand()); + var newVersion = status.Playlist; + + // Update the queue only if playlist versions differ + if (newVersion != _playlistVersion) + { + _ = Task.Run(async () => { + + var response = await MPDConnectionService.SafelySendCommandAsync(new PlChangesCommand(_playlistVersion)); + + if (response != null) + { + Source.CollectionChanged -= Source_CollectionChanged; + await DispatcherHelper.ExecuteOnUIThreadAsync(() => { + + // If the queue was cleared, PlaylistLength is 0. + if (status.PlaylistLength == 0) + { + Source.Clear(); + } + else + { + // PlChanges gives the full list of files starting from the change, so we delete all existing tracks from the source after that change, and swap the new ones in. + // If the response is empty, that means the last file in the source was removed. + var initialPosition = response.Count() == 0 ? Source.Count - 1 : response.First().Position; + + + while (Source.Count != initialPosition) + { + Source.RemoveAt(initialPosition); + } + + foreach (var item in response) + { + Source.Add(new TrackViewModel(item, false)); + } + } + + }); + Source.CollectionChanged += Source_CollectionChanged; + + // Update internal playlist version + _playlistVersion = newVersion; + } + }); + } } public ObservableCollection Source { get; } = new ObservableCollection(); public bool IsSourceEmpty => Source.Count == 0; - public async Task LoadDataAsync() + public async Task LoadInitialDataAsync() { - Source.CollectionChanged -= Source_CollectionChanged; - var tracks = new List(); var response = await MPDConnectionService.SafelySendCommandAsync(new PlaylistInfoCommand()); @@ -76,11 +216,14 @@ public async Task LoadDataAsync() } await DispatcherHelper.ExecuteOnUIThreadAsync(() => { - // TODO - Don't clear, update collection instead based on new data Source.Clear(); Source.AddRange(tracks); }); + // Set our internal playlist version + var status = await MPDConnectionService.SafelySendCommandAsync(new StatusCommand()); + _playlistVersion = status.Playlist; + Source.CollectionChanged += Source_CollectionChanged; } } diff --git a/Sources/FluentMPC/ViewModels/SearchResultsViewModel.cs b/Sources/FluentMPC/ViewModels/SearchResultsViewModel.cs new file mode 100644 index 00000000..76d6c01f --- /dev/null +++ b/Sources/FluentMPC/ViewModels/SearchResultsViewModel.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using FluentMPC.Helpers; +using FluentMPC.Services; +using FluentMPC.ViewModels.Items; +using Microsoft.Toolkit.Uwp.Helpers; +using MpcNET.Commands.Database; +using MpcNET.Commands.Playback; +using MpcNET.Commands.Playlist; +using MpcNET.Commands.Queue; +using MpcNET.Commands.Reflection; +using MpcNET.Tags; +using MpcNET.Types; + +namespace FluentMPC.ViewModels +{ + public class SearchResultsViewModel : Observable + { + private string _search; + + public string QueryText + { + get { return _search; } + set { Set(ref _search, value); } + } + + private bool _isSearching; + + public bool IsSearchInProgress + { + get { return _isSearching; } + set { Set(ref _isSearching, value); OnPropertyChanged(nameof(IsSourceEmpty)); } + } + + private bool _searchTracks; + + public bool SearchTracks + { + get { return _searchTracks; } + set { + Set(ref _searchTracks, value); + + if (value) + { + SearchAlbums = false; + SearchArtists = false; + } + + if (value || (!SearchArtists && !SearchAlbums && !SearchTracks)) + UpdateSource(); + } + } + + private bool _searchAlbums; + + public bool SearchAlbums + { + get { return _searchAlbums; } + set { + Set(ref _searchAlbums, value); + + if (value) + { + SearchTracks = false; + SearchArtists = false; + } + + if (value || (!SearchArtists && !SearchAlbums && !SearchTracks)) + UpdateSource(); + } + } + + private bool _searchArtists; + + public bool SearchArtists + { + get { return _searchArtists; } + set { + Set(ref _searchArtists, value); + + if (value) + { + SearchTracks = false; + SearchAlbums = false; + } + + if (value || (!SearchArtists && !SearchAlbums && !SearchTracks)) + UpdateSource(); + } + } + + public ObservableCollection Source { get; } = new ObservableCollection(); + + public bool IsSourceEmpty => !IsSearchInProgress && Source.Count == 0; + + #region Commands + + private bool IsSingleTrackSelected(object list) + { + // Cast the received __ComObject + var selectedTracks = (IList)list; + + return (selectedTracks?.Count == 1); + } + + private ICommand _addToQueueCommand; + public ICommand AddToQueueCommand => _addToQueueCommand ?? (_addToQueueCommand = new RelayCommand>(QueueTrack)); + + private async void QueueTrack(object list) + { + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var commandList = new CommandList(); + + foreach (var f in selectedTracks) + { + var trackVM = f as TrackViewModel; + commandList.Add(new AddIdCommand(trackVM.File.Path)); + } + + var r = await MPDConnectionService.SafelySendCommandAsync(commandList); + if (r != null) + NotificationService.ShowInAppNotification("AddedToQueueText".GetLocalized()); + } + } + + private ICommand _addToPlaylistCommand; + public ICommand AddToPlayListCommand => _addToPlaylistCommand ?? (_addToPlaylistCommand = new RelayCommand>(AddToPlaylist)); + + private async void AddToPlaylist(object list) + { + var playlistName = await DialogService.ShowAddToPlaylistDialog(); + if (playlistName == null) return; + + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var commandList = new CommandList(); + + foreach (var f in selectedTracks) + { + var trackVM = f as TrackViewModel; + commandList.Add(new PlaylistAddCommand(playlistName, trackVM.File.Path)); + } + + var req = await MPDConnectionService.SafelySendCommandAsync(commandList); + + if (req != null) + NotificationService.ShowInAppNotification(string.Format("AddedToPlaylistText".GetLocalized(), playlistName)); + } + } + + private ICommand _viewAlbumCommand; + public ICommand ViewAlbumCommand => _viewAlbumCommand ?? (_viewAlbumCommand = new RelayCommand>(ViewAlbum, IsSingleTrackSelected)); + + private void ViewAlbum(object list) + { + // Cast the received __ComObject + var selectedTracks = (IList)list; + + if (selectedTracks?.Count > 0) + { + var trackVM = selectedTracks.First() as TrackViewModel; + trackVM.ViewAlbumCommand.Execute(trackVM.File); + } + } + #endregion + + public SearchResultsViewModel() + { + Source.CollectionChanged += (s, e) => OnPropertyChanged(nameof(IsSourceEmpty)); + _searchTracks = true; + } + + public async Task InitializeAsync(string searchQuery) + { + QueryText = searchQuery; + UpdateSource(); + } + + private async void UpdateSource() + { + IsSearchInProgress = true; + Source.Clear(); + + _ = Task.Run(async () => + { + if (SearchTracks) await DoSearchAsync(FindTags.Title); + if (SearchAlbums) await DoSearchAsync(FindTags.Album); + if (SearchArtists) await DoSearchAsync(FindTags.Artist); + + await DispatcherHelper.ExecuteOnUIThreadAsync(() => IsSearchInProgress = false); + }); + } + + private async Task DoSearchAsync(ITag tag) + { + var response = await MPDConnectionService.SafelySendCommandAsync(new SearchCommand(tag, QueryText)); + + if (response != null) + { + await DispatcherHelper.ExecuteOnUIThreadAsync(() => + { + foreach (var f in response) + { + Source.Add(new TrackViewModel(f)); + } + }); + } + } + } +} diff --git a/Sources/FluentMPC/ViewModels/ShellViewModel.cs b/Sources/FluentMPC/ViewModels/ShellViewModel.cs index 239cad4b..9c1e89a6 100644 --- a/Sources/FluentMPC/ViewModels/ShellViewModel.cs +++ b/Sources/FluentMPC/ViewModels/ShellViewModel.cs @@ -9,6 +9,10 @@ using FluentMPC.Views; using Microsoft.Toolkit.Uwp.Helpers; using Microsoft.Toolkit.Uwp.UI.Controls; +using MpcNET.Commands.Database; +using MpcNET.Commands.Queue; +using MpcNET.Tags; +using MpcNET.Types; using Windows.System; using Windows.UI.Notifications; using Windows.UI.Xaml; @@ -72,6 +76,9 @@ public void Initialize(Frame frame, WinUI.NavigationView navigationView, WinUI.N NavigationService.Navigated += Frame_Navigated; _navigationView.BackRequested += OnBackRequested; + _navigationView.AutoSuggestBox.TextChanged += UpdateSearchSuggestions; + _navigationView.AutoSuggestBox.QuerySubmitted += HandleSearchRequest; + NotificationService.InAppNotificationRequested += Show_InAppNotification; DispatcherHelper.ExecuteOnUIThreadAsync(() => UpdatePlaylistNavigation()); @@ -152,6 +159,15 @@ private void Frame_Navigated(object sender, NavigationEventArgs e) return; } + // Create a transient navigationviewitem to show a custom header value. + if (e.SourcePageType == typeof(SearchResultsPage)) + { + var item = new WinUI.NavigationViewItem(); + item.Content = string.Format("SearchResultsFor".GetLocalized(), e.Parameter as string); + Selected = item; + return; + } + Selected = _navigationView.MenuItems .OfType() .FirstOrDefault(menuItem => IsMenuItemForPageType(menuItem, e.SourcePageType)); @@ -187,6 +203,51 @@ private void UpdatePlaylistNavigation() } } + private async void UpdateSearchSuggestions(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) + return; + + var suitableItems = new List(); + + if (sender.Text.Trim().Length > 0) + suitableItems.Add(string.Format("GoToDetailSearch".GetLocalized(), sender.Text)); + + if (sender.Text.Length > 2) + { + // Clear out suggestions before filling them up again, as it takes a bit of time. + sender.ItemsSource = suitableItems; + var response = await MPDConnectionService.SafelySendCommandAsync(new SearchCommand(FindTags.Title, sender.Text)); + + if (response != null) + { + foreach (var f in response) + { + suitableItems.Add(f); + } + } + } + + sender.ItemsSource = suitableItems; + } + + private async void HandleSearchRequest(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + if (args.ChosenSuggestion != null && args.ChosenSuggestion is IMpdFile) + { + var response = await MPDConnectionService.SafelySendCommandAsync(new AddCommand((args.ChosenSuggestion as IMpdFile).Path)); + + if (response != null) + NotificationService.ShowInAppNotification("AddedToQueueText".GetLocalized()); + } + else + { + // Navigate to detailed search page + NavigationService.Navigate(typeof(SearchResultsPage), args.QueryText); + } + } + + private static KeyboardAccelerator BuildKeyboardAccelerator(VirtualKey key, VirtualKeyModifiers? modifiers = null) { var keyboardAccelerator = new KeyboardAccelerator() { Key = key }; diff --git a/Sources/FluentMPC/Views/FoldersPage.xaml.cs b/Sources/FluentMPC/Views/FoldersPage.xaml.cs index 0136ad61..403d426c 100644 --- a/Sources/FluentMPC/Views/FoldersPage.xaml.cs +++ b/Sources/FluentMPC/Views/FoldersPage.xaml.cs @@ -43,7 +43,7 @@ private void TreeViewItem_DoubleTapped(object sender, Windows.UI.Xaml.Input.Doub { var treeViewItem = sender as Microsoft.UI.Xaml.Controls.TreeViewItem; var fileVm = treeViewItem.DataContext as FilePathViewModel; - fileVm.PlayCommand.Execute(null); + fileVm.AddToQueueCommand.Execute(null); } } } diff --git a/Sources/FluentMPC/Views/LibraryDetailPage.xaml b/Sources/FluentMPC/Views/LibraryDetailPage.xaml index e04f05ee..14c63801 100644 --- a/Sources/FluentMPC/Views/LibraryDetailPage.xaml +++ b/Sources/FluentMPC/Views/LibraryDetailPage.xaml @@ -91,27 +91,38 @@ - + + + + + + + + + + + + + @@ -128,7 +139,7 @@ Margin="-24,-78,-24,-24" Canvas.ZIndex="-1"> - @@ -150,7 +161,7 @@ RelativePanel.AlignLeftWithPanel="True" RelativePanel.AlignBottomWithPanel="True" RelativePanel.AlignRightWithPanel="True" - Margin="-24" + Margin="-24,-78,-24,-24" Canvas.ZIndex="-2"> diff --git a/Sources/FluentMPC/Views/LibraryDetailPage.xaml.cs b/Sources/FluentMPC/Views/LibraryDetailPage.xaml.cs index b2c1f2ee..44299457 100644 --- a/Sources/FluentMPC/Views/LibraryDetailPage.xaml.cs +++ b/Sources/FluentMPC/Views/LibraryDetailPage.xaml.cs @@ -1,5 +1,5 @@ using System; - +using FluentMPC.Helpers; using FluentMPC.Services; using FluentMPC.ViewModels; using FluentMPC.ViewModels.Items; @@ -38,36 +38,13 @@ protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) } } - // Propagate DataContext of the ListViewItem to the MenuFlyout. - // https://github.com/microsoft/microsoft-ui-xaml/issues/911 - private void MenuFlyout_Opening(object sender, object e) - { - var menuFlyout = (MenuFlyout)sender; - var dataContext = menuFlyout.Target?.DataContext ?? (menuFlyout.Target as ContentControl)?.Content; - if (dataContext != null) - { - foreach (var item in menuFlyout.Items) - { - var menuFlyoutItem = item as MenuFlyoutItem; - - if (menuFlyoutItem != null) - { - menuFlyoutItem.DataContext = dataContext; - } - } - } - else - { - menuFlyout.Hide(); - } - - } - private void Queue_Track(object sender, Windows.UI.Xaml.Input.DoubleTappedRoutedEventArgs e) { - var listView = sender as Helpers.AlternatingRowListView; + var listView = sender as AlternatingRowListView; var trackVm = listView.SelectedItem as TrackViewModel; trackVm.AddToQueueCommand.Execute(trackVm.File); } + + private void Select_Item(object sender, Windows.UI.Xaml.Input.RightTappedRoutedEventArgs e) => MiscHelpers.SelectItemOnFlyoutRightClick(QueueList, e); } } diff --git a/Sources/FluentMPC/Views/LibraryPage.xaml b/Sources/FluentMPC/Views/LibraryPage.xaml index 9ca2abbc..2b018349 100644 --- a/Sources/FluentMPC/Views/LibraryPage.xaml +++ b/Sources/FluentMPC/Views/LibraryPage.xaml @@ -35,23 +35,14 @@ - - - - - - - - - - - + Color="Black" > + + + + + + + + + - - + + - - + - - - + + + + - - + - + + + + + diff --git a/Sources/FluentMPC/Views/Playback/PlaybackView.xaml.cs b/Sources/FluentMPC/Views/Playback/PlaybackView.xaml.cs index 41ab6cf3..0aebaa03 100644 --- a/Sources/FluentMPC/Views/Playback/PlaybackView.xaml.cs +++ b/Sources/FluentMPC/Views/Playback/PlaybackView.xaml.cs @@ -19,18 +19,21 @@ public PlaybackView() protected override void OnNavigatedTo(NavigationEventArgs e) { - PlaybackViewModel = new PlaybackViewModel(CoreWindow.GetForCurrentThread().Dispatcher, VisualizationType.FullScreenPlayback); + PlaybackViewModel = (PlaybackViewModel)e.Parameter; + PlaybackViewModel.HostType = VisualizationType.FullScreenPlayback; } protected override void OnNavigatedFrom(NavigationEventArgs e) { - PlaybackViewModel?.Dispose(); + if (PlaybackViewModel != null) + PlaybackViewModel.HostType = VisualizationType.NowPlayingBar; PlaybackViewModel = null; } private void Page_Unloaded(object sender, Windows.UI.Xaml.RoutedEventArgs e) { - PlaybackViewModel?.Dispose(); + if (PlaybackViewModel != null) + PlaybackViewModel.HostType = VisualizationType.NowPlayingBar; PlaybackViewModel = null; } } diff --git a/Sources/FluentMPC/Views/PlaylistPage.xaml b/Sources/FluentMPC/Views/PlaylistPage.xaml index 0d7e9805..3deb58b1 100644 --- a/Sources/FluentMPC/Views/PlaylistPage.xaml +++ b/Sources/FluentMPC/Views/PlaylistPage.xaml @@ -93,35 +93,54 @@ - + + + + + + + + + + + + + + + + + + + + + + + + @@ -147,7 +166,7 @@ RelativePanel.AlignLeftWithPanel="True" RelativePanel.AlignBottomWithPanel="True" RelativePanel.AlignRightWithPanel="True" - Margin="-24" + Margin="-24,-78,-24,-24" Canvas.ZIndex="-2"> @@ -231,7 +250,7 @@ -