diff --git a/README.md b/README.md index 5ed3642..6e1cebe 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Stylophone =========== -[**Music Player Daemon**](https://www.musicpd.org/) Client for UWP. +[**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 @@ -15,7 +15,8 @@ Based on [MpcNET](https://github.com/Difegue/MpcNET), my own fork of the origina * Full playback control * Playlist management (Create, Add/Remove tracks, Delete) * Picture-in-picture mode -* Live tile +* Live tile on Windows 10 +* Integration with native playback controls * Browse library by albums, or directly by folders * All data is pulled from your MPD Server only * Support for both albumart and readpicture commands for maximum compatibility with your cover art library @@ -53,17 +54,30 @@ You can easily contribute translations to Stylophone! To help translate, follow ## Screenshots -![Screen1](Screenshots/Screen1.jpg) -![Screen2](Screenshots/Screen2.jpg) -![Screen3](Screenshots/Screen3.jpg) -![Screen4](Screenshots/Screen4.jpg) -![Screen5](Screenshots/Screen5.jpg) -![Screen6](Screenshots/Screen6.jpg) +|Queue, UWP | Queue, iOS | +|---|---| +| ![queue_win](Screenshots/Screen1.jpg) | ![queue_ios](Screenshots/Screen5.jpg) | -## Privacy Policy +|Library, UWP | Library, iOS | +|---|---| +| ![library_win](Screenshots/Screen2.jpg) | ![library_ios](Screenshots/Screen6.jpg) | -Stylophone collects no data from your computer by default. -The Windows Store version can send anonymized error reports related to crashes of the application back to me. +|Playlist, UWP | Playlist, iOS | +|---|---| +| ![playlist_win](Screenshots/Screen3.jpg)| ![playlist_ios](Screenshots/Screen7.jpg) | + +|Now Playing, UWP | Now Playing, iOS | +|---|---| +| ![nowplaying_win](Screenshots/Screen4.jpg)| ![nowplaying_ios](Screenshots/Screen8.jpg) | + +|Xbox Integration | iOS Control Center Integration | +|---|---| +| ![xbox](Screenshots/ScreenXbox.jpg)| ![controlcenter](Screenshots/ScreenNowPlaying.jpg) | -If you enable Telemetry in the app's settings, the application will send detailed crash reports using App Center. +## Privacy Policy + +If Telemetry is enabled in the app's settings, the application will send detailed crash reports using [App Center](https://appcenter.ms). Those reports can contain information about your hardware. (Motherboard type, etc) + +Stylophone collects no other data from your device. +The Windows Store version can send anonymized error reports related to crashes of the application back to me. diff --git a/Screenshots/Screen3.jpg b/Screenshots/Screen3.jpg index a8d807a..0695086 100644 Binary files a/Screenshots/Screen3.jpg and b/Screenshots/Screen3.jpg differ diff --git a/Screenshots/Screen4.jpg b/Screenshots/Screen4.jpg index 0695086..c604847 100644 Binary files a/Screenshots/Screen4.jpg and b/Screenshots/Screen4.jpg differ diff --git a/Screenshots/Screen5.jpg b/Screenshots/Screen5.jpg index fbe02c1..c1078ef 100644 Binary files a/Screenshots/Screen5.jpg and b/Screenshots/Screen5.jpg differ diff --git a/Screenshots/Screen6.jpg b/Screenshots/Screen6.jpg index c604847..71e6cbb 100644 Binary files a/Screenshots/Screen6.jpg and b/Screenshots/Screen6.jpg differ diff --git a/Screenshots/Screen7.jpg b/Screenshots/Screen7.jpg new file mode 100644 index 0000000..eed3966 Binary files /dev/null and b/Screenshots/Screen7.jpg differ diff --git a/Screenshots/Screen8.jpg b/Screenshots/Screen8.jpg new file mode 100644 index 0000000..2ff2fcd Binary files /dev/null and b/Screenshots/Screen8.jpg differ diff --git a/Screenshots/ScreenNowPlaying.jpg b/Screenshots/ScreenNowPlaying.jpg new file mode 100644 index 0000000..28d9639 Binary files /dev/null and b/Screenshots/ScreenNowPlaying.jpg differ diff --git a/Sources/Stylophone.Common/Services/AlbumArtService.cs b/Sources/Stylophone.Common/Services/AlbumArtService.cs index 414348d..25e9df9 100644 --- a/Sources/Stylophone.Common/Services/AlbumArtService.cs +++ b/Sources/Stylophone.Common/Services/AlbumArtService.cs @@ -191,6 +191,9 @@ private async Task GetAlbumBitmap(IMpdFile f, CancellationToken token } catch (Exception e) { + if (token.IsCancellationRequested) + return null; + Debug.WriteLine("Exception caught while getting albumart: " + e); _notificationService.ShowErrorNotification(e); return null; diff --git a/Sources/Stylophone.Common/Services/MPDConnectionService.cs b/Sources/Stylophone.Common/Services/MPDConnectionService.cs index 26b6d01..4d0acc2 100644 --- a/Sources/Stylophone.Common/Services/MPDConnectionService.cs +++ b/Sources/Stylophone.Common/Services/MPDConnectionService.cs @@ -11,6 +11,11 @@ using Stylophone.Common.Interfaces; using MpcNET.Commands.Reflection; using Stylophone.Localization.Strings; +using CommunityToolkit.Mvvm.DependencyInjection; +using Stylophone.Common.ViewModels; +using Microsoft.AppCenter.Analytics; +using Microsoft.AppCenter; +using System.Drawing; namespace Stylophone.Common.Services { @@ -19,7 +24,7 @@ public class SongChangedEventArgs : EventArgs { public int NewSongId { get; set; public class MPDConnectionService { private const int ConnectionPoolSize = 5; - public static MpdStatus BOGUS_STATUS = new MpdStatus(0, false, false, false, false, -1, -1, -1, MpdState.Unknown, -1, -1, -1, -1, TimeSpan.Zero, TimeSpan.Zero, -1, -1, -1, -1, -1, "", ""); + public static MpdStatus BOGUS_STATUS = new MpdStatus(-1, false, false, false, false, -1, -1, -1, MpdState.Unknown, -1, -1, -1, -1, TimeSpan.Zero, TimeSpan.Zero, -1, -1, -1, -1, -1, "", ""); private INotificationService _notificationService; @@ -70,6 +75,7 @@ public void SetServerInfo(string host, int port, string pass) public async Task InitializeAsync(bool withRetry = false) { IsConnecting = true; + CurrentStatus = BOGUS_STATUS; // Reset status if (IsConnected) { @@ -89,6 +95,8 @@ public async Task InitializeAsync(bool withRetry = false) { System.Diagnostics.Debug.WriteLine($"Error while connecting: {e.Message}"); + ConnectionChanged?.Invoke(this, new EventArgs()); + if (withRetry && !cancelToken.IsCancellationRequested) { // The RetryAttempter will call TryConnect() in five seconds. @@ -100,7 +108,6 @@ public async Task InitializeAsync(bool withRetry = false) } IsConnecting = false; - ConnectionChanged?.Invoke(this, new EventArgs()); } private void ClearResources() @@ -135,7 +142,6 @@ private async Task TryConnecting(CancellationToken token) _mpdEndpoint = new IPEndPoint(ipAddress, _port); - _idleConnection = await GetConnectionInternalAsync(token); _statusConnection = await GetConnectionInternalAsync(token); ConnectionPool = new ObjectPool>(ConnectionPoolSize, @@ -153,11 +159,14 @@ private async Task TryConnecting(CancellationToken token) // Connected, initialize basic data Version = _statusConnection.Version; + + _idleConnection = await GetConnectionInternalAsync(_cancelIdle.Token); await UpdatePlaylistsAsync(); InitializeStatusUpdater(_cancelIdle.Token); - ConnectionChanged?.Invoke(this, new EventArgs()); + IsConnecting = false; IsConnected = true; + ConnectionChanged?.Invoke(this, new EventArgs()); } /// @@ -176,8 +185,11 @@ public async Task> GetConnectionAsync(Cancell /// Return type of the command /// IMpcCommand to send /// The command results, or default value. - public async Task SafelySendCommandAsync(IMpcCommand command) + public async Task SafelySendCommandAsync(IMpcCommand command, bool showError = true) { + if (!IsConnected) + return default(T); + try { using (var c = await GetConnectionAsync()) @@ -198,8 +210,23 @@ public async Task SafelySendCommandAsync(IMpcCommand command) } catch (Exception e) { - _notificationService.ShowInAppNotification(string.Format(Resources.ErrorSendingMPDCommand, command.GetType().Name), - e.Message, NotificationType.Error); + System.Diagnostics.Debug.WriteLine($"MPD Error: {e.Message}"); + + if (showError) + _notificationService.ShowInAppNotification(string.Format(Resources.ErrorSendingMPDCommand, command.GetType().Name), + e.Message, NotificationType.Error); + +#if DEBUG +#else + var enableAnalytics = Ioc.Default.GetRequiredService().GetValue(nameof(SettingsViewModel.EnableAnalytics), true); + if (enableAnalytics) + { + var dict = new Dictionary(); + dict.Add("command", command.Serialize()); + dict.Add("exception", e.ToString()); + Analytics.TrackEvent("MPDError", dict); + } +#endif } return default(T); @@ -226,16 +253,21 @@ private async Task GetConnectionInternalAsync(CancellationToken t private void InitializeStatusUpdater(CancellationToken token = default) { - // Update status every second - _statusUpdater = new System.Timers.Timer(1000); - _statusUpdater.Elapsed += async (s, e) => await UpdateStatusAsync(_statusConnection); - _statusUpdater.Start(); - // Run an idle loop in a spare thread to fire events when needed Task.Run(async () => { while (true) { + if (_statusUpdater?.Enabled != true && _statusConnection.IsConnected) + { + // Update status every second + _statusUpdater?.Stop(); + _statusUpdater?.Dispose(); + _statusUpdater = new System.Timers.Timer(1000); + _statusUpdater.Elapsed += async (s, e) => await UpdateStatusAsync(_statusConnection); + _statusUpdater.Start(); + } + try { if (token.IsCancellationRequested || _idleConnection == null || !_idleConnection.IsConnected) @@ -250,11 +282,15 @@ private void InitializeStatusUpdater(CancellationToken token = default) } catch (Exception e) { - System.Diagnostics.Debug.WriteLine($"Error in Idle connection thread: {e.Message}"); - await InitializeAsync(true); + if (token.IsCancellationRequested) + System.Diagnostics.Debug.WriteLine($"Idle connection cancelled."); + else + { + System.Diagnostics.Debug.WriteLine($"Error in Idle connection thread: {e.Message}"); + await InitializeAsync(true); + } } } - }).ConfigureAwait(false); } diff --git a/Sources/Stylophone.Common/Stylophone.Common.csproj b/Sources/Stylophone.Common/Stylophone.Common.csproj index 4504847..84d56ce 100644 --- a/Sources/Stylophone.Common/Stylophone.Common.csproj +++ b/Sources/Stylophone.Common/Stylophone.Common.csproj @@ -14,10 +14,14 @@ + + + + diff --git a/Sources/Stylophone.Common/ViewModels/Bases/PlaybackViewModelBase.cs b/Sources/Stylophone.Common/ViewModels/Bases/PlaybackViewModelBase.cs index 357a3d9..d717b56 100644 --- a/Sources/Stylophone.Common/ViewModels/Bases/PlaybackViewModelBase.cs +++ b/Sources/Stylophone.Common/ViewModels/Bases/PlaybackViewModelBase.cs @@ -12,6 +12,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.NetworkInformation; using System.Threading; using System.Threading.Tasks; @@ -69,14 +70,14 @@ public PlaybackViewModelBase(INavigationService navigationService, INotification _mpdService.ConnectionChanged += OnConnectionChanged; if (_mpdService.IsConnected) - Task.Run(() => InitializeAsync()); + Initialize(); } private void OnConnectionChanged(object sender, EventArgs e) { if (_mpdService.IsConnected) { - Task.Run(() => InitializeAsync()); + Initialize(); } else { @@ -84,19 +85,23 @@ private void OnConnectionChanged(object sender, EventArgs e) } } - private async Task InitializeAsync() + private void Initialize() { - OnTrackChange(this, new SongChangedEventArgs { NewSongId = _mpdService.CurrentStatus.SongId }); + OnTrackChange(this, new SongChangedEventArgs { NewSongId = -1 }); CurrentTimeValue = _mpdService.CurrentStatus.Elapsed.TotalSeconds; OnStateChange(this, null); - await UpdateUpNextAsync(_mpdService.CurrentStatus); } #region Getters and Setters public bool HasNextTrack => NextTrack != null; + /// + /// "(deprecated: -1 if the volume cannot be determined)" + /// + public bool CanSetVolume => _mpdService.CurrentStatus.Volume != -1; + /// /// The current playing track /// @@ -171,7 +176,7 @@ public double MediaVolume set { // HACK: Abort if mpdService doesn't have updated volume from the server yet - if (_mpdService.CurrentStatus == MPDConnectionService.BOGUS_STATUS) + if (_mpdService.CurrentStatus == MPDConnectionService.BOGUS_STATUS || !CanSetVolume) return; SetProperty(ref _internalVolume, value); @@ -188,12 +193,14 @@ public double MediaVolume volumeTasks.Add(Task.Run(async () => { await _mpdService.SafelySendCommandAsync(new SetVolumeCommand((byte)value)); - Thread.Sleep(1000); // Wait for MPD to acknowledge the new volume in its status... - MediaVolume = _mpdService.CurrentStatus.Volume; // Update the value to the current server volume + Thread.Sleep(500); // Wait for MPD to acknowledge the new volume in its status... + + if (volumeTasks.Count == 1) + MediaVolume = _mpdService.CurrentStatus.Volume; // Update the value to the current server volume }, cts.Token)); // Update the UI - if ((int)value == 0) + if ((int)value <= 0) { VolumeIcon = _interop.GetIcon(PlaybackIcon.VolumeMute); } @@ -273,19 +280,12 @@ partial void OnIsSingleEnabledChanged(bool value) protected async void UpdateInformation(object sender, EventArgs e) { var status = _mpdService.CurrentStatus; + OnPropertyChanged(nameof(CanSetVolume)); // Only call the following if the player exists and the time is greater then 0. if (status.Elapsed.TotalMilliseconds <= 0) return; - // Update song in case we went out of sync - if (status.SongId != CurrentTrack?.File?.Id) - { - OnTrackChange(this, new SongChangedEventArgs { NewSongId = status.SongId }); - OnStateChange(this, null); - await UpdateUpNextAsync(_mpdService.CurrentStatus); - } - if (!HasNextTrack) await UpdateUpNextAsync(status); @@ -356,7 +356,8 @@ public void ToggleRepeat() { await _mpdService.SafelySendCommandAsync(new RepeatCommand(IsRepeatEnabled)); await _mpdService.SafelySendCommandAsync(new SingleCommand(IsSingleEnabled)); - Thread.Sleep(1000); // Wait for MPD to acknowledge the new status... + Thread.Sleep(500); // Wait for MPD to acknowledge the new status... + await UpdateUpNextAsync(_mpdService.CurrentStatus); }, cts.Token)); } @@ -376,7 +377,7 @@ public void ToggleShuffle() stateTasks.Add(Task.Run(async () => { await _mpdService.SafelySendCommandAsync(new RandomCommand(IsShuffleEnabled)); - Thread.Sleep(1000); // Wait for MPD to acknowledge the new status... + Thread.Sleep(500); // Wait for MPD to acknowledge the new status... await UpdateUpNextAsync(_mpdService.CurrentStatus); }, cts.Token)); } @@ -396,7 +397,7 @@ public void ToggleConsume() stateTasks.Add(Task.Run(async () => { await _mpdService.SafelySendCommandAsync(new ConsumeCommand(IsConsumeEnabled)); - Thread.Sleep(1000); // Wait for MPD to acknowledge the new status... + Thread.Sleep(500); // Wait for MPD to acknowledge the new status... }, cts.Token)); } @@ -431,9 +432,15 @@ public void NavigateNowPlaying() /// /// Toggles the state between the track playing - /// and not playing + /// and not playing. If playback is fully stopped, this will play index 0. /// - public void ChangePlaybackState() => _ = _mpdService.SafelySendCommandAsync(new PauseResumeCommand()); + public void ChangePlaybackState() + { + if (_mpdService.CurrentStatus.State == MpdState.Stop) + _mpdService.SafelySendCommandAsync(new PlayCommand(0)); + else + _mpdService.SafelySendCommandAsync(new PauseResumeCommand()); + } /// /// Go forward one track @@ -490,7 +497,8 @@ private async Task UpdateUpNextAsync(MpdStatus status) var nextSongId = status.NextSongId; if (nextSongId != -1) { - var response = await _mpdService.SafelySendCommandAsync(new PlaylistIdCommand(nextSongId)); + // Don't show errors if we can't get the next track due to an old status or something, it's fairly minor + var response = await _mpdService.SafelySendCommandAsync(new PlaylistIdCommand(nextSongId), false); if (response != null) { @@ -576,6 +584,10 @@ private void OnStateChange(object sender, EventArgs eventArgs) // Ditto for shuffle/repeat/single if (stateTasks.Count == 0) { + if (status.Random != IsShuffleEnabled || IsRepeatEnabled != status.Repeat || IsSingleEnabled != status.Single) + { + UpdateUpNextAsync(status); + } IsShuffleEnabled = status.Random; IsRepeatEnabled = status.Repeat; IsSingleEnabled = status.Single; diff --git a/Sources/Stylophone.Common/ViewModels/QueueViewModel.cs b/Sources/Stylophone.Common/ViewModels/QueueViewModel.cs index cfe7d4a..53ca64a 100644 --- a/Sources/Stylophone.Common/ViewModels/QueueViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/QueueViewModel.cs @@ -193,7 +193,6 @@ private async void MPDConnectionService_QueueChanged(object sender, EventArgs e) { _ = Task.Run(async () => { - var response = await _mpdService.SafelySendCommandAsync(new PlChangesCommand(PlaylistVersion)); if (response != null) @@ -213,9 +212,11 @@ private async void MPDConnectionService_QueueChanged(object sender, EventArgs e) while (Source.Count != initialPosition) { - // Make sure - if (Source.Count != initialPosition) - await _dispatcherService.ExecuteOnUIThreadAsync(() => Source.RemoveAt(initialPosition)); + await _dispatcherService.ExecuteOnUIThreadAsync(() => { + // Make sure + if (Source.Count != initialPosition) + Source.RemoveAt(initialPosition); + }); } var toAdd = new List(); @@ -240,6 +241,8 @@ private async void MPDConnectionService_QueueChanged(object sender, EventArgs e) public async Task LoadInitialDataAsync() { + Source.CollectionChanged -= Source_CollectionChanged; + var tracks = new List(); var response = await _mpdService.SafelySendCommandAsync(new PlaylistInfoCommand()); diff --git a/Sources/Stylophone.Common/ViewModels/SettingsViewModel.cs b/Sources/Stylophone.Common/ViewModels/SettingsViewModel.cs index d1ed921..34d07aa 100644 --- a/Sources/Stylophone.Common/ViewModels/SettingsViewModel.cs +++ b/Sources/Stylophone.Common/ViewModels/SettingsViewModel.cs @@ -65,6 +65,7 @@ public SettingsViewModel(MPDConnectionService mpdService, IApplicationStorageSer [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsServerValid))] + [NotifyPropertyChangedFor(nameof(ServerStatus))] private bool _isCheckingServer; [ObservableProperty] @@ -118,7 +119,7 @@ partial void OnEnableAnalyticsChanged(bool value) } public bool IsServerValid => _mpdService.IsConnected; - public string ServerStatus => IsServerValid ? ServerInfo?.Split('\n')?.First() + (IsStreamingAvailable ? ", "+ Resources.SettingsLocalPlaybackAvailable : "") : + public string ServerStatus => IsCheckingServer ? "..." : IsServerValid ? ServerInfo?.Split('\n')?.First() + (IsStreamingAvailable ? ", "+ Resources.SettingsLocalPlaybackAvailable : "") : Resources.SettingsNoServerError; partial void OnIsLocalPlaybackEnabledChanged(bool value) @@ -205,9 +206,11 @@ private string GetVersionDescription() var appName = Resources.AppDisplayName; Version version = _interop.GetAppVersion(); - return $"{version.Major}.{version.Minor}.{(version.Build > -1 ? version.Build : 0)}"; + return $"{version.Major}.{version.Minor}.{(version.Revision > -1 ? version.Revision : 0)}"; } + public void RetryConnection() => TriggerServerConnection(ServerHost, ServerPort, ServerPassword); + private void TriggerServerConnection(string host, int port, string pass) { IsCheckingServer = true; @@ -252,7 +255,7 @@ private async Task UpdateServerVersionAsync() if (!IsStreamingAvailable) IsLocalPlaybackEnabled = false; - } + } else { ServerInfo = $"MPD Protocol {_mpdService.Version}\n" + diff --git a/Sources/Stylophone.iOS/AppDelegate.cs b/Sources/Stylophone.iOS/AppDelegate.cs index 407af79..1d4d465 100644 --- a/Sources/Stylophone.iOS/AppDelegate.cs +++ b/Sources/Stylophone.iOS/AppDelegate.cs @@ -15,6 +15,7 @@ using Microsoft.AppCenter.Crashes; using System.Threading; using AVFoundation; +using System.Collections.Generic; namespace Stylophone.iOS { @@ -28,6 +29,9 @@ public class AppDelegate : UIResponder, IUIApplicationDelegate [Export("window")] public UIWindow Window { get; set; } + public event EventHandler ApplicationWillResign; + public event EventHandler ApplicationWillBecomeActive; + public UISplitViewController RootViewController { get; set; } public UIColor AppColor => UIColor.FromDynamicProvider((traitCollection) => @@ -66,6 +70,18 @@ public bool FinishedLaunching(UIApplication application, NSDictionary launchOpti return true; } + [Export("applicationWillResignActive:")] + public void OnResignActivation(UIApplication application) + { + ApplicationWillResign?.Invoke(this, EventArgs.Empty); + } + + [Export("applicationDidBecomeActive:")] + public void OnActivated(UIApplication application) + { + ApplicationWillBecomeActive?.Invoke(this, EventArgs.Empty); + } + private async Task InitializeApplicationAsync() { var storageService = Ioc.Default.GetRequiredService(); @@ -112,6 +128,12 @@ await Ioc.Default.GetRequiredService().ExecuteOnUIThreadAsyn // Initialize AppCenter AppCenter.Start("90b62f5a-2448-4ef1-81ca-3fb807a5b126", typeof(Analytics), typeof(Crashes)); + + AppDomain.CurrentDomain.UnhandledException += (sender, args) => { + var dict = new Dictionary(); + dict.Add("exception", args.ExceptionObject.ToString()); + Analytics.TrackEvent("UnhandledCrash", dict); + }; } #endif } diff --git a/Sources/Stylophone.iOS/Entitlements.plist b/Sources/Stylophone.iOS/Entitlements.plist index 36a8706..0c67376 100644 --- a/Sources/Stylophone.iOS/Entitlements.plist +++ b/Sources/Stylophone.iOS/Entitlements.plist @@ -1,6 +1,5 @@ - - + diff --git a/Sources/Stylophone.iOS/Info.plist b/Sources/Stylophone.iOS/Info.plist index 805f027..a804213 100644 --- a/Sources/Stylophone.iOS/Info.plist +++ b/Sources/Stylophone.iOS/Info.plist @@ -2,16 +2,12 @@ + CFBundleDevelopmentRegion + en CFBundleDisplayName Stylophone CFBundleIdentifier - com.tvc-16.Stylophone - CFBundleShortVersionString - 2.5 - CFBundleVersion - 2.5 - CFBundleDevelopmentRegion - en + com.tvc16.stylophone CFBundleLocalizations en @@ -19,6 +15,10 @@ pt zh + CFBundleShortVersionString + 2.5.4 + CFBundleVersion + 2.5.4 LSRequiresIPhoneOS MinimumOSVersion @@ -45,6 +45,7 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight diff --git a/Sources/Stylophone.iOS/Services/DialogService.cs b/Sources/Stylophone.iOS/Services/DialogService.cs index 78a276e..aad8b33 100644 --- a/Sources/Stylophone.iOS/Services/DialogService.cs +++ b/Sources/Stylophone.iOS/Services/DialogService.cs @@ -7,6 +7,7 @@ using Strings = Stylophone.Localization.Strings.Resources; using UIKit; using StoreKit; +using Stylophone.Common.ViewModels; namespace Stylophone.iOS.Services { @@ -15,12 +16,14 @@ public class DialogService : IDialogService private IDispatcherService _dispatcherService; private IApplicationStorageService _storageService; private IInteropService _interop; + private INavigationService _navigationService; private MPDConnectionService _mpdService; - public DialogService(IDispatcherService dispatcherService, IApplicationStorageService storageService, IInteropService interop, MPDConnectionService mpdService) + public DialogService(IDispatcherService dispatcherService, IApplicationStorageService storageService, IInteropService interop, INavigationService navigationService, MPDConnectionService mpdService) { _dispatcherService = dispatcherService; _storageService = storageService; + _navigationService = navigationService; _mpdService = mpdService; _interop = interop; } @@ -59,6 +62,7 @@ await _dispatcherService.ExecuteOnUIThreadAsync(async () => _storageService.SetValue("HasLaunchedOnce", true); await ShowConfirmDialogAsync(Strings.FirstRunTitle, Strings.FirstRunText, Strings.OKButtonText); + _navigationService.Navigate(); } }); } diff --git a/Sources/Stylophone.iOS/Services/NotificationService.cs b/Sources/Stylophone.iOS/Services/NotificationService.cs index ae2592e..819567c 100644 --- a/Sources/Stylophone.iOS/Services/NotificationService.cs +++ b/Sources/Stylophone.iOS/Services/NotificationService.cs @@ -19,6 +19,9 @@ public override void ShowInAppNotification(InAppNotification notification) { UIApplication.SharedApplication.InvokeOnMainThread(() => { + if (UIApplication.SharedApplication.ApplicationState != UIApplicationState.Active) + return; + RMessageType type = notification.NotificationType switch { NotificationType.Info => RMessageType.Normal, diff --git a/Sources/Stylophone.iOS/Stylophone.iOS.csproj b/Sources/Stylophone.iOS/Stylophone.iOS.csproj index b3e6584..87fcb18 100644 --- a/Sources/Stylophone.iOS/Stylophone.iOS.csproj +++ b/Sources/Stylophone.iOS/Stylophone.iOS.csproj @@ -39,6 +39,9 @@ None x86_64 9.0 + Automatique + iPhone Developer + true true @@ -66,6 +69,7 @@ iPhone Developer SdkOnly 9.0 + true diff --git a/Sources/Stylophone.iOS/ViewControllers/LibraryViewController.cs b/Sources/Stylophone.iOS/ViewControllers/LibraryViewController.cs index af83f99..fd1ef81 100644 --- a/Sources/Stylophone.iOS/ViewControllers/LibraryViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/LibraryViewController.cs @@ -64,6 +64,14 @@ private async Task InitializeLibraryAsync() CollectionView.PrefetchingEnabled = true; UpdateDataSource(); + + // Start a timer to load visible items every sec -- a bit jank but eh + var timer = NSTimer.CreateRepeatingScheduledTimer(TimeSpan.FromSeconds(1), (NSTimer obj) => + { + // Make sure we load the displayed items -- We don't specify IndexPaths here, the method picks up visible items automatically + CollectionView.PrefetchDataSource.PrefetchItems(CollectionView, new NSIndexPath[] { }); + }); + NSRunLoop.Current.AddTimer(timer, NSRunLoopMode.Common); } private void UpdateDataSource() @@ -74,9 +82,6 @@ private void UpdateDataSource() snapshot.AppendItems(items); _dataSource.ApplySnapshot(snapshot, new NSString("base"), true); - - // Make sure we load the displayed items - CollectionView.PrefetchDataSource.PrefetchItems(CollectionView, new NSIndexPath[] { }); } private UICollectionViewCell GetAlbumViewCell(UICollectionView collectionView, NSIndexPath indexPath, NSObject identifier) @@ -89,12 +94,6 @@ private UICollectionViewCell GetAlbumViewCell(UICollectionView collectionView, N return cell; } - public override void Scrolled(UIScrollView scrollView) - { - var visibleIndexes = CollectionView.IndexPathsForVisibleItems; - - } - public override void ItemSelected(UICollectionView collectionView, NSIndexPath indexPath) { var holder = _dataSource.GetItemIdentifier(indexPath); @@ -149,7 +148,7 @@ public CGSize GetSizeForItem(UICollectionView collectionView, UICollectionViewLa if (referenceSize.Width < 600) size = new CGSize(148, 148); - if (referenceSize.Width < 350) + if (referenceSize.Width < 350 || referenceSize.Width == 490) size = new CGSize(120, 120); return size; diff --git a/Sources/Stylophone.iOS/ViewControllers/PlaybackViewController.cs b/Sources/Stylophone.iOS/ViewControllers/PlaybackViewController.cs index 2c8db93..662b91a 100644 --- a/Sources/Stylophone.iOS/ViewControllers/PlaybackViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/PlaybackViewController.cs @@ -73,6 +73,7 @@ public override void AwakeFromNib() 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); } diff --git a/Sources/Stylophone.iOS/ViewControllers/QueueViewController.cs b/Sources/Stylophone.iOS/ViewControllers/QueueViewController.cs index 188d7a9..ec61bf5 100644 --- a/Sources/Stylophone.iOS/ViewControllers/QueueViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/QueueViewController.cs @@ -11,6 +11,7 @@ using UIKit; using System.Collections.Generic; using System.ComponentModel; +using System.Threading.Tasks; namespace Stylophone.iOS.ViewControllers { @@ -31,6 +32,14 @@ public override void AwakeFromNib() base.AwakeFromNib(); ViewModel.PropertyChanged += UpdateListOnPlaylistVersionChange; + + (UIApplication.SharedApplication.Delegate as AppDelegate).ApplicationWillBecomeActive += OnLeavingBackground; + } + + private void OnLeavingBackground(object sender, EventArgs e) + { + if (_mpdService.IsConnected) + Task.Run(async () => await ViewModel.LoadInitialDataAsync()); } public override void ViewDidLoad() @@ -64,11 +73,22 @@ 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(() => - TableView.ScrollToRow(NSIndexPath.FromRowSection(ViewModel.Source.IndexOf(playing), 0), - UITableViewScrollPosition.Middle, true)); + { + 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}"); + } + }); } private UIMenu GetRowContextMenu(NSIndexPath indexPath) diff --git a/Sources/Stylophone.iOS/ViewControllers/SettingsViewController.cs b/Sources/Stylophone.iOS/ViewControllers/SettingsViewController.cs index 557957d..b81da0e 100644 --- a/Sources/Stylophone.iOS/ViewControllers/SettingsViewController.cs +++ b/Sources/Stylophone.iOS/ViewControllers/SettingsViewController.cs @@ -60,6 +60,11 @@ public override void ViewDidLoad() { base.ViewDidLoad(); + // Trick iOS into showing us the local network access prompt, + // So that the user can accept/decline it before we actually try to connect to MPD servers. + // cf. https://stackoverflow.com/questions/63940427/ios-14-how-to-trigger-local-network-dialog-and-check-user-answer + var hostName = NSProcessInfo.ProcessInfo.HostName; + // Value Transformers var negateBoolTransformer = NSValueTransformer.GetValueTransformer(nameof(ReverseBoolValueTransformer)); var intToStringTransformer = NSValueTransformer.GetValueTransformer(nameof(IntToStringValueTransformer)); @@ -93,6 +98,20 @@ public override void ViewDidLoad() GithubButton.PrimaryActionTriggered += (s, e) => UIApplication.SharedApplication.OpenUrl(new NSUrl(Resources.SettingsGithubLink)); + ServerHostnameField.PrimaryActionTriggered += (s, e) => { + ((UITextField)s).ResignFirstResponder(); + ViewModel.RetryConnection(); + }; + ServerPortField.PrimaryActionTriggered += (s, e) => + { + ((UITextField)s).ResignFirstResponder(); + ViewModel.RetryConnection(); + }; + ServerPasswordField.PrimaryActionTriggered += (s, e) => { + ((UITextField)s).ResignFirstResponder(); + ViewModel.RetryConnection(); + }; + } } diff --git a/Sources/Stylophone.iOS/ViewControllers/SubViews/CompactPlaybackView.cs b/Sources/Stylophone.iOS/ViewControllers/SubViews/CompactPlaybackView.cs index 0d2b27f..47bf673 100644 --- a/Sources/Stylophone.iOS/ViewControllers/SubViews/CompactPlaybackView.cs +++ b/Sources/Stylophone.iOS/ViewControllers/SubViews/CompactPlaybackView.cs @@ -36,7 +36,18 @@ public override void AwakeFromNib() Layer.ShadowOffset = new CGSize(0, 0); Layer.ShadowRadius = 4; + // Add shadow to albumart + ShadowCaster.Layer.MasksToBounds = false; + ShadowCaster.Layer.CornerRadius = 8; + ShadowCaster.Layer.ShadowColor = UIColor.Black.CGColor; + ShadowCaster.Layer.ShadowOpacity = 0.5F; + ShadowCaster.Layer.ShadowOffset = new CGSize(0, 0); + ShadowCaster.Layer.ShadowRadius = 4; + AlbumArt.Layer.CornerRadius = 8; + + // Fallback default + TrackTitle.Text = Localization.Strings.Resources.NotificationNoTrackPlaying; } public override void LayoutSubviews() diff --git a/Sources/Stylophone.iOS/ViewControllers/SubViews/CompactPlaybackView.designer.cs b/Sources/Stylophone.iOS/ViewControllers/SubViews/CompactPlaybackView.designer.cs index ed458e1..6b2ee3b 100644 --- a/Sources/Stylophone.iOS/ViewControllers/SubViews/CompactPlaybackView.designer.cs +++ b/Sources/Stylophone.iOS/ViewControllers/SubViews/CompactPlaybackView.designer.cs @@ -42,6 +42,9 @@ partial class CompactPlaybackView [Outlet] public UIKit.UIButton PrevButton { get; private set; } + [Outlet] + UIKit.UIView ShadowCaster { get; set; } + [Outlet] public UIKit.UIButton ShuffleButton { get; private set; } @@ -117,6 +120,11 @@ void ReleaseDesignerOutlets () VolumeButton.Dispose (); VolumeButton = null; } + + if (ShadowCaster != null) { + ShadowCaster.Dispose (); + ShadowCaster = null; + } } } } diff --git a/Sources/Stylophone.iOS/ViewModels/PlaybackViewModel.cs b/Sources/Stylophone.iOS/ViewModels/PlaybackViewModel.cs index 0a8be8f..e5e42f4 100644 --- a/Sources/Stylophone.iOS/ViewModels/PlaybackViewModel.cs +++ b/Sources/Stylophone.iOS/ViewModels/PlaybackViewModel.cs @@ -17,12 +17,14 @@ public class PlaybackViewModel : PlaybackViewModelBase public PlaybackViewModel(INavigationService navigationService, INotificationService notificationService, IDispatcherService dispatcherService, IInteropService interop, MPDConnectionService mpdService, TrackViewModelFactory trackVmFactory, LocalPlaybackViewModel localPlayback) : base(navigationService, notificationService, dispatcherService, interop, mpdService, trackVmFactory, localPlayback) { - //Application.Current.LeavingBackground += CurrentOnLeavingBackground; + (UIApplication.SharedApplication.Delegate as AppDelegate).ApplicationWillBecomeActive += OnLeavingBackground; - ((NavigationService)_navigationService).Navigated += (s, e) => - _dispatcherService.ExecuteOnUIThreadAsync(() => { - //ShowTrackName = _navigationService.CurrentPageViewModelType != typeof(PlaybackViewModelBase); - }); + } + + private void OnLeavingBackground(object sender, EventArgs e) + { + // Refresh all + UpdateInformation(sender, null); } public override Task SwitchToCompactViewAsync(EventArgs obj) diff --git a/Sources/Stylophone.iOS/Views/NowPlaying.storyboard b/Sources/Stylophone.iOS/Views/NowPlaying.storyboard index 0efd25b..03699eb 100644 --- a/Sources/Stylophone.iOS/Views/NowPlaying.storyboard +++ b/Sources/Stylophone.iOS/Views/NowPlaying.storyboard @@ -21,42 +21,42 @@ - + - + - + - + - + - -