diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3baa511..67595b9 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -922,4 +922,4 @@ /* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index cc36b20..d55e260 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,8 @@ import 'package:finale/util/image_id_cache.dart'; import 'package:finale/util/notifications.dart' as notifications; import 'package:finale/util/preference.dart'; import 'package:finale/util/preferences.dart'; -import 'package:finale/util/quick_actions_manager.dart'; +import 'package:finale/util/quick_actions_manager.dart' + as quick_actions_manager; import 'package:finale/util/theme.dart'; import 'package:finale/widgets/entity/lastfm/profile_stack.dart'; import 'package:finale/widgets/main/login_view.dart'; @@ -17,7 +18,7 @@ Future main() async { await Preference.setup(); if (isMobile) { - await QuickActionsManager().setup(); + await quick_actions_manager.setup(); await background_task_manager.setup(); await notifications.setup(); } diff --git a/lib/util/external_actions.dart b/lib/util/external_actions.dart new file mode 100644 index 0000000..cf477cd --- /dev/null +++ b/lib/util/external_actions.dart @@ -0,0 +1,61 @@ +import 'package:finale/services/generic.dart'; +import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; + +import 'profile_tab.dart'; +import 'time_safe_stream.dart'; + +/// An action taken outside of the app that causes the app to open. +/// +/// Sources are quick actions, iOS widgets, and notifications. +class ExternalAction { + final ExternalActionType type; + final dynamic value; + + ExternalAction.scrobbleOnce() + : type = ExternalActionType.scrobbleOnce, + value = null; + + ExternalAction.scrobbleContinuously() + : type = ExternalActionType.scrobbleContinuously, + value = null; + + ExternalAction.viewAlbum(BasicAlbum album) + : type = ExternalActionType.viewAlbum, + value = album; + + ExternalAction.viewArtist(BasicArtist artist) + : type = ExternalActionType.viewArtist, + value = artist; + + ExternalAction.viewTrack(Track track) + : type = ExternalActionType.viewTrack, + value = track; + + ExternalAction.viewTab(ProfileTab tab) + : type = ExternalActionType.viewTab, + value = tab; + + ExternalAction.openSpotifyChecker() + : type = ExternalActionType.openSpotifyChecker, + value = null; +} + +enum ExternalActionType { + scrobbleOnce, + scrobbleContinuously, + viewAlbum, + viewArtist, + viewTrack, + viewTab, + openSpotifyChecker, +} + +// This stream needs to be open for the entire lifetime of the app. +// ignore: close_sinks +@protected +final externalActions = ReplaySubject>(); + +/// A stream of [ExternalAction]s that should be handled. +Stream get externalActionsStream => + externalActions.timeSafeStream(); diff --git a/lib/util/notifications.dart b/lib/util/notifications.dart index f033216..a8aa4da 100644 --- a/lib/util/notifications.dart +++ b/lib/util/notifications.dart @@ -1,27 +1,22 @@ +import 'package:finale/util/external_actions.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:universal_io/io.dart'; import 'time_safe_stream.dart'; enum NotificationType { spotifyCheckerOutOfSync( - 'Spotify Checker', 'Spotify is not sending scrobbles to Last.fm!'); + 'Spotify Checker', + 'Spotify is not sending scrobbles to Last.fm!', + ExternalAction.openSpotifyChecker); final String title; final String body; + final ExternalAction Function() externalActionFactory; - const NotificationType(this.title, this.body); + const NotificationType(this.title, this.body, this.externalActionFactory); } -// This stream needs to be open for the entire lifetime of the app. -// ignore: close_sinks -final _notifications = ReplaySubject>(); - -/// A stream of notifications that should be handled. -Stream get notificationsStream => - _notifications.timeSafeStream(); - Future setup() async { const initializationSettings = InitializationSettings( android: AndroidInitializationSettings('@drawable/music_note'), @@ -69,5 +64,6 @@ Future showNotification(NotificationType type) async { @pragma('vm:entry-point') void didReceiveNotification(NotificationResponse details) { - _notifications.addTimestamped(NotificationType.values[details.id!]); + externalActions.addTimestamped( + NotificationType.values[details.id!].externalActionFactory()); } diff --git a/lib/util/quick_actions_manager.dart b/lib/util/quick_actions_manager.dart index 30edc5c..a85ed19 100644 --- a/lib/util/quick_actions_manager.dart +++ b/lib/util/quick_actions_manager.dart @@ -1,136 +1,86 @@ +/// Handles [QuickActions] and initial links by firing the corresponding +/// [ExternalAction]s. + import 'package:finale/services/generic.dart'; -import 'package:finale/util/profile_tab.dart'; +import 'package:finale/util/external_actions.dart'; import 'package:quick_actions/quick_actions.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:uni_links/uni_links.dart'; +import 'profile_tab.dart'; import 'time_safe_stream.dart'; -class QuickAction { - final QuickActionType type; - final dynamic value; - - QuickAction.scrobbleOnce() - : type = QuickActionType.scrobbleOnce, - value = null; - - QuickAction.scrobbleContinuously() - : type = QuickActionType.scrobbleContinuously, - value = null; - - QuickAction.viewAlbum(BasicAlbum album) - : type = QuickActionType.viewAlbum, - value = album; - - QuickAction.viewArtist(BasicArtist artist) - : type = QuickActionType.viewArtist, - value = artist; - - QuickAction.viewTrack(Track track) - : type = QuickActionType.viewTrack, - value = track; - - QuickAction.viewTab(ProfileTab tab) - : type = QuickActionType.viewTab, - value = tab; -} +Future setup() async { + const quickActions = QuickActions(); + await quickActions.initialize((type) { + _handleLink(Uri(host: type)); + }); + await quickActions.setShortcutItems(const [ + ShortcutItem( + type: 'scrobbleonce', + localizedTitle: 'Recognize song', + icon: 'add', + ), + ShortcutItem( + type: 'scrobblecontinuously', + localizedTitle: 'Recognize continuously', + icon: 'all_inclusive', + ), + ]); + + try { + final initialUri = await getInitialUri(); + _handleLink(initialUri); + } on FormatException { + // Do nothing. + } -enum QuickActionType { - scrobbleOnce, - scrobbleContinuously, - viewAlbum, - viewArtist, - viewTrack, - viewTab, + uriLinkStream.listen((uri) { + _handleLink(uri); + }); } -class QuickActionsManager { - static QuickActionsManager? _instance; - - factory QuickActionsManager() => _instance ??= QuickActionsManager._(); - - QuickActionsManager._(); - - // This stream needs to be open for the entire lifetime of the app. - // ignore: close_sinks - final _quickActions = ReplaySubject>(); - - /// A stream of [QuickAction]s that should be handled. - Stream get quickActionStream => _quickActions.timeSafeStream(); - - Future setup() async { - const quickActions = QuickActions(); - await quickActions.initialize((type) { - _handleLink(Uri(host: type)); - }); - await quickActions.setShortcutItems(const [ - ShortcutItem( - type: 'scrobbleonce', - localizedTitle: 'Recognize song', - icon: 'add', - ), - ShortcutItem( - type: 'scrobblecontinuously', - localizedTitle: 'Recognize continuously', - icon: 'all_inclusive', - ), - ]); - - try { - final initialUri = await getInitialUri(); - _handleLink(initialUri); - } on FormatException { - // Do nothing. +void _handleLink(Uri? uri) { + if (uri == null) { + return; + } else if (uri.host == 'scrobbleonce') { + externalActions.addTimestamped(ExternalAction.scrobbleOnce()); + } else if (uri.host == 'scrobblecontinuously') { + externalActions.addTimestamped(ExternalAction.scrobbleContinuously()); + } else if (uri.host == 'album') { + final name = uri.queryParameters['name']!; + final artist = uri.queryParameters['artist']!; + externalActions.addTimestamped(ExternalAction.viewAlbum( + ConcreteBasicAlbum(name, ConcreteBasicArtist(artist)))); + } else if (uri.host == 'artist') { + final name = uri.queryParameters['name']!; + externalActions + .addTimestamped(ExternalAction.viewArtist(ConcreteBasicArtist(name))); + } else if (uri.host == 'track') { + final name = uri.queryParameters['name']!; + final artist = uri.queryParameters['artist']!; + externalActions.addTimestamped( + ExternalAction.viewTrack(BasicConcreteTrack(name, artist, null))); + } else if (uri.host == 'profiletab') { + final tabString = uri.queryParameters['tab']; + ProfileTab tab; + + switch (tabString) { + case 'scrobble': + tab = ProfileTab.recentScrobbles; + break; + case 'artist': + tab = ProfileTab.topArtists; + break; + case 'album': + tab = ProfileTab.topAlbums; + break; + case 'track': + tab = ProfileTab.topTracks; + break; + default: + throw ArgumentError.value(tabString, 'tab', 'Unknown tab'); } - uriLinkStream.listen((uri) { - _handleLink(uri); - }); - } - - void _handleLink(Uri? uri) { - if (uri == null) { - return; - } else if (uri.host == 'scrobbleonce') { - _quickActions.addTimestamped(QuickAction.scrobbleOnce()); - } else if (uri.host == 'scrobblecontinuously') { - _quickActions.addTimestamped(QuickAction.scrobbleContinuously()); - } else if (uri.host == 'album') { - final name = uri.queryParameters['name']!; - final artist = uri.queryParameters['artist']!; - _quickActions.addTimestamped(QuickAction.viewAlbum( - ConcreteBasicAlbum(name, ConcreteBasicArtist(artist)))); - } else if (uri.host == 'artist') { - final name = uri.queryParameters['name']!; - _quickActions - .addTimestamped(QuickAction.viewArtist(ConcreteBasicArtist(name))); - } else if (uri.host == 'track') { - final name = uri.queryParameters['name']!; - final artist = uri.queryParameters['artist']!; - _quickActions.addTimestamped( - QuickAction.viewTrack(BasicConcreteTrack(name, artist, null))); - } else if (uri.host == 'profiletab') { - final tabString = uri.queryParameters['tab']; - ProfileTab tab; - - switch (tabString) { - case 'scrobble': - tab = ProfileTab.recentScrobbles; - break; - case 'artist': - tab = ProfileTab.topArtists; - break; - case 'album': - tab = ProfileTab.topAlbums; - break; - case 'track': - tab = ProfileTab.topTracks; - break; - default: - throw ArgumentError.value(tabString, 'tab', 'Unknown tab'); - } - - _quickActions.addTimestamped(QuickAction.viewTab(tab)); - } + externalActions.addTimestamped(ExternalAction.viewTab(tab)); } } diff --git a/lib/widgets/main/main_view.dart b/lib/widgets/main/main_view.dart index d4d9eed..e0ddbfa 100644 --- a/lib/widgets/main/main_view.dart +++ b/lib/widgets/main/main_view.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:finale/services/generic.dart'; import 'package:finale/util/constants.dart'; -import 'package:finale/util/quick_actions_manager.dart'; +import 'package:finale/util/external_actions.dart'; import 'package:finale/widgets/entity/lastfm/album_view.dart'; import 'package:finale/widgets/entity/lastfm/artist_view.dart'; import 'package:finale/widgets/entity/lastfm/track_view.dart'; @@ -28,32 +28,32 @@ class _MainViewState extends State { @override void initState() { super.initState(); - _subscription = QuickActionsManager().quickActionStream.listen((action) { - if (action.type == QuickActionType.scrobbleOnce || - action.type == QuickActionType.scrobbleContinuously) { + _subscription = externalActionsStream.listen((action) { + if (action.type == ExternalActionType.scrobbleOnce || + action.type == ExternalActionType.scrobbleContinuously) { setState(() { Navigator.popUntil(context, (route) => route.isFirst); _index = 2; }); - } else if (action.type == QuickActionType.viewAlbum) { + } else if (action.type == ExternalActionType.viewAlbum) { Navigator.push( context, MaterialPageRoute( builder: (_) => AlbumView(album: action.value as BasicAlbum)), ); - } else if (action.type == QuickActionType.viewArtist) { + } else if (action.type == ExternalActionType.viewArtist) { Navigator.push( context, MaterialPageRoute( builder: (_) => ArtistView(artist: action.value as BasicArtist)), ); - } else if (action.type == QuickActionType.viewTrack) { + } else if (action.type == ExternalActionType.viewTrack) { Navigator.push( context, MaterialPageRoute( builder: (_) => TrackView(track: action.value as Track)), ); - } else if (action.type == QuickActionType.viewTab) { + } else if (action.type == ExternalActionType.viewTab) { setState(() { Navigator.popUntil(context, (route) => route.isFirst); _index = 0; diff --git a/lib/widgets/profile/profile_view.dart b/lib/widgets/profile/profile_view.dart index 7ebd567..1c6241f 100644 --- a/lib/widgets/profile/profile_view.dart +++ b/lib/widgets/profile/profile_view.dart @@ -6,10 +6,9 @@ import 'package:finale/services/lastfm/lastfm.dart'; import 'package:finale/services/lastfm/track.dart'; import 'package:finale/services/lastfm/user.dart'; import 'package:finale/util/constants.dart'; -import 'package:finale/util/notifications.dart' as notifications; +import 'package:finale/util/external_actions.dart'; import 'package:finale/util/preferences.dart'; import 'package:finale/util/profile_tab.dart'; -import 'package:finale/util/quick_actions_manager.dart'; import 'package:finale/widgets/base/app_bar.dart'; import 'package:finale/widgets/base/future_builder_view.dart'; import 'package:finale/widgets/base/now_playing_animation.dart'; @@ -48,8 +47,7 @@ class _ProfileViewState extends State final _recentScrobblesKey = GlobalKey(); late final StreamSubscription _profileTabsOrderSubscription; - StreamSubscription? _quickActionsSubscription; - StreamSubscription? _notificationsSubscription; + StreamSubscription? _externalActionsSubscription; late ProfileStack _profileStack; @@ -79,10 +77,10 @@ class _ProfileViewState extends State _createTabController(); if (widget.isTab) { - _quickActionsSubscription = - QuickActionsManager().quickActionStream.listen((action) async { + _externalActionsSubscription = + externalActionsStream.listen((action) async { await Future.delayed(const Duration(milliseconds: 250)); - if (action.type == QuickActionType.viewTab) { + if (action.type == ExternalActionType.viewTab) { final tab = action.value as ProfileTab; final index = _tabOrder.indexOf(tab); @@ -91,13 +89,7 @@ class _ProfileViewState extends State _tabController!.index = index; }); } - } - }); - - _notificationsSubscription = - notifications.notificationsStream.listen((notification) { - if (notification == - notifications.NotificationType.spotifyCheckerOutOfSync) { + } else if (action.type == ExternalActionType.openSpotifyChecker) { launchUrl(Lastfm.applicationSettingsUri); } }); @@ -269,8 +261,7 @@ class _ProfileViewState extends State void dispose() { _tabController?.dispose(); _profileTabsOrderSubscription.cancel(); - _quickActionsSubscription?.cancel(); - _notificationsSubscription?.cancel(); + _externalActionsSubscription?.cancel(); WidgetsBinding.instance.removeObserver(this); _profileStack.pop(); super.dispose(); diff --git a/lib/widgets/scrobble/music_recognition_component.dart b/lib/widgets/scrobble/music_recognition_component.dart index ac02301..16d7a4c 100644 --- a/lib/widgets/scrobble/music_recognition_component.dart +++ b/lib/widgets/scrobble/music_recognition_component.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:finale/env.dart'; import 'package:finale/util/constants.dart'; -import 'package:finale/util/quick_actions_manager.dart'; +import 'package:finale/util/external_actions.dart'; import 'package:finale/widgets/base/titled_box.dart'; import 'package:finale/widgets/scrobble/acrcloud_dialog.dart'; import 'package:finale/widgets/scrobble/listen_continuously_view.dart'; @@ -27,12 +27,11 @@ class _MusicRecognitionComponentState extends State { void initState() { super.initState(); - _subscription = - QuickActionsManager().quickActionStream.listen((action) async { + _subscription = externalActionsStream.listen((action) async { await Future.delayed(const Duration(milliseconds: 250)); - if (action.type == QuickActionType.scrobbleOnce) { + if (action.type == ExternalActionType.scrobbleOnce) { _scrobbleOnce(); - } else if (action.type == QuickActionType.scrobbleContinuously) { + } else if (action.type == ExternalActionType.scrobbleContinuously) { _scrobbleContinuously(); } });