diff --git a/README.md b/README.md index 6e1cebeb..c04b8de4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Stylophone [**Music Player Daemon**](https://www.musicpd.org/) Client for UWP and iOS/iPadOS. Based on [MpcNET](https://github.com/Difegue/MpcNET), my own fork of the original .NET Client Library for MPD. (now on NuGet!) -English badge +[](https://www.microsoft.com/store/apps/9NCB693428T8?cid=storebadge&ocid=badge) [](https://apps.apple.com/us/app/stylophone/id1644672889?itsct=apps_box_link&itscg=30200) [Buy a sticker if you want!](https://ko-fi.com/s/9fcf421b6e) diff --git a/Sources/Stylophone.Common/Helpers/ColorThief.Skia.cs b/Sources/Stylophone.Common/Helpers/ColorThief.Skia.cs index c1ca7f72..72e3e67c 100644 --- a/Sources/Stylophone.Common/Helpers/ColorThief.Skia.cs +++ b/Sources/Stylophone.Common/Helpers/ColorThief.Skia.cs @@ -93,6 +93,12 @@ public QuantizedColor GetColor(SKBitmap sourceImage, int quality = DefaultQualit { var palette = GetPalette(sourceImage, 3, quality, ignoreWhite); + // Handle case where GetPalette returns an empty list (because GetColorMap failed?) + if (palette.Count == 0) + { + return new QuantizedColor(SKColors.Black, 1); + } + var avgR = Convert.ToByte(palette.Average(a => a.Color.Red)); var avgG = Convert.ToByte(palette.Average(a => a.Color.Green)); var avgB = Convert.ToByte(palette.Average(a => a.Color.Blue)); diff --git a/Sources/Stylophone.Common/Helpers/Miscellaneous.cs b/Sources/Stylophone.Common/Helpers/Miscellaneous.cs index d1f021fb..c4b0530c 100644 --- a/Sources/Stylophone.Common/Helpers/Miscellaneous.cs +++ b/Sources/Stylophone.Common/Helpers/Miscellaneous.cs @@ -2,6 +2,7 @@ using SkiaSharp; using System; using System.IO; +using System.Net; namespace Stylophone.Common.Helpers { @@ -83,5 +84,25 @@ public static string EscapeFilename(string fileName) } return fileName.Replace(".", "002E"); } + + public static IPEndPoint GetIPEndPointFromHostName(string hostName, int port, bool throwIfMoreThanOneIP) + { + var addresses = System.Net.Dns.GetHostAddresses(hostName); + if (addresses.Length == 0) + { + throw new ArgumentException( + "Unable to retrieve address from specified host name.", + "hostName" + ); + } + else if (throwIfMoreThanOneIP && addresses.Length > 1) + { + throw new ArgumentException( + "There is more that one IP address to the specified host.", + "hostName" + ); + } + return new IPEndPoint(addresses[0], port); // Port gets validated here. + } } } diff --git a/Sources/Stylophone.Common/Helpers/RangedObservableCollection.cs b/Sources/Stylophone.Common/Helpers/RangedObservableCollection.cs new file mode 100644 index 00000000..97bb61e8 --- /dev/null +++ b/Sources/Stylophone.Common/Helpers/RangedObservableCollection.cs @@ -0,0 +1,170 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; + +namespace System.Collections.ObjectModel +{ + /// + /// Implementation of a dynamic data collection based on generic Collection<T>, + /// implementing INotifyCollectionChanged to notify listeners + /// when items get added, removed or the whole list is refreshed. + /// + public class RangedObservableCollection : ObservableCollection + { + private const string CountName = "Count"; + private const string IndexerName = "Item[]"; + + /// + /// Initializes a new instance of RangedObservableCollection that is empty and has default initial capacity. + /// + public RangedObservableCollection() + : base() + { + } + + /// + /// Initializes a new instance of the RangedObservableCollection class that contains + /// elements copied from the specified collection and has sufficient capacity + /// to accommodate the number of elements copied. + /// + /// The collection whose elements are copied to the new list. + /// + /// The elements are copied onto the RangedObservableCollection in the + /// same order they are read by the enumerator of the collection. + /// + /// collection is a null reference + public RangedObservableCollection(IEnumerable collection) + : base(collection) + { + } + + /// + /// Adds the elements of the specified collection to the end of the RangedObservableCollection. + /// + /// The collection whose elements are added to the list. + /// + /// The elements are copied onto the RangedObservableCollection in the + /// same order they are read by the enumerator of the collection. + /// + /// collection is a null reference + public void AddRange(IEnumerable collection) + { + if (collection == null) + throw new ArgumentNullException(nameof(collection)); + + CheckReentrancy(); + + var startIndex = Count; + var changedItems = new List(collection); + + foreach (var i in changedItems) + Items.Add(i); + + OnCountPropertyChanged(); + OnIndexerPropertyChanged(); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, changedItems, startIndex)); + } + + /// + /// Clears the current RangedObservableCollection and replaces the elements with the elements of specified collection. + /// + /// The collection whose elements are added to the list. + /// + /// The elements are copied onto the RangedObservableCollection in the + /// same order they are read by the enumerator of the collection. + /// + /// collection is a null reference + public void ReplaceRange(IEnumerable collection) + { + if (collection == null) + throw new ArgumentNullException(nameof(collection)); + + CheckReentrancy(); + + Items.Clear(); + foreach (var i in collection) + Items.Add(i); + + OnCountPropertyChanged(); + OnIndexerPropertyChanged(); + OnCollectionReset(); + } + + /// + /// Removes the first occurence of each item in the specified collection. + /// + /// The collection whose elements are removed from list. + /// + /// The elements are copied onto the RangedObservableCollection in the + /// same order they are read by the enumerator of the collection. + /// + /// collection is a null reference + public void RemoveRange(IEnumerable collection) + { + if (collection == null) + throw new ArgumentNullException(nameof(collection)); + + CheckReentrancy(); + + // HACK ATTACK: normally, since this method can remove items from multiple different spaces in the collection, this'd be incorrect... + // but since we only use it for ranged deletions of queue items, which are always sequential, it should be fine(tm) + var index = Items.IndexOf(collection.FirstOrDefault()); + + var changedItems = new List(collection); + for (int i = 0; i < changedItems.Count; i++) + { + if (!Items.Remove(changedItems[i])) + { + changedItems.RemoveAt(i); + i--; + } + } + + if (changedItems.Count > 0) + { + OnCountPropertyChanged(); + OnIndexerPropertyChanged(); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, changedItems, index)); + } + } + + /// + /// Clears the current RangedObservableCollection and replaces the elements with the specified element. + /// + /// The element which is added to the list. + public void Replace(T item) + { + CheckReentrancy(); + + Items.Clear(); + Items.Add(item); + + OnCountPropertyChanged(); + OnIndexerPropertyChanged(); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + private void OnCountPropertyChanged() + { + OnPropertyChanged(EventArgsCache.CountPropertyChanged); + } + + private void OnIndexerPropertyChanged() + { + OnPropertyChanged(EventArgsCache.IndexerPropertyChanged); + } + + private void OnCollectionReset() + { + OnCollectionChanged(EventArgsCache.ResetCollectionChanged); + } + + private static class EventArgsCache + { + internal static readonly PropertyChangedEventArgs CountPropertyChanged = new PropertyChangedEventArgs(CountName); + internal static readonly PropertyChangedEventArgs IndexerPropertyChanged = new PropertyChangedEventArgs(IndexerName); + internal static readonly NotifyCollectionChangedEventArgs ResetCollectionChanged = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); + } + } +} \ No newline at end of file diff --git a/Sources/Stylophone.Common/Services/AlbumArtService.cs b/Sources/Stylophone.Common/Services/AlbumArtService.cs index 25e9df96..7e88da06 100644 --- a/Sources/Stylophone.Common/Services/AlbumArtService.cs +++ b/Sources/Stylophone.Common/Services/AlbumArtService.cs @@ -40,7 +40,6 @@ public void Initialize() { _queueCanceller?.Cancel(); _queueCanceller = new CancellationTokenSource(); - var token = _queueCanceller.Token; _albumArtQueue = new Stack(); @@ -81,6 +80,11 @@ public void Initialize() }).ConfigureAwait(false); } + public void Stop() + { + _queueCanceller?.Cancel(); + } + /// /// Check if this file's album art is already stored in the internal Album Art cache. /// diff --git a/Sources/Stylophone.Common/Services/MPDConnectionService.cs b/Sources/Stylophone.Common/Services/MPDConnectionService.cs index 4d0acc26..1f6f6db7 100644 --- a/Sources/Stylophone.Common/Services/MPDConnectionService.cs +++ b/Sources/Stylophone.Common/Services/MPDConnectionService.cs @@ -11,11 +11,10 @@ using Stylophone.Common.Interfaces; using MpcNET.Commands.Reflection; using Stylophone.Localization.Strings; +using Stylophone.Common.Helpers; using CommunityToolkit.Mvvm.DependencyInjection; -using Stylophone.Common.ViewModels; using Microsoft.AppCenter.Analytics; -using Microsoft.AppCenter; -using System.Drawing; +using Stylophone.Common.ViewModels; namespace Stylophone.Common.Services { @@ -77,13 +76,7 @@ public async Task InitializeAsync(bool withRetry = false) IsConnecting = true; CurrentStatus = BOGUS_STATUS; // Reset status - if (IsConnected) - { - IsConnected = false; - ConnectionChanged?.Invoke(this, new EventArgs()); - } - - ClearResources(); + Disconnect(); var cancelToken = _cancelConnect.Token; @@ -106,23 +99,31 @@ public async Task InitializeAsync(bool withRetry = false) _connectionRetryAttempter.Start(); } } - + IsConnecting = false; } - private void ClearResources() + public void Disconnect() { - _idleConnection?.SendAsync(new NoIdleCommand()); - _idleConnection?.DisconnectAsync(); - _statusConnection?.DisconnectAsync(); + + if (IsConnected) + { + System.Diagnostics.Debug.WriteLine($"Terminating MPD connections"); + IsConnected = false; + ConnectionChanged?.Invoke(this, new EventArgs()); + } + + // Stop the idle connection first + _cancelIdle?.Cancel(); _connectionRetryAttempter?.Stop(); _connectionRetryAttempter?.Dispose(); + // Stop the status timer before killing the matching connection _statusUpdater?.Stop(); _statusUpdater?.Dispose(); + _statusConnection?.DisconnectAsync(); - _cancelIdle?.Cancel(); _cancelIdle = new CancellationTokenSource(); _cancelConnect?.Cancel(); @@ -137,10 +138,16 @@ private void ClearResources() private async Task TryConnecting(CancellationToken token) { if (token.IsCancellationRequested) return; - if (!IPAddress.TryParse(_host, out var ipAddress)) - throw new Exception("Invalid IP address"); - _mpdEndpoint = new IPEndPoint(ipAddress, _port); + if (!IPAddress.TryParse(_host, out var ipAddress)) + { + // Maybe it's a hostname? Try getting an IP from it nonetheless + _mpdEndpoint = Miscellaneous.GetIPEndPointFromHostName(_host, _port, false); + } + else + { + _mpdEndpoint = new IPEndPoint(ipAddress, _port); + } _statusConnection = await GetConnectionInternalAsync(token); @@ -223,8 +230,12 @@ public async Task SafelySendCommandAsync(IMpcCommand command, bool show { var dict = new Dictionary(); dict.Add("command", command.Serialize()); - dict.Add("exception", e.ToString()); - Analytics.TrackEvent("MPDError", dict); + dict.Add("exception", e.InnerException?.ToString()); + dict.Add("source", e.Source); + dict.Add("message", e.Message); + dict.Add("stacktrace", e.StackTrace); + + Analytics.TrackEvent("MPDError", dict); } #endif } @@ -270,15 +281,25 @@ private void InitializeStatusUpdater(CancellationToken token = default) try { + // Run the idleConnection in a wrapper task since MpcNET isn't fully async and will block here + var idleChangesTask = Task.Run(async () => await _idleConnection.SendAsync(new IdleCommand("stored_playlist playlist player mixer output options update"))); + + // Wait for the idle command to finish or for the token to be cancelled + await Task.WhenAny(idleChangesTask, Task.Delay(-1, token)); + if (token.IsCancellationRequested || _idleConnection == null || !_idleConnection.IsConnected) + { + //_idleConnection?.SendAsync(new NoIdleCommand()); + _idleConnection?.DisconnectAsync(); break; + } + + var message = idleChangesTask.Result; - var idleChanges = await _idleConnection.SendAsync(new IdleCommand("stored_playlist playlist player mixer output options update")); - - if (idleChanges.IsResponseValid) - await HandleIdleResponseAsync(idleChanges.Response.Content); + if (message.IsResponseValid) + await HandleIdleResponseAsync(message.Response.Content); else - throw new Exception(idleChanges.Response?.Content); + throw new Exception(message.Response?.Content); } catch (Exception e) { diff --git a/Sources/Stylophone.Common/Stylophone.Common.csproj b/Sources/Stylophone.Common/Stylophone.Common.csproj index 84d56cee..082c9c60 100644 --- a/Sources/Stylophone.Common/Stylophone.Common.csproj +++ b/Sources/Stylophone.Common/Stylophone.Common.csproj @@ -7,14 +7,13 @@ - - - - - - - - + + + + + + + diff --git a/Sources/Stylophone.Common/ViewModels/AlbumDetailViewModel.cs b/Sources/Stylophone.Common/ViewModels/AlbumDetailViewModel.cs index f67add3a..d852b8a2 100644 --- a/Sources/Stylophone.Common/ViewModels/AlbumDetailViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/AlbumDetailViewModel.cs @@ -46,7 +46,7 @@ public AlbumDetailViewModel(IDialogService dialogService, INotificationService n public bool IsSourceEmpty => Source.Count == 0; [RelayCommand] - private async void AddToQueue(object list) + private async Task AddToQueue(object list) { var selectedTracks = (IList)list; @@ -67,7 +67,7 @@ private async void AddToQueue(object list) } [RelayCommand] - private async void AddToPlaylist(object list) + private async Task AddToPlaylist(object list) { var playlistName = await _dialogService.ShowAddToPlaylistDialog(); if (playlistName == null) return; diff --git a/Sources/Stylophone.Common/ViewModels/Bases/LibraryViewModelBase.cs b/Sources/Stylophone.Common/ViewModels/Bases/LibraryViewModelBase.cs index 0cc0c545..078c979e 100644 --- a/Sources/Stylophone.Common/ViewModels/Bases/LibraryViewModelBase.cs +++ b/Sources/Stylophone.Common/ViewModels/Bases/LibraryViewModelBase.cs @@ -40,7 +40,7 @@ public async Task LoadDataAsync() FilteredSource.CollectionChanged += (s, e) => OnPropertyChanged(nameof(IsSourceEmpty)); Source.Clear(); - var response = await _mpdService.SafelySendCommandAsync(new ListCommand(MpdTags.Album)); + var response = await _mpdService.SafelySendCommandAsync(new ListCommand(MpdTags.AlbumSort)); if (response != null) GroupAlbumsByName(response); diff --git a/Sources/Stylophone.Common/ViewModels/Bases/PlaybackViewModelBase.cs b/Sources/Stylophone.Common/ViewModels/Bases/PlaybackViewModelBase.cs index d717b565..17b1a2f3 100644 --- a/Sources/Stylophone.Common/ViewModels/Bases/PlaybackViewModelBase.cs +++ b/Sources/Stylophone.Common/ViewModels/Bases/PlaybackViewModelBase.cs @@ -61,10 +61,9 @@ public PlaybackViewModelBase(INavigationService navigationService, INotification _internalVolume = _mpdService.CurrentStatus.Volume; - // Bind timer methods and start it + // Bind timer methods _updateInformationTimer = new System.Timers.Timer(500); _updateInformationTimer.Elapsed += UpdateInformation; - _updateInformationTimer.Start(); // Update info to current track _mpdService.ConnectionChanged += OnConnectionChanged; @@ -82,6 +81,7 @@ private void OnConnectionChanged(object sender, EventArgs e) else { IsTrackInfoAvailable = false; + _updateInformationTimer?.Stop(); } } @@ -90,6 +90,7 @@ private void Initialize() OnTrackChange(this, new SongChangedEventArgs { NewSongId = -1 }); CurrentTimeValue = _mpdService.CurrentStatus.Elapsed.TotalSeconds; + _updateInformationTimer.Start(); OnStateChange(this, null); } @@ -638,7 +639,6 @@ public virtual void Dispose() _mpdService.SongChanged -= OnTrackChange; _updateInformationTimer.Stop(); - _updateInformationTimer.Dispose(); } #region Commands diff --git a/Sources/Stylophone.Common/ViewModels/Items/AlbumViewModel.cs b/Sources/Stylophone.Common/ViewModels/Items/AlbumViewModel.cs index b3115af1..0a85e31f 100644 --- a/Sources/Stylophone.Common/ViewModels/Items/AlbumViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/Items/AlbumViewModel.cs @@ -113,7 +113,7 @@ internal void SetAlbumArt(AlbumArt art) } [RelayCommand] - private async void AddToPlaylist() + private async Task AddToPlaylist() { var playlistName = await _dialogService.ShowAddToPlaylistDialog(); if (playlistName == null || Files.Count == 0) return; @@ -132,7 +132,7 @@ private async void AddToPlaylist() } [RelayCommand] - private async void AddAlbum() + private async Task AddAlbum() { var commandList = new CommandList(); @@ -152,7 +152,7 @@ private async void AddAlbum() } [RelayCommand] - private async void PlayAlbum() + private async Task PlayAlbum() { if (Files.Count == 0) { @@ -208,7 +208,8 @@ public async Task LoadAlbumDataAsync(MpcConnection c) if (Files.Count == 0) Files.AddRange(findReq.Response.Content); - Artist = Files.Select(f => f.Artist).Distinct().Where(f => f != "").Aggregate((f1, f2) => $"{f1}, {f2}"); + Artist = Files.Any(f => f.HasAlbumArtist) ? Files.First(f => f.HasAlbumArtist).AlbumArtist : + Files.Select(f => f.Artist).Distinct().Where(f => f != "").Aggregate((f1, f2) => $"{f1}, {f2}"); // If we've already generated album art, don't use the queue and directly grab it if (await _albumArtService.IsAlbumArtCachedAsync(Files[0])) diff --git a/Sources/Stylophone.Common/ViewModels/Items/FilePathViewModel.cs b/Sources/Stylophone.Common/ViewModels/Items/FilePathViewModel.cs index b323e154..b6a3cb3e 100644 --- a/Sources/Stylophone.Common/ViewModels/Items/FilePathViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/Items/FilePathViewModel.cs @@ -66,10 +66,11 @@ internal FilePathViewModel(FilePathViewModelFactory factory, IMpdFilePath file, if (file is MpdDirectory) { IsDirectory = true; - _children = new RangedObservableCollection(); - - // Add a bogus child that'll be replaced when the list is loaded - _children.Add(new FilePathViewModel(Resources.FoldersLoadingTreeItem, this, _dispatcherService)); + _children = new RangedObservableCollection + { + // Add a bogus child that'll be replaced when the list is loaded + new FilePathViewModel(Resources.FoldersLoadingTreeItem, this, _dispatcherService) + }; } } @@ -99,7 +100,7 @@ public FilePathViewModel(string name, FilePathViewModel parent, IDispatcherServi private bool _isLoadingChildren; public async Task LoadChildrenAsync() { - if (IsLoaded || _isLoadingChildren || IsDirectory == false || _children == null || Path == null) return; + if (IsLoaded || _isLoadingChildren || IsDirectory == false || Children == null || Path == null) return; _isLoadingChildren = true; try @@ -118,8 +119,8 @@ public async Task LoadChildrenAsync() await _dispatcherService.ExecuteOnUIThreadAsync(() => { - _children.AddRange(newChildren); - _children.RemoveAt(0); // Remove the placeholder after adding the new items, otherwise the treeitem can close back up + Children.AddRange(newChildren); + Children.RemoveAt(0); // Remove the placeholder after adding the new items, otherwise the treeitem can close back up IsLoaded = true; }); } @@ -131,7 +132,7 @@ await _dispatcherService.ExecuteOnUIThreadAsync(() => [RelayCommand] - private async void Play() + private async Task Play() { // Clear queue, add path and play var commandList = new CommandList(new IMpcCommand[] { new ClearCommand(), new AddCommand(Path), new PlayCommand(0) }); @@ -143,7 +144,7 @@ private async void Play() } [RelayCommand] - private async void AddToQueue() + private async Task AddToQueue() { // AddCommand adds either the full directory or the song, depending on the path given. var response = await _mpdService.SafelySendCommandAsync(new AddCommand(Path)); @@ -153,7 +154,7 @@ private async void AddToQueue() } [RelayCommand] - private async void AddToPlaylist() + private async Task AddToPlaylist() { var playlistName = await _dialogService.ShowAddToPlaylistDialog(); if (playlistName == null) return; diff --git a/Sources/Stylophone.Common/ViewModels/Items/TrackViewModel.cs b/Sources/Stylophone.Common/ViewModels/Items/TrackViewModel.cs index 76371ee8..907e7465 100644 --- a/Sources/Stylophone.Common/ViewModels/Items/TrackViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/Items/TrackViewModel.cs @@ -90,13 +90,13 @@ internal TrackViewModel(TrackViewModelFactory factory, IMpdFile file): base(fact private bool _isLight; [RelayCommand] - private async void PlayTrack(IMpdFile file) => await _mpdService.SafelySendCommandAsync(new PlayIdCommand(file.Id)); + private async Task PlayTrack(IMpdFile file) => await _mpdService.SafelySendCommandAsync(new PlayIdCommand(file.Id)); [RelayCommand] - private async void RemoveFromQueue(IMpdFile file) => await _mpdService.SafelySendCommandAsync(new DeleteIdCommand(file.Id)); + private async Task RemoveFromQueue(IMpdFile file) => await _mpdService.SafelySendCommandAsync(new DeleteIdCommand(file.Id)); [RelayCommand] - private async void AddToQueue(IMpdFile file) + private async Task AddToQueue(IMpdFile file) { var response = await _mpdService.SafelySendCommandAsync(new AddIdCommand(file.Path)); @@ -105,7 +105,7 @@ private async void AddToQueue(IMpdFile file) } [RelayCommand] - private async void AddToPlaylist(IMpdFile file) + private async Task AddToPlaylist(IMpdFile file) { var playlistName = await _dialogService.ShowAddToPlaylistDialog(); if (playlistName == null) return; @@ -168,5 +168,9 @@ public void Dispose() AlbumArt?.Dispose(); } + public override string ToString() + { + return $"{Name} - {File.Artist} - {File.Album} - {Miscellaneous.FormatTimeString(File.Time*1000)}"; + } } } diff --git a/Sources/Stylophone.Common/ViewModels/PlaylistViewModel.cs b/Sources/Stylophone.Common/ViewModels/PlaylistViewModel.cs index 917e1a18..9f4be259 100644 --- a/Sources/Stylophone.Common/ViewModels/PlaylistViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/PlaylistViewModel.cs @@ -95,7 +95,7 @@ private bool IsSingleTrackSelected(object list) } [RelayCommand] - private async void RemovePlaylist() + private async Task RemovePlaylist() { var result = await _dialogService.ShowConfirmDialogAsync(Resources.DeletePlaylistContentDialog, "", Resources.OKButtonText, Resources.CancelButtonText); @@ -112,7 +112,7 @@ private async void RemovePlaylist() } [RelayCommand] - private async void LoadPlaylist() + private async Task LoadPlaylist() { var res = await _mpdService.SafelySendCommandAsync(new LoadCommand(Name)); @@ -121,7 +121,7 @@ private async void LoadPlaylist() } [RelayCommand] - private async void PlayPlaylist() + private async Task PlayPlaylist() { // Clear queue, add playlist and play var commandList = new CommandList(new IMpcCommand[] { new ClearCommand() , new LoadCommand(Name), new PlayCommand(0) }); @@ -135,7 +135,7 @@ private async void PlayPlaylist() [RelayCommand] - private async void AddToQueue(object list) + private async Task AddToQueue(object list) { // Cast the received __ComObject var selectedTracks = (IList)list; @@ -171,27 +171,20 @@ private void ViewAlbum(object list) [RelayCommand] - private async void RemoveTrackFromPlaylist(object list) + private async Task RemoveTrackFromPlaylist(object list) { // Cast the received __ComObject var selectedTracks = (IList)list; - - if (selectedTracks?.Count > 0) + var trackCount = selectedTracks?.Count; + if (trackCount > 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(); + var command = new PlaylistDeleteCommand(Name, Source.IndexOf(selectedTracks.First() as TrackViewModel)); + + // Use new ranged variant if necessary + if (trackCount > 1) + command = new PlaylistDeleteCommand(Name, Source.IndexOf(selectedTracks.First() as TrackViewModel), Source.IndexOf(selectedTracks.Last() as TrackViewModel)); - foreach (var f in selectedTracks) - { - var trackVM = f as TrackViewModel; - commandList.Add(new PlaylistDeleteCommand(Name, copy.IndexOf(trackVM))); - copy.Remove(trackVM); - } - - var r = await _mpdService.SafelySendCommandAsync(commandList); + var r = await _mpdService.SafelySendCommandAsync(command); if (r != null) // Reload playlist await LoadDataAsync(Name); } @@ -237,7 +230,7 @@ public async Task LoadDataAsync(string playlistName) await Task.Run(async () => { // Get album art for three albums to display in the playlist view - Random r = new Random(); + Random r = new(); var distinctAlbums = Source.GroupBy(tr => tr.File.Album).Select(tr => tr.First()).OrderBy((item) => r.Next()).ToList(); if (distinctAlbums.Count > 1) @@ -247,8 +240,13 @@ await Task.Run(async () => DominantColor = (art?.DominantColor?.Color).GetValueOrDefault(); - if (DominantColor == default(SKColor)) - DominantColor = _interop.GetAccentColor(); + if (DominantColor == default) + { + await _dispatcherService.ExecuteOnUIThreadAsync(() => + { + DominantColor = _interop.GetAccentColor(); + }); + } IsLight = (!art?.DominantColor?.IsDark).GetValueOrDefault(); } @@ -274,6 +272,13 @@ await Task.Run(async () => private async void Source_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { + // iOS uses move + if (e.Action == NotifyCollectionChangedAction.Move) + { + await _mpdService.SafelySendCommandAsync(new PlaylistMoveCommand(Name, e.OldStartingIndex, e.NewStartingIndex)); + return; + } + if (e.Action == NotifyCollectionChangedAction.Add && _previousAction == NotifyCollectionChangedAction.Remove) { // User reordered tracks, send matching command diff --git a/Sources/Stylophone.Common/ViewModels/QueueViewModel.cs b/Sources/Stylophone.Common/ViewModels/QueueViewModel.cs index 53ca64ae..6ec853ee 100644 --- a/Sources/Stylophone.Common/ViewModels/QueueViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/QueueViewModel.cs @@ -162,12 +162,21 @@ private async Task ClearQueue() /// private async void Source_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { + // iOS uses move + if (e.Action == NotifyCollectionChangedAction.Move) + { + _oldId = e.OldItems.Count > 0 ? (e.OldItems[0] as TrackViewModel).File.Id : -1; + await _mpdService.SafelySendCommandAsync(new MoveIdCommand(_oldId, e.NewStartingIndex)); + return; + } + if (e.Action == NotifyCollectionChangedAction.Add && _previousAction == NotifyCollectionChangedAction.Remove) { // One Remove->Add Pair means one MoveIdCommand - await _mpdService.SafelySendCommandAsync(new MoveIdCommand(_oldId, e.NewStartingIndex)); _previousAction = NotifyCollectionChangedAction.Move; + await _mpdService.SafelySendCommandAsync(new MoveIdCommand(_oldId, e.NewStartingIndex)); } + if (e.Action == NotifyCollectionChangedAction.Remove) { _previousAction = e.Action; @@ -199,37 +208,39 @@ private async void MPDConnectionService_QueueChanged(object sender, EventArgs e) { Source.CollectionChanged -= Source_CollectionChanged; - // If the queue was cleared, PlaylistLength is 0. - if (status.PlaylistLength == 0) + // PlChanges gives the full list of files affected by the change. + foreach (var f in response) { - await _dispatcherService.ExecuteOnUIThreadAsync(() => 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; + var trackVm = Source.Where(tvm => tvm.File.Id == f.Id).FirstOrDefault(); - while (Source.Count != initialPosition) - { - await _dispatcherService.ExecuteOnUIThreadAsync(() => { - // Make sure - if (Source.Count != initialPosition) - Source.RemoveAt(initialPosition); - }); - } + // We might already be up to date + if (Source.IndexOf(trackVm) == f.Position) + continue; - var toAdd = new List(); - foreach (var item in response) + // Nw track + if (trackVm == default) { - var trackVm = _trackVmFactory.GetTrackViewModel(item); - toAdd.Add(trackVm); + trackVm = _trackVmFactory.GetTrackViewModel(f); } + else await _dispatcherService.ExecuteOnUIThreadAsync(() => + { + Source.Remove(trackVm); + }); - if (toAdd.Count > 0) - await _dispatcherService.ExecuteOnUIThreadAsync(() => Source.AddRange(toAdd)); + await _dispatcherService.ExecuteOnUIThreadAsync(() => + { + Source.Insert(f.Position, trackVm); + }); } + // To detect songs that were deleted at the end of the playlist, use playlistlength returned by status command. + // (If the queue was cleared, PlaylistLength is 0.) + var tracksToRemove = Source.Skip(status.PlaylistLength).ToList(); + + await _dispatcherService.ExecuteOnUIThreadAsync(() => { + Source.RemoveRange(tracksToRemove); + }); + Source.CollectionChanged += Source_CollectionChanged; // Update internal playlist version diff --git a/Sources/Stylophone.Common/ViewModels/SearchResultsViewModel.cs b/Sources/Stylophone.Common/ViewModels/SearchResultsViewModel.cs index 6b382472..e5768e84 100644 --- a/Sources/Stylophone.Common/ViewModels/SearchResultsViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/SearchResultsViewModel.cs @@ -105,7 +105,7 @@ private bool IsSingleTrackSelected(object list) } [RelayCommand] - private async void AddToQueue(object list) + private async Task AddToQueue(object list) { var selectedTracks = (IList)list; @@ -126,7 +126,7 @@ private async void AddToQueue(object list) } [RelayCommand] - private async void AddToPlaylist(object list) + private async Task AddToPlaylist(object list) { var playlistName = await _dialogService.ShowAddToPlaylistDialog(); if (playlistName == null) return; diff --git a/Sources/Stylophone.Common/ViewModels/SettingsViewModel.cs b/Sources/Stylophone.Common/ViewModels/SettingsViewModel.cs index 34d07aa7..85f28524 100644 --- a/Sources/Stylophone.Common/ViewModels/SettingsViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/SettingsViewModel.cs @@ -78,11 +78,7 @@ public SettingsViewModel(MPDConnectionService mpdService, IApplicationStorageSer partial void OnElementThemeChanged(Theme value) { Task.Run (async () => await _interop.SetThemeAsync(value)); - - if (value != _elementTheme) - { - _applicationStorageService.SetValue(nameof(ElementTheme), value.ToString()); - } + _applicationStorageService.SetValue(nameof(ElementTheme), value.ToString()); } partial void OnServerHostChanged(string value) @@ -242,7 +238,7 @@ private async Task UpdateServerVersionAsync() var songs = response.ContainsKey("songs") ? response["songs"] : "??"; var albums = response.ContainsKey("albums") ? response["albums"] : "??"; - if (outputs != null) + if (outputs != null && outputs.Count() > 0) { var outputString = outputs.Select(o => o.Plugin).Aggregate((s, s2) => $"{s}, {s2}"); diff --git a/Sources/Stylophone.Localization/Strings/Resources.Designer.cs b/Sources/Stylophone.Localization/Strings/Resources.Designer.cs index 589217cc..dd3195d1 100644 --- a/Sources/Stylophone.Localization/Strings/Resources.Designer.cs +++ b/Sources/Stylophone.Localization/Strings/Resources.Designer.cs @@ -1,10 +1,10 @@ //------------------------------------------------------------------------------ // -// Ce code a été généré par un outil. -// Version du runtime :4.0.30319.42000 +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 // -// Les modifications apportées à ce fichier peuvent provoquer un comportement incorrect et seront perdues si -// le code est régénéré. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. // //------------------------------------------------------------------------------ @@ -13,12 +13,12 @@ namespace Stylophone.Localization.Strings { /// - /// Une classe de ressource fortement typée destinée, entre autres, à la consultation des chaînes localisées. + /// A strongly-typed resource class, for looking up localized strings, etc. /// - // Cette classe a été générée automatiquement par la classe StronglyTypedResourceBuilder - // à l'aide d'un outil, tel que ResGen ou Visual Studio. - // Pour ajouter ou supprimer un membre, modifiez votre fichier .ResX, puis réexécutez ResGen - // avec l'option /str ou régénérez votre projet VS. + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] @@ -33,7 +33,7 @@ internal Resources() { } /// - /// Retourne l'instance ResourceManager mise en cache utilisée par cette classe. + /// Returns the cached ResourceManager instance used by this class. /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] public static global::System.Resources.ResourceManager ResourceManager { @@ -47,8 +47,8 @@ internal Resources() { } /// - /// Remplace la propriété CurrentUICulture du thread actuel pour toutes - /// les recherches de ressources à l'aide de cette classe de ressource fortement typée. + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] public static global::System.Globalization.CultureInfo Culture { @@ -61,7 +61,7 @@ internal Resources() { } /// - /// Recherche une chaîne localisée semblable à Change Volume. + /// Looks up a localized string similar to Change Volume. /// public static string ActionChangeVolume { get { @@ -70,7 +70,7 @@ public static string ActionChangeVolume { } /// - /// Recherche une chaîne localisée semblable à Show Mini Player. + /// Looks up a localized string similar to Show Mini Player. /// public static string ActionCompactOverlay { get { @@ -79,7 +79,16 @@ public static string ActionCompactOverlay { } /// - /// Recherche une chaîne localisée semblable à More Actions. + /// Looks up a localized string similar to Open Fullscreen Playback. + /// + public static string ActionFullscreenPlayback { + get { + return ResourceManager.GetString("ActionFullscreenPlayback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to More Actions. /// public static string ActionMore { get { @@ -88,7 +97,7 @@ public static string ActionMore { } /// - /// Recherche une chaîne localisée semblable à Play/Pause. + /// Looks up a localized string similar to Play/Pause. /// public static string ActionPlayPause { get { @@ -97,7 +106,7 @@ public static string ActionPlayPause { } /// - /// Recherche une chaîne localisée semblable à Next Track. + /// Looks up a localized string similar to Next Track. /// public static string ActionSkipNext { get { @@ -106,7 +115,7 @@ public static string ActionSkipNext { } /// - /// Recherche une chaîne localisée semblable à Previous Track. + /// Looks up a localized string similar to Previous Track. /// public static string ActionSkipPrevious { get { @@ -115,7 +124,7 @@ public static string ActionSkipPrevious { } /// - /// Recherche une chaîne localisée semblable à Toggle Consume. + /// Looks up a localized string similar to Toggle Consume. /// public static string ActionToggleConsume { get { @@ -124,7 +133,7 @@ public static string ActionToggleConsume { } /// - /// Recherche une chaîne localisée semblable à Toggle Repeat. + /// Looks up a localized string similar to Toggle Repeat. /// public static string ActionToggleRepeat { get { @@ -133,7 +142,7 @@ public static string ActionToggleRepeat { } /// - /// Recherche une chaîne localisée semblable à Toggle Shuffle. + /// Looks up a localized string similar to Toggle Shuffle. /// public static string ActionToggleShuffle { get { @@ -142,7 +151,7 @@ public static string ActionToggleShuffle { } /// - /// Recherche une chaîne localisée semblable à Create New Playlist. + /// Looks up a localized string similar to Create New Playlist. /// public static string AddToPlaylistCreateNewPlaylist { get { @@ -151,7 +160,7 @@ public static string AddToPlaylistCreateNewPlaylist { } /// - /// Recherche une chaîne localisée semblable à Playlist Name. + /// Looks up a localized string similar to Playlist Name. /// public static string AddToPlaylistNewPlaylistName { get { @@ -160,7 +169,7 @@ public static string AddToPlaylistNewPlaylistName { } /// - /// Recherche une chaîne localisée semblable à Select a Playlist. + /// Looks up a localized string similar to Select a Playlist. /// public static string AddToPlaylistPlaceholder { get { @@ -169,7 +178,7 @@ public static string AddToPlaylistPlaceholder { } /// - /// Recherche une chaîne localisée semblable à Add. + /// Looks up a localized string similar to Add. /// public static string AddToPlaylistPrimaryButtonText { get { @@ -178,7 +187,7 @@ public static string AddToPlaylistPrimaryButtonText { } /// - /// Recherche une chaîne localisée semblable à Add track(s) to the following Playlist:. + /// Looks up a localized string similar to Add track(s) to the following Playlist:. /// public static string AddToPlaylistText { get { @@ -187,7 +196,7 @@ public static string AddToPlaylistText { } /// - /// Recherche une chaîne localisée semblable à Add to Playlist. + /// Looks up a localized string similar to Add to Playlist. /// public static string AddToPlaylistTitle { get { @@ -196,7 +205,7 @@ public static string AddToPlaylistTitle { } /// - /// Recherche une chaîne localisée semblable à Stylophone. + /// Looks up a localized string similar to Stylophone. /// public static string AppDisplayName { get { @@ -205,7 +214,7 @@ public static string AppDisplayName { } /// - /// Recherche une chaîne localisée semblable à Cancel. + /// Looks up a localized string similar to Cancel. /// public static string CancelButtonText { get { @@ -214,7 +223,7 @@ public static string CancelButtonText { } /// - /// Recherche une chaîne localisée semblable à Save Queue as Playlist. + /// Looks up a localized string similar to Save Queue as Playlist. /// public static string ContextMenuAddQueueToPlaylist { get { @@ -223,7 +232,7 @@ public static string ContextMenuAddQueueToPlaylist { } /// - /// Recherche une chaîne localisée semblable à Add to Playlist. + /// Looks up a localized string similar to Add to Playlist. /// public static string ContextMenuAddToPlaylist { get { @@ -232,7 +241,7 @@ public static string ContextMenuAddToPlaylist { } /// - /// Recherche une chaîne localisée semblable à Add to Queue. + /// Looks up a localized string similar to Add to Queue. /// public static string ContextMenuAddToQueue { get { @@ -241,7 +250,7 @@ public static string ContextMenuAddToQueue { } /// - /// Recherche une chaîne localisée semblable à Clear Queue. + /// Looks up a localized string similar to Clear Queue. /// public static string ContextMenuClearQueue { get { @@ -250,7 +259,7 @@ public static string ContextMenuClearQueue { } /// - /// Recherche une chaîne localisée semblable à Delete Playlist. + /// Looks up a localized string similar to Delete Playlist. /// public static string ContextMenuDeletePlaylist { get { @@ -259,7 +268,7 @@ public static string ContextMenuDeletePlaylist { } /// - /// Recherche une chaîne localisée semblable à Play. + /// Looks up a localized string similar to Play. /// public static string ContextMenuPlay { get { @@ -268,7 +277,7 @@ public static string ContextMenuPlay { } /// - /// Recherche une chaîne localisée semblable à Remove from Playlist. + /// Looks up a localized string similar to Remove from Playlist. /// public static string ContextMenuRemoveFromPlaylist { get { @@ -277,7 +286,7 @@ public static string ContextMenuRemoveFromPlaylist { } /// - /// Recherche une chaîne localisée semblable à Remove from Queue. + /// Looks up a localized string similar to Remove from Queue. /// public static string ContextMenuRemoveFromQueue { get { @@ -286,7 +295,7 @@ public static string ContextMenuRemoveFromQueue { } /// - /// Recherche une chaîne localisée semblable à View Album. + /// Looks up a localized string similar to View Album. /// public static string ContextMenuViewAlbum { get { @@ -295,7 +304,7 @@ public static string ContextMenuViewAlbum { } /// - /// Recherche une chaîne localisée semblable à Server Database is Updating. + /// Looks up a localized string similar to Server Database is Updating. /// public static string DatabaseUpdateHeader { get { @@ -304,7 +313,7 @@ public static string DatabaseUpdateHeader { } /// - /// Recherche une chaîne localisée semblable à Delete Playlist?. + /// Looks up a localized string similar to Delete Playlist?. /// public static string DeletePlaylistContentDialog { get { @@ -313,7 +322,7 @@ public static string DeletePlaylistContentDialog { } /// - /// Recherche une chaîne localisée semblable à Is your MPD server connected?. + /// Looks up a localized string similar to Is your MPD server connected?. /// public static string EmptyFoldersDesc { get { @@ -322,7 +331,7 @@ public static string EmptyFoldersDesc { } /// - /// Recherche une chaîne localisée semblable à No folders found on server.. + /// Looks up a localized string similar to No folders found on server.. /// public static string EmptyFoldersTitle { get { @@ -331,7 +340,7 @@ public static string EmptyFoldersTitle { } /// - /// Recherche une chaîne localisée semblable à Ain't nothin' like an empty library.. + /// Looks up a localized string similar to Ain't nothin' like an empty library.. /// public static string EmptyLibraryTitle { get { @@ -340,7 +349,7 @@ public static string EmptyLibraryTitle { } /// - /// Recherche une chaîne localisée semblable à Get some tracks in there!. + /// Looks up a localized string similar to Get some tracks in there!. /// public static string EmptyPlaylistDesc { get { @@ -349,7 +358,7 @@ public static string EmptyPlaylistDesc { } /// - /// Recherche une chaîne localisée semblable à I can't jam to this.. + /// Looks up a localized string similar to I can't jam to this.. /// public static string EmptyPlaylistTitle { get { @@ -358,7 +367,7 @@ public static string EmptyPlaylistTitle { } /// - /// Recherche une chaîne localisée semblable à Why don't you add some music?. + /// Looks up a localized string similar to Why don't you add some music?. /// public static string EmptyQueueDesc { get { @@ -367,7 +376,7 @@ public static string EmptyQueueDesc { } /// - /// Recherche une chaîne localisée semblable à All is quiet now.. + /// Looks up a localized string similar to All is quiet now.. /// public static string EmptyQueueTitle { get { @@ -376,7 +385,7 @@ public static string EmptyQueueTitle { } /// - /// Recherche une chaîne localisée semblable à No Tracks found on Server.. + /// Looks up a localized string similar to No Tracks found on Server.. /// public static string EmptySearchDesc { get { @@ -385,7 +394,7 @@ public static string EmptySearchDesc { } /// - /// Recherche une chaîne localisée semblable à Funky Fresh Search!. + /// Looks up a localized string similar to Funky Fresh Search!. /// public static string EmptySearchTitle { get { @@ -394,7 +403,7 @@ public static string EmptySearchTitle { } /// - /// Recherche une chaîne localisée semblable à Couldn't add Album: {0}. + /// Looks up a localized string similar to Couldn't add Album: {0}. /// public static string ErrorAddingAlbum { get { @@ -403,7 +412,7 @@ public static string ErrorAddingAlbum { } /// - /// Recherche une chaîne localisée semblable à Couldn't clear queue!. + /// Looks up a localized string similar to Couldn't clear queue!. /// public static string ErrorCantClearCache { get { @@ -412,7 +421,7 @@ public static string ErrorCantClearCache { } /// - /// Recherche une chaîne localisée semblable à Error: {0}. + /// Looks up a localized string similar to Error: {0}. /// public static string ErrorGeneric { get { @@ -421,7 +430,7 @@ public static string ErrorGeneric { } /// - /// Recherche une chaîne localisée semblable à Invalid MPD Response.. + /// Looks up a localized string similar to Invalid MPD Response.. /// public static string ErrorInvalidMPDResponse { get { @@ -430,7 +439,7 @@ public static string ErrorInvalidMPDResponse { } /// - /// Recherche une chaîne localisée semblable à This track doesn't have a matching Album.. + /// Looks up a localized string similar to This track doesn't have a matching Album.. /// public static string ErrorNoMatchingAlbum { get { @@ -439,7 +448,7 @@ public static string ErrorNoMatchingAlbum { } /// - /// Recherche une chaîne localisée semblable à Invalid password. + /// Looks up a localized string similar to Invalid password. /// public static string ErrorPassword { get { @@ -448,7 +457,7 @@ public static string ErrorPassword { } /// - /// Recherche une chaîne localisée semblable à Error trying to play the MPD server's httpd stream. + /// Looks up a localized string similar to Error trying to play the MPD server's httpd stream. /// public static string ErrorPlayingMPDStream { get { @@ -457,7 +466,7 @@ public static string ErrorPlayingMPDStream { } /// - /// Recherche une chaîne localisée semblable à Couldn't play content: {0}. + /// Looks up a localized string similar to Couldn't play content: {0}. /// public static string ErrorPlayingTrack { get { @@ -466,7 +475,7 @@ public static string ErrorPlayingTrack { } /// - /// Recherche une chaîne localisée semblable à Sending {0} failed. + /// Looks up a localized string similar to Sending {0} failed. /// public static string ErrorSendingMPDCommand { get { @@ -475,7 +484,7 @@ public static string ErrorSendingMPDCommand { } /// - /// Recherche une chaîne localisée semblable à Updating Playlists failed. + /// Looks up a localized string similar to Updating Playlists failed. /// public static string ErrorUpdatingPlaylist { get { @@ -484,7 +493,7 @@ public static string ErrorUpdatingPlaylist { } /// - /// Recherche une chaîne localisée semblable à David Bowie is credited with playing the Stylophone on his 1969 debut hit song "Space Oddity" and also for his 2002 album Heathen track titled "Slip Away," as well as on the song "Heathen (The Rays)".. + /// Looks up a localized string similar to David Bowie is credited with playing the Stylophone on his 1969 debut hit song "Space Oddity" and also for his 2002 album Heathen track titled "Slip Away," as well as on the song "Heathen (The Rays)".. /// public static string FirstRunFlavorText { get { @@ -493,7 +502,7 @@ public static string FirstRunFlavorText { } /// - /// Recherche une chaîne localisée semblable à To get started, please visit the Settings page to key in your MPD server's URL and port. + /// Looks up a localized string similar to To get started, please visit the Settings page to key in your MPD server's URL and port. ///Keep in mind Stylophone can send usage data to help diagnose bugs. ///If you're not okay with this, you can disable telemetry in the Settings. ///Hope you enjoy using the application! @@ -506,7 +515,7 @@ public static string FirstRunText { } /// - /// Recherche une chaîne localisée semblable à Welcome to Stylophone!. + /// Looks up a localized string similar to Welcome to Stylophone!. /// public static string FirstRunTitle { get { @@ -515,7 +524,7 @@ public static string FirstRunTitle { } /// - /// Recherche une chaîne localisée semblable à Close all open folders. + /// Looks up a localized string similar to Close all open folders. /// public static string FoldersCollapseAll { get { @@ -524,7 +533,7 @@ public static string FoldersCollapseAll { } /// - /// Recherche une chaîne localisée semblable à Folders. + /// Looks up a localized string similar to Folders. /// public static string FoldersHeader { get { @@ -533,7 +542,7 @@ public static string FoldersHeader { } /// - /// Recherche une chaîne localisée semblable à Loading.... + /// Looks up a localized string similar to Loading.... /// public static string FoldersLoadingTreeItem { get { @@ -542,7 +551,7 @@ public static string FoldersLoadingTreeItem { } /// - /// Recherche une chaîne localisée semblable à MPD Filesystem. + /// Looks up a localized string similar to MPD Filesystem. /// public static string FoldersRoot { get { @@ -551,7 +560,7 @@ public static string FoldersRoot { } /// - /// Recherche une chaîne localisée semblable à Library. + /// Looks up a localized string similar to Library. /// public static string LibraryHeader { get { @@ -560,7 +569,7 @@ public static string LibraryHeader { } /// - /// Recherche une chaîne localisée semblable à Search. + /// Looks up a localized string similar to Search. /// public static string LibrarySearchPlaceholder { get { @@ -569,7 +578,7 @@ public static string LibrarySearchPlaceholder { } /// - /// Recherche une chaîne localisée semblable à Local Volume. + /// Looks up a localized string similar to Local Volume. /// public static string LocalVolumeHeader { get { @@ -578,7 +587,7 @@ public static string LocalVolumeHeader { } /// - /// Recherche une chaîne localisée semblable à No. + /// Looks up a localized string similar to No. /// public static string NoButtonText { get { @@ -587,7 +596,7 @@ public static string NoButtonText { } /// - /// Recherche une chaîne localisée semblable à Added to Playlist {0}!. + /// Looks up a localized string similar to Added to Playlist {0}!. /// public static string NotificationAddedToPlaylist { get { @@ -596,7 +605,7 @@ public static string NotificationAddedToPlaylist { } /// - /// Recherche une chaîne localisée semblable à Added to Queue!. + /// Looks up a localized string similar to Added to Queue!. /// public static string NotificationAddedToQueue { get { @@ -605,7 +614,7 @@ public static string NotificationAddedToQueue { } /// - /// Recherche une chaîne localisée semblable à Album art cache has been deleted.. + /// Looks up a localized string similar to Album art cache has been deleted.. /// public static string NotificationCacheDeleted { get { @@ -614,7 +623,7 @@ public static string NotificationCacheDeleted { } /// - /// Recherche une chaîne localisée semblable à The database is already being updated.. + /// Looks up a localized string similar to The database is already being updated.. /// public static string NotificationDbAlreadyUpdating { get { @@ -623,7 +632,7 @@ public static string NotificationDbAlreadyUpdating { } /// - /// Recherche une chaîne localisée semblable à Database update started.. + /// Looks up a localized string similar to Database update started.. /// public static string NotificationDbUpdateStarted { get { @@ -632,7 +641,7 @@ public static string NotificationDbUpdateStarted { } /// - /// Recherche une chaîne localisée semblable à No Track is currently playing.. + /// Looks up a localized string similar to No Track is currently playing.. /// public static string NotificationNoTrackPlaying { get { @@ -641,7 +650,7 @@ public static string NotificationNoTrackPlaying { } /// - /// Recherche une chaîne localisée semblable à No tracks loaded yet.. + /// Looks up a localized string similar to No tracks loaded yet.. /// public static string NotificationNoTracksLoaded { get { @@ -650,7 +659,7 @@ public static string NotificationNoTracksLoaded { } /// - /// Recherche une chaîne localisée semblable à Now Playing {0}. + /// Looks up a localized string similar to Now Playing {0}. /// public static string NotificationNowPlayingTrack { get { @@ -659,7 +668,7 @@ public static string NotificationNowPlayingTrack { } /// - /// Recherche une chaîne localisée semblable à The Playlist has been removed.. + /// Looks up a localized string similar to The Playlist has been removed.. /// public static string NotificationPlaylistRemoved { get { @@ -668,7 +677,7 @@ public static string NotificationPlaylistRemoved { } /// - /// Recherche une chaîne localisée semblable à Off. + /// Looks up a localized string similar to Off. /// public static string OffText { get { @@ -677,7 +686,7 @@ public static string OffText { } /// - /// Recherche une chaîne localisée semblable à OK. + /// Looks up a localized string similar to OK. /// public static string OKButtonText { get { @@ -686,7 +695,7 @@ public static string OKButtonText { } /// - /// Recherche une chaîne localisée semblable à On. + /// Looks up a localized string similar to On. /// public static string OnText { get { @@ -695,7 +704,7 @@ public static string OnText { } /// - /// Recherche une chaîne localisée semblable à Up Next:. + /// Looks up a localized string similar to Up Next:. /// public static string PlaybackUpNext { get { @@ -704,7 +713,7 @@ public static string PlaybackUpNext { } /// - /// Recherche une chaîne localisée semblable à Playlists. + /// Looks up a localized string similar to Playlists. /// public static string PlaylistsHeader { get { @@ -713,7 +722,7 @@ public static string PlaylistsHeader { } /// - /// Recherche une chaîne localisée semblable à Queue. + /// Looks up a localized string similar to Queue. /// public static string QueueHeader { get { @@ -722,7 +731,7 @@ public static string QueueHeader { } /// - /// Recherche une chaîne localisée semblable à Pick some Songs. + /// Looks up a localized string similar to Pick some Songs. /// public static string RandomTracksHeader { get { @@ -731,7 +740,7 @@ public static string RandomTracksHeader { } /// - /// Recherche une chaîne localisée semblable à Shuffling through your library.... + /// Looks up a localized string similar to Shuffling through your library.... /// public static string RandomTracksInProgress { get { @@ -740,7 +749,7 @@ public static string RandomTracksInProgress { } /// - /// Recherche une chaîne localisée semblable à Thanks for using Stylophone! Would you like to rate the app on the Store? + /// Looks up a localized string similar to Thanks for using Stylophone! Would you like to rate the app on the Store? ///(We won't ask again. 🙏). /// public static string RateAppPromptText { @@ -750,7 +759,7 @@ public static string RateAppPromptText { } /// - /// Recherche une chaîne localisée semblable à Rate the Application. + /// Looks up a localized string similar to Rate the Application. /// public static string RateAppPromptTitle { get { @@ -759,7 +768,7 @@ public static string RateAppPromptTitle { } /// - /// Recherche une chaîne localisée semblable à Albums. + /// Looks up a localized string similar to Albums. /// public static string SearchAlbumsToggle { get { @@ -768,7 +777,7 @@ public static string SearchAlbumsToggle { } /// - /// Recherche une chaîne localisée semblable à Artists. + /// Looks up a localized string similar to Artists. /// public static string SearchArtistsToggle { get { @@ -777,7 +786,7 @@ public static string SearchArtistsToggle { } /// - /// Recherche une chaîne localisée semblable à Search for "{0}" on the server.... + /// Looks up a localized string similar to Search for "{0}" on the server.... /// public static string SearchGoToDetail { get { @@ -786,7 +795,7 @@ public static string SearchGoToDetail { } /// - /// Recherche une chaîne localisée semblable à No Results have been found.... + /// Looks up a localized string similar to No Results have been found.... /// public static string SearchNoResultsTitle_Text { get { @@ -795,7 +804,7 @@ public static string SearchNoResultsTitle_Text { } /// - /// Recherche une chaîne localisée semblable à Search Tracks.... + /// Looks up a localized string similar to Search Tracks.... /// public static string SearchPlaceholderText { get { @@ -804,7 +813,7 @@ public static string SearchPlaceholderText { } /// - /// Recherche une chaîne localisée semblable à Search Results for "{0}". + /// Looks up a localized string similar to Search Results for "{0}". /// public static string SearchResultsFor { get { @@ -813,7 +822,7 @@ public static string SearchResultsFor { } /// - /// Recherche une chaîne localisée semblable à Tracks. + /// Looks up a localized string similar to Tracks. /// public static string SearchTracksToggle { get { @@ -822,7 +831,7 @@ public static string SearchTracksToggle { } /// - /// Recherche une chaîne localisée semblable à About this application. + /// Looks up a localized string similar to About this application. /// public static string SettingsAbout { get { @@ -831,7 +840,7 @@ public static string SettingsAbout { } /// - /// Recherche une chaîne localisée semblable à A pretty cool MPD Client. Uses MpcNET.. + /// Looks up a localized string similar to A pretty cool MPD Client. Uses MpcNET.. /// public static string SettingsAboutText { get { @@ -840,7 +849,7 @@ public static string SettingsAboutText { } /// - /// Recherche une chaîne localisée semblable à Download Album Art from the MPD Server. + /// Looks up a localized string similar to Download Album Art from the MPD Server. /// public static string SettingsAlbumArt { get { @@ -849,7 +858,7 @@ public static string SettingsAlbumArt { } /// - /// Recherche une chaîne localisée semblable à Stylophone stores album art locally to avoid overloading your MPD Server.. + /// Looks up a localized string similar to Stylophone stores album art locally to avoid overloading your MPD Server.. /// public static string SettingsAlbumArtText { get { @@ -858,7 +867,7 @@ public static string SettingsAlbumArtText { } /// - /// Recherche une chaîne localisée semblable à Analytics. + /// Looks up a localized string similar to Analytics. /// public static string SettingsAnalytics { get { @@ -867,7 +876,7 @@ public static string SettingsAnalytics { } /// - /// Recherche une chaîne localisée semblable à Allow Stylophone to send crash and analytics reports. + /// Looks up a localized string similar to Allow Stylophone to send crash and analytics reports. /// public static string SettingsAnalyticsText { get { @@ -876,7 +885,7 @@ public static string SettingsAnalyticsText { } /// - /// Recherche une chaîne localisée semblable à This setting will apply after restarting the app.. + /// Looks up a localized string similar to This setting will apply after restarting the app.. /// public static string SettingsApplyOnRestart { get { @@ -885,7 +894,7 @@ public static string SettingsApplyOnRestart { } /// - /// Recherche une chaîne localisée semblable à Clear Cache. + /// Looks up a localized string similar to Clear Cache. /// public static string SettingsClearCache { get { @@ -894,7 +903,7 @@ public static string SettingsClearCache { } /// - /// Recherche une chaîne localisée semblable à Clear the local album art cache. + /// Looks up a localized string similar to Clear the local album art cache. /// public static string SettingsClearCacheDescription { get { @@ -903,7 +912,7 @@ public static string SettingsClearCacheDescription { } /// - /// Recherche une chaîne localisée semblable à Personalization. + /// Looks up a localized string similar to Personalization. /// public static string SettingsCustomization { get { @@ -912,7 +921,7 @@ public static string SettingsCustomization { } /// - /// Recherche une chaîne localisée semblable à Database and Album Art. + /// Looks up a localized string similar to Database and Album Art. /// public static string SettingsDatabase { get { @@ -921,7 +930,7 @@ public static string SettingsDatabase { } /// - /// Recherche une chaîne localisée semblable à Source Code, License and Privacy Statement. + /// Looks up a localized string similar to Source Code, License and Privacy Statement. /// public static string SettingsGithub { get { @@ -930,7 +939,7 @@ public static string SettingsGithub { } /// - /// Recherche une chaîne localisée semblable à https://github.com/Difegue/Stylophone. + /// Looks up a localized string similar to https://github.com/Difegue/Stylophone. /// public static string SettingsGithubLink { get { @@ -939,7 +948,7 @@ public static string SettingsGithubLink { } /// - /// Recherche une chaîne localisée semblable à Settings. + /// Looks up a localized string similar to Settings. /// public static string SettingsHeader { get { @@ -948,7 +957,7 @@ public static string SettingsHeader { } /// - /// Recherche une chaîne localisée semblable à Local Playback available. + /// Looks up a localized string similar to Local Playback available. /// public static string SettingsLocalPlaybackAvailable { get { @@ -957,7 +966,7 @@ public static string SettingsLocalPlaybackAvailable { } /// - /// Recherche une chaîne localisée semblable à Local Playback. + /// Looks up a localized string similar to Local Playback. /// public static string SettingsLocalPlaybackHeader { get { @@ -966,7 +975,7 @@ public static string SettingsLocalPlaybackHeader { } /// - /// Recherche une chaîne localisée semblable à Stylophone can play your MPD Server's music stream. + /// Looks up a localized string similar to Stylophone can play your MPD Server's music stream. ///Enabling this option will show a second volume slider to control local volume.. /// public static string SettingsLocalPlaybackText { @@ -976,7 +985,7 @@ public static string SettingsLocalPlaybackText { } /// - /// Recherche une chaîne localisée semblable à Couldn't find an MPD server at this address!. + /// Looks up a localized string similar to Couldn't find an MPD server at this address!. /// public static string SettingsNoServerError { get { @@ -985,7 +994,7 @@ public static string SettingsNoServerError { } /// - /// Recherche une chaîne localisée semblable à MPD Server. + /// Looks up a localized string similar to MPD Server. /// public static string SettingsServer { get { @@ -994,7 +1003,7 @@ public static string SettingsServer { } /// - /// Recherche une chaîne localisée semblable à Hostname. + /// Looks up a localized string similar to Hostname. /// public static string SettingsServerHost { get { @@ -1003,7 +1012,7 @@ public static string SettingsServerHost { } /// - /// Recherche une chaîne localisée semblable à Password. + /// Looks up a localized string similar to Password. /// public static string SettingsServerPassword { get { @@ -1012,7 +1021,7 @@ public static string SettingsServerPassword { } /// - /// Recherche une chaîne localisée semblable à Port. + /// Looks up a localized string similar to Port. /// public static string SettingsServerPort { get { @@ -1021,7 +1030,7 @@ public static string SettingsServerPort { } /// - /// Recherche une chaîne localisée semblable à Theme. + /// Looks up a localized string similar to Theme. /// public static string SettingsTheme { get { @@ -1030,7 +1039,7 @@ public static string SettingsTheme { } /// - /// Recherche une chaîne localisée semblable à Dark. + /// Looks up a localized string similar to Dark. /// public static string SettingsThemeDark { get { @@ -1039,7 +1048,7 @@ public static string SettingsThemeDark { } /// - /// Recherche une chaîne localisée semblable à System default. + /// Looks up a localized string similar to System default. /// public static string SettingsThemeDefault { get { @@ -1048,7 +1057,7 @@ public static string SettingsThemeDefault { } /// - /// Recherche une chaîne localisée semblable à Light. + /// Looks up a localized string similar to Light. /// public static string SettingsThemeLight { get { @@ -1057,7 +1066,7 @@ public static string SettingsThemeLight { } /// - /// Recherche une chaîne localisée semblable à UI Density. + /// Looks up a localized string similar to UI Density. /// public static string SettingsUIDensity { get { @@ -1066,7 +1075,7 @@ public static string SettingsUIDensity { } /// - /// Recherche une chaîne localisée semblable à Compact. + /// Looks up a localized string similar to Compact. /// public static string SettingsUIDensityCompact { get { @@ -1075,7 +1084,7 @@ public static string SettingsUIDensityCompact { } /// - /// Recherche une chaîne localisée semblable à Normal. + /// Looks up a localized string similar to Normal. /// public static string SettingsUIDensityNormal { get { @@ -1084,7 +1093,7 @@ public static string SettingsUIDensityNormal { } /// - /// Recherche une chaîne localisée semblable à Update. + /// Looks up a localized string similar to Update. /// public static string SettingsUpdateDatabase { get { @@ -1093,7 +1102,7 @@ public static string SettingsUpdateDatabase { } /// - /// Recherche une chaîne localisée semblable à This operation might take the server some time to complete.. + /// Looks up a localized string similar to This operation might take the server some time to complete.. /// public static string SettingsUpdateDbDesc { get { @@ -1102,7 +1111,7 @@ public static string SettingsUpdateDbDesc { } /// - /// Recherche une chaîne localisée semblable à Update the MPD Server Database. + /// Looks up a localized string similar to Update the MPD Server Database. /// public static string SettingsUpdateDbTitle { get { @@ -1111,7 +1120,16 @@ public static string SettingsUpdateDbTitle { } /// - /// Recherche une chaîne localisée semblable à Yes. + /// Looks up a localized string similar to Song playback. + /// + public static string SongPlaybackLabel { + get { + return ResourceManager.GetString("SongPlaybackLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes. /// public static string YesButtonText { get { diff --git a/Sources/Stylophone.Localization/Strings/Resources.en-US.resx b/Sources/Stylophone.Localization/Strings/Resources.en-US.resx index b3419349..50ac5315 100644 --- a/Sources/Stylophone.Localization/Strings/Resources.en-US.resx +++ b/Sources/Stylophone.Localization/Strings/Resources.en-US.resx @@ -482,4 +482,10 @@ Enabling this option will show a second volume slider to control local volume. Updating Playlists failed + + Open Fullscreen Playback + + + Song playback + \ No newline at end of file diff --git a/Sources/Stylophone.Localization/Strings/Resources.fr-FR.resx b/Sources/Stylophone.Localization/Strings/Resources.fr-FR.resx index eb9d656e..2820b28c 100644 --- a/Sources/Stylophone.Localization/Strings/Resources.fr-FR.resx +++ b/Sources/Stylophone.Localization/Strings/Resources.fr-FR.resx @@ -481,4 +481,10 @@ L'activation de cette option affichera un second slider pour contrôler le volum La mise à jour des playlists a échoué + + Ouvrir le lecteur plein écran + + + Lecture de la piste + \ No newline at end of file diff --git a/Sources/Stylophone.Localization/Strings/Resources.pt-PT.resx b/Sources/Stylophone.Localization/Strings/Resources.pt-PT.resx index 92c13b3b..135d070a 100644 --- a/Sources/Stylophone.Localization/Strings/Resources.pt-PT.resx +++ b/Sources/Stylophone.Localization/Strings/Resources.pt-PT.resx @@ -478,4 +478,10 @@ Ativando esta opção mostrará um segundo deslizador de volume para controlar o Não foi possível enviar {0} + + Mostrar Leitor + + + Leitura da faixa + \ No newline at end of file diff --git a/Sources/Stylophone.Localization/Strings/Resources.resx b/Sources/Stylophone.Localization/Strings/Resources.resx index a5fe00b4..b18cecb7 100644 --- a/Sources/Stylophone.Localization/Strings/Resources.resx +++ b/Sources/Stylophone.Localization/Strings/Resources.resx @@ -482,4 +482,10 @@ Enabling this option will show a second volume slider to control local volume. Updating Playlists failed + + Open Fullscreen Playback + + + Song playback + \ No newline at end of file diff --git a/Sources/Stylophone.iOS/Helpers/LocalizedLabel.cs b/Sources/Stylophone.iOS/Helpers/LocalizedLabel.cs index 34151f95..9825ae35 100644 --- a/Sources/Stylophone.iOS/Helpers/LocalizedLabel.cs +++ b/Sources/Stylophone.iOS/Helpers/LocalizedLabel.cs @@ -26,10 +26,10 @@ public override void AwakeFromNib() // Use the text set in IB to find the matching property. // Set the identifier in "User Defined Runtime Attributes". - var prop = typeof(Resources).GetProperty(stringIdentifier ?? "AppDisplayName"); + var identifier = stringIdentifier ?? "AppDisplayName"; // Get the property value to have the localized string. - Text = prop.GetValue(null, null).ToString(); + Text = Resources.ResourceManager.GetString(identifier); } } } diff --git a/Sources/Stylophone.iOS/Helpers/NSValueConverters.cs b/Sources/Stylophone.iOS/Helpers/NSValueConverters.cs index fd850591..039e324b 100644 --- a/Sources/Stylophone.iOS/Helpers/NSValueConverters.cs +++ b/Sources/Stylophone.iOS/Helpers/NSValueConverters.cs @@ -147,4 +147,34 @@ public override NSObject ReverseTransformedValue(NSObject value) return NSNumber.FromInt32(result); } } + + [Register(nameof(TrackToStringValueTransformer))] + public class TrackToStringValueTransformer : NSValueTransformer + { + public static new Class TransformedValueClass => new NSString().Class; + public static new bool AllowsReverseTransformation => false; + + public override NSObject TransformedValue(NSObject value) + { + var text = ""; + + if (value is NSWrapper wrap) + { + var trackVm = (TrackViewModel)wrap.ManagedObject; + text = trackVm.ToString(); + } + + return new NSString(text); + } + + public override NSObject ReverseTransformedValue(NSObject value) + { + int result = 0; + + if (value is NSString s) + int.TryParse(s, out result); + + return NSNumber.FromInt32(result); + } + } } diff --git a/Sources/Stylophone.iOS/Helpers/PropertyBinder.cs b/Sources/Stylophone.iOS/Helpers/PropertyBinder.cs index 2d11df33..4ab0e174 100644 --- a/Sources/Stylophone.iOS/Helpers/PropertyBinder.cs +++ b/Sources/Stylophone.iOS/Helpers/PropertyBinder.cs @@ -13,8 +13,9 @@ namespace Stylophone.iOS.Helpers public class PropertyBinder: IDisposable where TObservable : ObservableObject { - private readonly Dictionary> _bindings = new Dictionary>(); - private readonly Dictionary _observers = new Dictionary(); + private readonly Dictionary> _bindings = new(); + private readonly Dictionary _observers = new(); + private readonly Dictionary _buttonBindings = new(); private TObservable _observableObject; public PropertyBinder(TObservable viewModel) @@ -31,13 +32,22 @@ public void Dispose() // Dispose our observers foreach (IDisposable observer in _observers.Values) observer.Dispose(); + + // Unregister our button bindings + foreach (var kvp in _buttonBindings) + kvp.Key.PrimaryActionTriggered -= kvp.Value; } // Shorthand method to attach a command to a UIButton. public void BindButton(UIButton button, string buttonText, ICommand command, object parameter = null) { button.SetTitle(buttonText, UIControlState.Normal); - button.PrimaryActionTriggered += (s, e) => command.Execute(parameter); + + // Record button/eventhandler association so we can unregister them when the binder is disposed + var evtHandler = new EventHandler((s, e) => command.Execute(parameter)); + button.PrimaryActionTriggered += evtHandler; + + _buttonBindings.Add(button, evtHandler); } public UIAction GetCommandAction(string actionText, string systemImage, ICommand command, object parameter = null) diff --git a/Sources/Stylophone.iOS/Helpers/TrackTableViewDataSource.cs b/Sources/Stylophone.iOS/Helpers/TrackTableViewDataSource.cs index d0a8de7f..3bd07597 100644 --- a/Sources/Stylophone.iOS/Helpers/TrackTableViewDataSource.cs +++ b/Sources/Stylophone.iOS/Helpers/TrackTableViewDataSource.cs @@ -21,7 +21,9 @@ public class TrackTableViewDataSource : UITableViewDelegate, IUITableViewDataSou private Func _menuFactory; private Func _swipeFactory; private Action _scrollHandler; + private Action _primaryAction; private ObservableCollection _sourceCollection; + private bool _canReorder; public TrackTableViewDataSource(IntPtr handle) : base(handle) { @@ -34,21 +36,26 @@ public TrackTableViewDataSource(IntPtr handle) : base(handle) /// The source TrackViewModels /// A factory for row context menus /// A factory for row swipe actions - /// Whether you can select multiple rows + /// Whether you can reorder items /// Optional scrollHandler + /// Optional primary action public TrackTableViewDataSource(UITableView tableView, ObservableCollection source, Func contextMenuFactory, Func swipeActionFactory, - bool canSelectRows = false, Action scrollHandler = null) + bool canReorder = false, Action scrollHandler = null, Action primaryAction = null) { _tableView = tableView; _sourceCollection = source; _menuFactory = contextMenuFactory; _swipeFactory = swipeActionFactory; + _canReorder = canReorder; _scrollHandler = scrollHandler; + _primaryAction = primaryAction; _sourceCollection.CollectionChanged += (s,e) => UIApplication.SharedApplication.InvokeOnMainThread( () => UpdateUITableView(s,e)); - _tableView.AllowsMultipleSelection = canSelectRows; + + //_tableView.AllowsMultipleSelectionDuringEditing = _canReorder; + //_tableView.AllowsMultipleSelection = canSelectRows; } private void UpdateUITableView(object sender, NotifyCollectionChangedEventArgs e) @@ -74,7 +81,9 @@ private void UpdateUITableView(object sender, NotifyCollectionChangedEventArgs e if (e.Action == NotifyCollectionChangedAction.Remove) { - for (var i = e.OldStartingIndex; i < e.OldStartingIndex + e.OldItems.Count; i++) + var startIndex = e.OldStartingIndex; + + for (var i = startIndex; i < startIndex + e.OldItems.Count; i++) indexPaths.Add(NSIndexPath.FromItemSection(i, 0)); _tableView.DeleteRows(indexPaths.ToArray(), UITableViewRowAnimation.Right); @@ -86,6 +95,9 @@ private void UpdateUITableView(object sender, NotifyCollectionChangedEventArgs e #region DataSource + [Export("tableView:canMoveRowAtIndexPath:")] + public bool CanMoveRow(UITableView tableView, NSIndexPath indexPath) => _canReorder; + public nint RowsInSection(UITableView tableView, nint section) { return _sourceCollection.Count; @@ -113,10 +125,20 @@ public UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) return cell; } + [Export("tableView:moveRowAtIndexPath:toIndexPath:")] + public void MoveRow(UITableView tableView, NSIndexPath sourceIndexPath, NSIndexPath destinationIndexPath) + { + _sourceCollection.Move(sourceIndexPath.Row, destinationIndexPath.Row); + } + #endregion #region Delegate + // If multiselect isn't enabled, this will show a delete icon on the left side of the cells + public override UITableViewCellEditingStyle EditingStyleForRow(UITableView tableView, NSIndexPath indexPath) + => UITableViewCellEditingStyle.Delete; + public override void Scrolled(UIScrollView scrollView) { _scrollHandler?.Invoke(scrollView); @@ -124,14 +146,20 @@ public override void Scrolled(UIScrollView scrollView) public override void RowSelected(UITableView tableView, NSIndexPath indexPath) { - if (tableView.AllowsMultipleSelection) - tableView.CellAt(indexPath).Accessory = UITableViewCellAccessory.Checkmark; + //if (tableView.Editing) + // tableView.CellAt(indexPath).Accessory = UITableViewCellAccessory.Checkmark; } public override void RowDeselected(UITableView tableView, NSIndexPath indexPath) { - if (tableView.AllowsMultipleSelection) - tableView.CellAt(indexPath).Accessory = UITableViewCellAccessory.None; + //if (tableView.Editing) + // tableView.CellAt(indexPath).Accessory = UITableViewCellAccessory.None; + } + + public override void PerformPrimaryAction(UITableView tableView, NSIndexPath rowIndexPath) + { + if (!tableView.Editing) + _primaryAction?.Invoke(rowIndexPath); } public override UIContextMenuConfiguration GetContextMenuConfiguration(UITableView tableView, NSIndexPath indexPath, CoreGraphics.CGPoint point) diff --git a/Sources/Stylophone.iOS/Helpers/UICircularProgressView.cs b/Sources/Stylophone.iOS/Helpers/UICircularProgressView.cs index d248dccd..b77f1134 100644 --- a/Sources/Stylophone.iOS/Helpers/UICircularProgressView.cs +++ b/Sources/Stylophone.iOS/Helpers/UICircularProgressView.cs @@ -20,7 +20,7 @@ public UICircularProgressView (IntPtr handle) : base (handle) private CAShapeLayer _backgroundCircle = new CAShapeLayer(); private CAShapeLayer _progressCircle = new CAShapeLayer(); - private UIColor _backgroundCircleColor = UIColor.SystemGray4Color; + private UIColor _backgroundCircleColor = UIColor.SystemGray4; [Export(nameof(BackgroundCircleColor))] public UIColor BackgroundCircleColor { diff --git a/Sources/Stylophone.iOS/Info.plist b/Sources/Stylophone.iOS/Info.plist index a8042133..eeebfbeb 100644 --- a/Sources/Stylophone.iOS/Info.plist +++ b/Sources/Stylophone.iOS/Info.plist @@ -16,13 +16,13 @@ zh CFBundleShortVersionString - 2.5.4 + 2.6.0 CFBundleVersion - 2.5.4 + 2.6.0 LSRequiresIPhoneOS MinimumOSVersion - 15.0 + 16.0 UIBackgroundModes audio diff --git a/Sources/Stylophone.iOS/Properties/AssemblyInfo.cs b/Sources/Stylophone.iOS/Properties/AssemblyInfo.cs deleted file mode 100644 index 63e95fbd..00000000 --- a/Sources/Stylophone.iOS/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Stylophone.iOS")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Stylophone.iOS")] -[assembly: AssemblyCopyright("Copyright © 2017")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("50c7b8c9-e664-45af-b88e-0c9b8b9c1be1")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Sources/Stylophone.iOS/Services/DialogService.cs b/Sources/Stylophone.iOS/Services/DialogService.cs index aad8b33a..55d3ee35 100644 --- a/Sources/Stylophone.iOS/Services/DialogService.cs +++ b/Sources/Stylophone.iOS/Services/DialogService.cs @@ -39,9 +39,11 @@ public async Task ShowAddToPlaylistDialog(bool allowExistingPlaylists = var dialog = new AddToPlaylistViewController(_mpdService, allowExistingPlaylists); var navigationController = new UINavigationController(dialog); - // Specify medium detent for the NavigationController's presentation + // Custom size detent since the addToPlaylist view is quite small UISheetPresentationController uspc = (UISheetPresentationController)navigationController.PresentationController; - uspc.Detents = new [] { UISheetPresentationControllerDetent.CreateMediumDetent() }; + + var detent = UISheetPresentationControllerDetent.Create(null, (context) => 240); + uspc.Detents = new [] { detent }; UIApplication.SharedApplication.KeyWindow.RootViewController.PresentViewController(navigationController, true, null); diff --git a/Sources/Stylophone.iOS/Services/NavigationService.cs b/Sources/Stylophone.iOS/Services/NavigationService.cs index 91c00bd0..3b7a4db9 100644 --- a/Sources/Stylophone.iOS/Services/NavigationService.cs +++ b/Sources/Stylophone.iOS/Services/NavigationService.cs @@ -29,7 +29,7 @@ public NavigationService() _viewControllers = new List(); } - public override bool CanGoBack => NavigationController.ViewControllers?.Count() > 1; + public override bool CanGoBack => NavigationController.ViewControllers?.Length > 1; public override Type CurrentPageViewModelType => _viewModelToStoryboardDictionary.Keys.Where( k => _viewModelToStoryboardDictionary[k] == NavigationController.VisibleViewController.Storyboard).FirstOrDefault(); @@ -73,14 +73,22 @@ public override void NavigateImplementation(Type viewmodelType, object parameter { viewController = viewControllerLoaded.First(); (viewController as IPreparableViewController)?.Prepare(parameter); - NavigationController.PushViewController(viewController, true); + + if (NavigationController.ViewControllers.Length == 0) + NavigationController.ViewControllers = new UIViewController[] { viewController }; + else + NavigationController.PushViewController(viewController, true); } else // This is truly new, load the VC from scratch { viewController = storyboard.InstantiateInitialViewController(); (viewController as IPreparableViewController)?.Prepare(parameter); - NavigationController.PushViewController(viewController, true); _viewControllers.Add(viewController); + + if (NavigationController.ViewControllers.Length == 0) + NavigationController.ViewControllers = new UIViewController[] { viewController }; + else + NavigationController.PushViewController(viewController, true); } _lastParamUsed = parameter; diff --git a/Sources/Stylophone.iOS/Services/NotificationService.cs b/Sources/Stylophone.iOS/Services/NotificationService.cs index 819567c5..3e83d5b4 100644 --- a/Sources/Stylophone.iOS/Services/NotificationService.cs +++ b/Sources/Stylophone.iOS/Services/NotificationService.cs @@ -1,14 +1,13 @@ using System; using UserNotifications; using Stylophone.Common.Interfaces; -using UIKit; -using Xam.RMessage; namespace Stylophone.iOS.Services { public class NotificationService : NotificationServiceBase { private IDispatcherService _dispatcherService; + private Timer _notificationTimer; public NotificationService(IDispatcherService dispatcherService) { @@ -17,22 +16,30 @@ public NotificationService(IDispatcherService dispatcherService) public override void ShowInAppNotification(InAppNotification notification) { + if (notification.NotificationType == NotificationType.Error) + { + // Let's just use alerts until TipKit is available... This is cheap but w/e + var alert = new UIAlertView(notification.NotificationTitle, notification.NotificationText, null, "Ok"); + alert.Show(); + return; + } + + var rootVc = (UIApplication.SharedApplication.Delegate as AppDelegate).RootViewController; + UIApplication.SharedApplication.InvokeOnMainThread(() => { if (UIApplication.SharedApplication.ApplicationState != UIApplicationState.Active) return; - RMessageType type = notification.NotificationType switch - { - NotificationType.Info => RMessageType.Normal, - NotificationType.Warning => RMessageType.Warning, - NotificationType.Error => RMessageType.Error, - _ => RMessageType.Normal - }; - - RMessage.ShowNotificationWithTitle(notification.NotificationTitle, notification.NotificationText, type, "", - notification.NotificationType == NotificationType.Error ? 300000 : 2, () => { }); + var popover = new NotificationPopoverViewController(notification.NotificationTitle, rootVc); + + rootVc.PresentViewController(popover, true, null); }); + + _notificationTimer?.Dispose(); + _notificationTimer = new Timer((_) => UIApplication.SharedApplication.InvokeOnMainThread(() => + rootVc.DismissViewController(true, null)), + null, 2000, Timeout.Infinite); } public override void ShowBasicToastNotification(string title, string description) @@ -51,4 +58,58 @@ public override void ShowBasicToastNotification(string title, string description }); } } + + public class NotificationPopoverViewController : UIViewController, IUIPopoverPresentationControllerDelegate + { + private string _text; + + public NotificationPopoverViewController(string text, UISplitViewController rootVc) //, CGRect sourceBounds, CGSize contentSize) + { + _text = text; + + ModalPresentationStyle = UIModalPresentationStyle.Popover; + + PopoverPresentationController.SourceView = rootVc.View; + PopoverPresentationController.Delegate = this; + PopoverPresentationController.PermittedArrowDirections = UIPopoverArrowDirection.Left; + PopoverPresentationController.SourceRect = new CGRect(16, 64, 1, 1); + } + + public override void LoadView() + { + base.LoadView(); + + PreferredContentSize = new CGSize(196, 54); + + var stackView = new UIStackView + { + Axis = UILayoutConstraintAxis.Horizontal, + Distribution = UIStackViewDistribution.FillProportionally, + Spacing = 32 + }; + + stackView.AddArrangedSubview(new UIView()); + stackView.AddArrangedSubview(new UILabel + { + Text = _text, + Font = UIFont.PreferredHeadline, + AdjustsFontSizeToFitWidth = true, + Lines = 2, + }); + stackView.AddArrangedSubview(new UIView()); + + View = stackView; + + } + + [Export("adaptivePresentationStyleForPresentationController:traitCollection:")] + public UIModalPresentationStyle GetAdaptivePresentationStyle(UIPresentationController controller, UITraitCollection traitCollection) + { + // Prevent popover from being adaptive fullscreen on phones + // (https://pspdfkit.com/blog/2022/presenting-popovers-on-iphone-with-swiftui/) + return UIModalPresentationStyle.None; + } + + } + } diff --git a/Sources/Stylophone.iOS/Stylophone.iOS.csproj b/Sources/Stylophone.iOS/Stylophone.iOS.csproj index 87fcb187..3889d5c1 100644 --- a/Sources/Stylophone.iOS/Stylophone.iOS.csproj +++ b/Sources/Stylophone.iOS/Stylophone.iOS.csproj @@ -1,264 +1,80 @@ - - + - Debug - iPhoneSimulator - {A437E83D-67F2-410A-99DB-6B6D03AA4130} - {FEACFBD2-3405-455C-9665-78FE426C6842};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - {e1087329-5912-47eb-bd6a-19b74ccb7863} + net7.0-ios Exe - Stylophone.iOS - Resources - Stylophone.iOS - true - NSUrlSessionHandler - PackageReference - automatic + enable + true + 16.0 - - true - full - false - bin\iPhoneSimulator\Debug - DEBUG - prompt - 4 - x86_64 - None - true - 9.0 - iOS Team Provisioning Profile: com.tvc-16.Stylophone - iPhone Developer + + false - - none - true - bin\iPhoneSimulator\Release - prompt - 4 - None - x86_64 - 9.0 - Automatique - iPhone Developer - true - - - true - full - false - bin\iPhone\Debug - DEBUG - prompt - 4 - ARM64 - Entitlements.plist - iPhone Developer - true - SdkOnly - 9.0 - - - none - true - bin\iPhone\Release - prompt - 4 - Entitlements.plist - ARM64 - iPhone Developer - SdkOnly - 9.0 - true + + false - - - - - - - + SidebarViewController.cs - - - - - - - - - + SettingsViewController.cs - - - - - - - - + QueueViewController.cs - - + PlaybackViewController.cs - - + TrackViewCell.cs - - + CompactPlaybackView.cs - - - + UICircularProgressView.cs - - - + AlbumDetailViewController.cs - - + FoldersViewController.cs - - + FilePathCell.cs - - + AlbumCollectionViewCell.cs - - + LibraryViewController.cs - - - + SearchResultsViewController.cs - - - - + PlaylistViewController.cs - - - + SymbolUIButton.cs - - - - - - - - - - - - false - - - false - - - false - - - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 2.88.1 - - - 6.0.0 - - - 4.5.3 - - - 4.5.3 - - - 0.0.1 - - - 1.0.0 - - - 3.3.17 - - - - - - - - - - - - - - + + + + + + + - {83B15D0E-C82F-4438-8290-0DFC23A6498F} - Stylophone.Common - {3DEDF3EF-8DD2-4E20-B057-21DF5E342230} - Stylophone.Localization - \ No newline at end of file diff --git a/Sources/Stylophone.iOS/ViewControllers/AddToPlaylistViewController.cs b/Sources/Stylophone.iOS/ViewControllers/AddToPlaylistViewController.cs index 53fb9daa..c96149bc 100644 --- a/Sources/Stylophone.iOS/ViewControllers/AddToPlaylistViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/AddToPlaylistViewController.cs @@ -27,7 +27,6 @@ public AddToPlaylistViewController(MPDConnectionService mpdService, bool allowEx Playlists = new ObservableCollection(mpdService.Playlists); - Title = Strings.AddToPlaylistTitle; ModalPresentationStyle = UIModalPresentationStyle.FormSheet; ModalTransitionStyle = UIModalTransitionStyle.CoverVertical; @@ -58,10 +57,10 @@ public override void LoadView() { base.LoadView(); - PreferredContentSize = new CGSize(512, 368); + PreferredContentSize = new CGSize(512, 196); var stackView = new UIStackView { - BackgroundColor = UIColor.SystemBackgroundColor, + BackgroundColor = UIColor.SystemBackground, Axis = UILayoutConstraintAxis.Vertical, Alignment = UIStackViewAlignment.Center, Distribution = UIStackViewDistribution.Fill, @@ -70,7 +69,10 @@ public override void LoadView() var playlistPicker = new UIPickerView(); playlistPicker.DataSource = this; - playlistPicker.Delegate = this; + playlistPicker.Delegate = this; + + var newPlaylistLabel = new UILabel { Text = Strings.AddToPlaylistText, Font = UIFont.PreferredTitle2 }; + newPlaylistLabel.Hidden = AllowExistingPlaylists; var newPlaylistTextField = new UITextField { Placeholder = Strings.AddToPlaylistNewPlaylistName, BorderStyle = UITextBorderStyle.RoundedRect }; newPlaylistTextField.EditingChanged += (s, e) => PlaylistName = newPlaylistTextField.Text; @@ -85,16 +87,18 @@ public override void LoadView() AddNewPlaylist = playlistSwitch.SelectedSegment == 1; playlistPicker.Hidden = AddNewPlaylist; newPlaylistTextField.Hidden = !AddNewPlaylist; + newPlaylistLabel.Hidden = !AddNewPlaylist; }; if (AllowExistingPlaylists) - stackView.AddArrangedSubview(playlistSwitch); - - //stackView.AddArrangedSubview(new UILabel { Text = Strings.AddToPlaylistText, Font = UIFont.PreferredTitle2 }); + NavigationItem.TitleView = playlistSwitch; + else + Title = Strings.AddToPlaylistCreateNewPlaylist; if (AllowExistingPlaylists) stackView.AddArrangedSubview(playlistPicker); - + + stackView.AddArrangedSubview(newPlaylistLabel); stackView.AddArrangedSubview(newPlaylistTextField); var spacerView = new UIView(); @@ -104,6 +108,7 @@ public override void LoadView() var constraints = new List(); constraints.Add(newPlaylistTextField.WidthAnchor.ConstraintEqualTo(View.WidthAnchor, 0.8F)); + constraints.Add(playlistPicker.HeightAnchor.ConstraintLessThanOrEqualTo(196)); NSLayoutConstraint.ActivateConstraints(constraints.ToArray()); } diff --git a/Sources/Stylophone.iOS/ViewControllers/AlbumDetailViewController.cs b/Sources/Stylophone.iOS/ViewControllers/AlbumDetailViewController.cs index 0f19babd..1c49a151 100644 --- a/Sources/Stylophone.iOS/ViewControllers/AlbumDetailViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/AlbumDetailViewController.cs @@ -34,16 +34,21 @@ public override void AwakeFromNib() TraitCollectionDidChange(null); NavigationItem.LargeTitleDisplayMode = UINavigationItemLargeTitleDisplayMode.Never; - var trackDataSource = new TrackTableViewDataSource(TableView, ViewModel.Source, GetRowContextMenu, GetRowSwipeActions, true, OnScroll); + var trackDataSource = new TrackTableViewDataSource(TableView, ViewModel.Source, + GetRowContextMenu, GetRowSwipeActions, false, OnScroll, OnTap); TableView.DataSource = trackDataSource; TableView.Delegate = trackDataSource; + TableView.SelfSizingInvalidation = UITableViewSelfSizingInvalidation.EnabledIncludingConstraints; - Binder.Bind(EmptyView, "hidden", nameof(ViewModel.IsSourceEmpty), + Binder.Bind(EmptyView, "hidden", nameof(ViewModel.IsSourceEmpty), valueTransformer: NSValueTransformer.GetValueTransformer(nameof(ReverseBoolValueTransformer))); Binder.Bind(AlbumTrackInfo, "text", nameof(ViewModel.PlaylistInfo)); } - private void OnScroll(UIScrollView scrollView) + private void OnTap(NSIndexPath indexPath) => + ViewModel.AddToQueueCommand.Execute(new List { ViewModel?.Source[indexPath.Row] }); + + private void OnScroll(UIScrollView scrollView) { if (scrollView.ContentOffset.Y > 192) { diff --git a/Sources/Stylophone.iOS/ViewControllers/PlaybackViewController.cs b/Sources/Stylophone.iOS/ViewControllers/PlaybackViewController.cs index 662b91ab..571e9c59 100644 --- a/Sources/Stylophone.iOS/ViewControllers/PlaybackViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/PlaybackViewController.cs @@ -10,11 +10,9 @@ using Stylophone.Common.ViewModels; using Stylophone.iOS.Helpers; using Stylophone.iOS.ViewModels; -using UIKit; -using Pop = ARSPopover.iOS; -using static Xamarin.Essentials.Permissions; using CommunityToolkit.Mvvm.Input; using CoreGraphics; +using UIKit; namespace Stylophone.iOS.ViewControllers { @@ -53,6 +51,7 @@ public override void AwakeFromNib() // Bind var negateBoolTransformer = NSValueTransformer.GetValueTransformer(nameof(ReverseBoolValueTransformer)); var intToStringTransformer = NSValueTransformer.GetValueTransformer(nameof(IntToStringValueTransformer)); + var trackToStringTransformer = NSValueTransformer.GetValueTransformer(nameof(TrackToStringValueTransformer)); // Compact View Binding Binder.Bind(CompactView, "hidden", nameof(ViewModel.IsTrackInfoAvailable), valueTransformer: negateBoolTransformer); @@ -62,7 +61,12 @@ public override void AwakeFromNib() CompactView.PlayPauseButton.PrimaryActionTriggered += (s, e) => ViewModel.ChangePlaybackState(); CompactView.ShuffleButton.PrimaryActionTriggered += (s, e) => ViewModel.ToggleShuffle(); + CompactView.VolumeButton.AccessibilityLabel = Strings.ActionChangeVolume; + CompactView.ShuffleButton.AccessibilityLabel = Strings.ActionToggleShuffle; + CompactView.OpenFullScreenButton.PrimaryActionTriggered += (s, e) => ViewModel.NavigateNowPlaying(); + CompactView.OpenFullScreenButton.AccessibilityLabel = Strings.ActionFullscreenPlayback; + Binder.Bind(CompactView.OpenFullScreenButton, "accessibilityValue", nameof(ViewModel.CurrentTrack), valueTransformer: trackToStringTransformer); // Volume Popover Binding LocalPlaybackBinder.Bind(LocalPlaybackView, "hidden", nameof(ViewModel.LocalPlayback.IsEnabled), valueTransformer: negateBoolTransformer); @@ -70,12 +74,13 @@ public override void AwakeFromNib() LocalMuteButton.PrimaryActionTriggered += (s, e) => ViewModel.LocalPlayback.ToggleMute(); LocalPlaybackBinder.Bind(LocalVolumeSlider, "value", nameof(ViewModel.LocalPlayback.Volume), true); LocalPlaybackBinder.Bind(LocalVolume, "text", nameof(ViewModel.LocalPlayback.Volume), valueTransformer: intToStringTransformer); + LocalVolumeSlider.AccessibilityLabel = Strings.LocalVolumeHeader; ServerMuteButton.PrimaryActionTriggered += (s, e) => ViewModel.ToggleMute(); Binder.Bind(ServerVolumeSlider, "value", nameof(ViewModel.MediaVolume), true); Binder.Bind(ServerVolumeSlider, "enabled", nameof(ViewModel.CanSetVolume)); Binder.Bind(ServerVolume, "text", nameof(ViewModel.MediaVolume), valueTransformer: intToStringTransformer); - + ServerVolumeSlider.AccessibilityLabel = Strings.ActionChangeVolume; } public override void ViewWillAppear(bool animated) @@ -98,23 +103,17 @@ public override void ViewDidLayoutSubviews() if (!ViewModel.IsFullScreen) return; - // Don't multi-line for small phones in vertical orientation + // Different multi-line behavior depending on size classes if (TraitCollection.HorizontalSizeClass == UIUserInterfaceSizeClass.Compact && TraitCollection.VerticalSizeClass == UIUserInterfaceSizeClass.Regular && UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Phone) { TrackTitle.Lines = 2; - AlbumName.Lines = 1; - } else { - TrackTitle.Lines = 3; - - if (UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Phone) - AlbumName.Lines = 1; - else - AlbumName.Lines = 2; + if (UIDevice.CurrentDevice.UserInterfaceIdiom != UIUserInterfaceIdiom.Phone) + TrackTitle.Lines = 3; } // On compact widths, change the application tintcolor, as that's what is used instead of the navigation bar's @@ -136,6 +135,7 @@ public override void ViewDidLoad() { base.ViewDidLoad(); + TrackSlider.AccessibilityLabel = Strings.SongPlaybackLabel; TrackSlider.TouchDragInside += (s, e) => { ViewModel.TimeListened = Miscellaneous.FormatTimeString(TrackSlider.Value * 1000); @@ -144,6 +144,7 @@ public override void ViewDidLoad() TrackSlider.ValueChanged += (s, e) => { ViewModel.OnPlayingSliderChange(); + TrackSlider.AccessibilityValue = ViewModel.TimeListened; }; var upNextTransformer = NSValueTransformer.GetValueTransformer(nameof(NextTrackToStringValueTransformer)); @@ -153,6 +154,8 @@ public override void ViewDidLoad() Binder.Bind(RemainingTime, "text", nameof(ViewModel.TimeRemaining)); Binder.Bind(TrackSlider, "value", nameof(ViewModel.CurrentTimeValue), true); Binder.Bind(TrackSlider, "maximumValue", nameof(ViewModel.MaxTimeValue)); + Binder.Bind(TrackSlider, "accessibilityValue", nameof(ViewModel.TimeListened)); + Binder.Bind(upNextView, "text", nameof(ViewModel.NextTrack), valueTransformer:upNextTransformer); UpdateFullView(ViewModel.CurrentTrack); @@ -162,6 +165,10 @@ public override void ViewDidLoad() UpdateButton(RepeatButton, ViewModel.RepeatIcon); UpdateButton(ShuffleButton, ViewModel.IsShuffleEnabled ? "shuffle.circle.fill" : "shuffle.circle"); + VolumeButton.AccessibilityLabel = Strings.ActionChangeVolume; + ShuffleButton.AccessibilityLabel = Strings.ActionToggleShuffle; + RepeatButton.AccessibilityLabel = Strings.ActionToggleRepeat; + SkipPrevButton.PrimaryActionTriggered += (s, e) => ViewModel.SkipPrevious(); SkipNextButton.PrimaryActionTriggered += (s, e) => ViewModel.SkipNext(); PlayPauseButton.PrimaryActionTriggered += (s, e) => ViewModel.ChangePlaybackState(); @@ -271,17 +278,9 @@ private UIBarButtonItem CreateSettingsButton() public void ShowVolumePopover(UIButton sourceButton, UIViewController sourceVc = null) { var sourceBounds = sourceButton.ImageView.Bounds; + var size = ViewModel.LocalPlayback.IsEnabled ? new CGSize(276, 176) : new CGSize(276, 96); - var popover = new Pop.ARSPopover - { - SourceView = sourceButton.ImageView, - SourceRect = new CoreGraphics.CGRect(sourceBounds.Width/2, -4, 0, 0), - ContentSize = ViewModel.LocalPlayback.IsEnabled ? - new CoreGraphics.CGSize(276, 176) : new CoreGraphics.CGSize(276, 96), - ArrowDirection = UIPopoverArrowDirection.Down - }; - - popover.View.AddSubview(VolumePopover); + var popover = new VolumePopoverViewController(VolumePopover, sourceButton, sourceBounds, size); if (sourceVc == null) sourceVc = this; @@ -289,4 +288,36 @@ public void ShowVolumePopover(UIButton sourceButton, UIViewController sourceVc = sourceVc.PresentViewController(popover, true, null); } } + + public class VolumePopoverViewController: UIViewController, IUIPopoverPresentationControllerDelegate + { + private UIView _volumeView; + + public VolumePopoverViewController(UIView view, UIButton sourceButton, CGRect sourceBounds, CGSize contentSize) + { + _volumeView = view; + + ModalPresentationStyle = UIModalPresentationStyle.Popover; + PopoverPresentationController.SourceItem = sourceButton.ImageView; + PopoverPresentationController.Delegate = this; + PopoverPresentationController.PermittedArrowDirections = UIPopoverArrowDirection.Down; + PopoverPresentationController.SourceRect = new CGRect(sourceBounds.Width / 2, -4, 0, 0); + + PreferredContentSize = contentSize; + } + + [Export("adaptivePresentationStyleForPresentationController:traitCollection:")] + public UIModalPresentationStyle GetAdaptivePresentationStyle(UIPresentationController controller, UITraitCollection traitCollection) + { + // Prevent popover from being adaptive fullscreen on phones + // (https://pspdfkit.com/blog/2022/presenting-popovers-on-iphone-with-swiftui/) + return UIModalPresentationStyle.None; + } + + public override void ViewDidLoad() + { + base.ViewDidLoad(); + View.AddSubview(_volumeView); + } + } } diff --git a/Sources/Stylophone.iOS/ViewControllers/PlaylistViewController.cs b/Sources/Stylophone.iOS/ViewControllers/PlaylistViewController.cs index d05e7858..3ddbfde3 100644 --- a/Sources/Stylophone.iOS/ViewControllers/PlaylistViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/PlaylistViewController.cs @@ -59,7 +59,8 @@ public override void AwakeFromNib() _settingsBtn = CreateSettingsButton(); - var trackDataSource = new TrackTableViewDataSource(TableView, ViewModel.Source, GetRowContextMenu, GetRowSwipeActions, true, OnScroll); + var trackDataSource = new TrackTableViewDataSource(TableView, ViewModel.Source, + GetRowContextMenu, GetRowSwipeActions, true, OnScroll, OnTap); TableView.DataSource = trackDataSource; TableView.Delegate = trackDataSource; @@ -95,6 +96,8 @@ public override void AwakeFromNib() ArtContainer.Layer.ShadowOpacity = 0.5F; ArtContainer.Layer.ShadowOffset = new CGSize(0, 0); ArtContainer.Layer.ShadowRadius = 4; + + NavigationItem.RightBarButtonItems = new UIBarButtonItem[] { EditButtonItem }; } public override void ViewWillDisappear(bool animated) @@ -103,17 +106,20 @@ public override void ViewWillDisappear(bool animated) ViewModel.Dispose(); } + private void OnTap(NSIndexPath indexPath) => + ViewModel.AddToQueueCommand.Execute(new List { ViewModel?.Source[indexPath.Row] }); + private void OnScroll(UIScrollView scrollView) { if (scrollView.ContentOffset.Y > 192) { Title = ViewModel?.Name; - NavigationItem.RightBarButtonItem = _settingsBtn; + NavigationItem.RightBarButtonItems = new UIBarButtonItem[] { _settingsBtn, EditButtonItem }; } else { Title = ""; - NavigationItem.RightBarButtonItem = null; + NavigationItem.RightBarButtonItems = new UIBarButtonItem[] { EditButtonItem }; } } diff --git a/Sources/Stylophone.iOS/ViewControllers/QueueViewController.cs b/Sources/Stylophone.iOS/ViewControllers/QueueViewController.cs index ec61bf55..e979284e 100644 --- a/Sources/Stylophone.iOS/ViewControllers/QueueViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/QueueViewController.cs @@ -11,7 +11,6 @@ using UIKit; using System.Collections.Generic; using System.ComponentModel; -using System.Threading.Tasks; namespace Stylophone.iOS.ViewControllers { @@ -39,7 +38,7 @@ public override void AwakeFromNib() private void OnLeavingBackground(object sender, EventArgs e) { if (_mpdService.IsConnected) - Task.Run(async () => await ViewModel.LoadInitialDataAsync()); + Task.Run(ViewModel.LoadInitialDataAsync); } public override void ViewDidLoad() @@ -52,15 +51,20 @@ public override void ViewDidLoad() Binder.Bind(EmptyView, "hidden", nameof(ViewModel.IsSourceEmpty), valueTransformer: negateBoolTransformer); - NavigationItem.RightBarButtonItem = CreateSettingsButton(); + NavigationItem.RightBarButtonItems = new UIBarButtonItem[] { CreateSettingsButton(), EditButtonItem }; - var trackDataSource = new TrackTableViewDataSource(TableView, ViewModel.Source, GetRowContextMenu, GetRowSwipeActions); + var trackDataSource = new TrackTableViewDataSource(TableView, ViewModel.Source, + GetRowContextMenu, GetRowSwipeActions, true, primaryAction:OnTap); TableView.DataSource = trackDataSource; TableView.Delegate = trackDataSource; + TableView.SelfSizingInvalidation = UITableViewSelfSizingInvalidation.EnabledIncludingConstraints; _mpdService.SongChanged += ScrollToPlayingSong; } + private void OnTap(NSIndexPath indexPath) => + ViewModel.PlayTrackCommand.Execute(new List { ViewModel?.Source[indexPath.Row] }); + private void UpdateListOnPlaylistVersionChange(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(ViewModel.PlaylistVersion)) @@ -74,21 +78,24 @@ private void ScrollToPlayingSong(object sender = null, SongChangedEventArgs e = // Scroll to currently playing song var playing = ViewModel.Source.Where(t => t.IsPlaying).FirstOrDefault(); - if (playing != null) - UIApplication.SharedApplication.BeginInvokeOnMainThread(() => + if (playing == null) + return; + + UIApplication.SharedApplication.BeginInvokeOnMainThread(() => + { + try + { + var indexPath = NSIndexPath.FromRowSection(ViewModel.Source.IndexOf(playing), 0); + var tableViewRows = TableView.NumberOfRowsInSection(0); + + if (tableViewRows >= indexPath.Row) + TableView.ScrollToRow(indexPath, UITableViewScrollPosition.Middle, true); + } + catch (Exception e) { - try - { - var indexPath = NSIndexPath.FromRowSection(ViewModel.Source.IndexOf(playing), 0); - var tableViewRows = TableView.NumberOfRowsInSection(0); - - if (tableViewRows >= indexPath.Row) - TableView.ScrollToRow(indexPath, UITableViewScrollPosition.Middle, true); - } catch (Exception e) - { - System.Diagnostics.Debug.WriteLine($"Error while scrolling to row: {e}"); - } - }); + System.Diagnostics.Debug.WriteLine($"Error while scrolling to row: {e}"); + } + }); } private UIMenu GetRowContextMenu(NSIndexPath indexPath) diff --git a/Sources/Stylophone.iOS/ViewControllers/SearchResultsViewController.cs b/Sources/Stylophone.iOS/ViewControllers/SearchResultsViewController.cs index 65e773bc..9034ea7a 100644 --- a/Sources/Stylophone.iOS/ViewControllers/SearchResultsViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/SearchResultsViewController.cs @@ -46,7 +46,8 @@ public override void ViewDidLoad() valueTransformer: negateBoolTransformer); - var trackDataSource = new TrackTableViewDataSource(TableView, ViewModel.Source, GetRowContextMenu, GetRowSwipeActions); + var trackDataSource = new TrackTableViewDataSource(TableView, ViewModel.Source, + GetRowContextMenu, GetRowSwipeActions, primaryAction: OnTap); TableView.DataSource = trackDataSource; TableView.Delegate = trackDataSource; @@ -58,6 +59,9 @@ public override void ViewDidLoad() SearchSegmentedControl.PrimaryActionTriggered += SearchSegmentedControl_PrimaryActionTriggered; } + private void OnTap(NSIndexPath indexPath) => + ViewModel.AddToQueueCommand.Execute(new List { ViewModel?.Source[indexPath.Row] }); + private void SearchSegmentedControl_PrimaryActionTriggered(object sender, EventArgs e) { switch (SearchSegmentedControl.SelectedSegment) diff --git a/Sources/Stylophone.iOS/ViewControllers/SubViews/TrackViewCell.cs b/Sources/Stylophone.iOS/ViewControllers/SubViews/TrackViewCell.cs index a3308e66..217a8083 100644 --- a/Sources/Stylophone.iOS/ViewControllers/SubViews/TrackViewCell.cs +++ b/Sources/Stylophone.iOS/ViewControllers/SubViews/TrackViewCell.cs @@ -35,7 +35,7 @@ public override void LayoutSubviews() internal void Configure(int row, TrackViewModel trackViewModel) { // Set background depending on the row number - BackgroundColor = (row % 2 == 0) ? UIColor.SystemGray6Color : UIColor.Clear; + BackgroundColor = (row % 2 == 0) ? UIColor.SystemGray6 : UIColor.Clear; // Bind trackData _trackViewModel = trackViewModel; diff --git a/Sources/Stylophone.iOS/ViewModels/ShellViewModel.cs b/Sources/Stylophone.iOS/ViewModels/ShellViewModel.cs index dd497c62..9e562345 100644 --- a/Sources/Stylophone.iOS/ViewModels/ShellViewModel.cs +++ b/Sources/Stylophone.iOS/ViewModels/ShellViewModel.cs @@ -97,7 +97,7 @@ protected override void Navigate(object itemInvoked) { if (itemInvoked is string s) { - _navigationService.Navigate(); + Navigate(typeof(SettingsViewModel)); return; } @@ -114,11 +114,21 @@ protected override void Navigate(object itemInvoked) // Playlist items navigate with their name as parameter if (pageType == typeof(PlaylistViewModel)) - _navigationService.Navigate(pageType, sidebarItem.Title); + Navigate(pageType, sidebarItem.Title); else - _navigationService.Navigate(pageType); + Navigate(pageType); } } } + + private void Navigate(Type viewModel, object parameter = null) + { + // On phones, since the sidebar is its own screen, + // we don't want to keep the root view controller(usually the queue) when navigating from the sidebar. + if (UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Phone) + ((NavigationService)_navigationService).NavigationController.SetViewControllers(new UIViewController[0], false); + + _navigationService.Navigate(viewModel, parameter); + } } } diff --git a/Sources/Stylophone.iOS/Views/AlbumDetail.storyboard b/Sources/Stylophone.iOS/Views/AlbumDetail.storyboard index 2b5092c5..122955b1 100644 --- a/Sources/Stylophone.iOS/Views/AlbumDetail.storyboard +++ b/Sources/Stylophone.iOS/Views/AlbumDetail.storyboard @@ -1,8 +1,8 @@ - - + + - + @@ -12,25 +12,25 @@ - - + + - + - + - + - + - - + @@ -163,10 +163,11 @@ - + + @@ -174,19 +175,17 @@ + - - + + - - - @@ -247,11 +246,11 @@ - + - + @@ -262,17 +261,17 @@ - + - + - + - - + + + + + + + + + + + @@ -357,9 +378,9 @@ - + - + diff --git a/Sources/Stylophone.iOS/Views/NowPlaying.storyboard b/Sources/Stylophone.iOS/Views/NowPlaying.storyboard index 03699eb8..0cf141e2 100644 --- a/Sources/Stylophone.iOS/Views/NowPlaying.storyboard +++ b/Sources/Stylophone.iOS/Views/NowPlaying.storyboard @@ -1,8 +1,10 @@ - - + + + + - + @@ -16,332 +18,366 @@ - - + + - + - + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - + + + - + + - + + - + - - + + - + - - - + + + - - - - - - - + + + + + + + @@ -383,20 +419,20 @@ - - + - + - + @@ -768,22 +807,19 @@ - + - - - - - - + + + + + + - - - - + diff --git a/Sources/Stylophone.iOS/Views/Playlist.storyboard b/Sources/Stylophone.iOS/Views/Playlist.storyboard index 4ead2beb..6a9ebd64 100644 --- a/Sources/Stylophone.iOS/Views/Playlist.storyboard +++ b/Sources/Stylophone.iOS/Views/Playlist.storyboard @@ -1,8 +1,8 @@ - - + + - + @@ -12,25 +12,25 @@ - - + + - + - + - + - + - - + + + @@ -157,11 +161,10 @@ - - - - + + + @@ -178,19 +181,15 @@ - - + + - - - - @@ -199,7 +198,6 @@ - @@ -219,7 +217,6 @@ - @@ -236,7 +233,7 @@ - + @@ -247,11 +244,11 @@ - + - + @@ -262,17 +259,17 @@ - + - + - + - - + + + + + + + + + + + @@ -359,16 +370,16 @@ - + - + - - + + diff --git a/Sources/Stylophone.iOS/Views/Queue.storyboard b/Sources/Stylophone.iOS/Views/Queue.storyboard index ed9a2041..42036a5e 100644 --- a/Sources/Stylophone.iOS/Views/Queue.storyboard +++ b/Sources/Stylophone.iOS/Views/Queue.storyboard @@ -1,8 +1,8 @@ - - + + - + @@ -12,21 +12,21 @@ - + - + - + - - + + - + - + - - + + + + + + + + + + + @@ -149,8 +161,8 @@ - - + + diff --git a/Sources/Stylophone.iOS/Views/SearchResults.storyboard b/Sources/Stylophone.iOS/Views/SearchResults.storyboard index fe48b876..098df16e 100644 --- a/Sources/Stylophone.iOS/Views/SearchResults.storyboard +++ b/Sources/Stylophone.iOS/Views/SearchResults.storyboard @@ -1,8 +1,8 @@ - + - + @@ -37,14 +37,14 @@ - + - + @@ -107,7 +107,7 @@ - + @@ -127,7 +127,7 @@ - + @@ -147,7 +147,9 @@