diff --git a/android/app/src/main/res/drawable-xxxhdpi/quick_action_download.png b/android/app/src/main/res/drawable-xxxhdpi/quick_action_download.png new file mode 100644 index 00000000..4aa41b9a Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/quick_action_download.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/quick_action_favorites.png b/android/app/src/main/res/drawable-xxxhdpi/quick_action_favorites.png new file mode 100644 index 00000000..b42e7c49 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/quick_action_favorites.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/quick_action_recent.png b/android/app/src/main/res/drawable-xxxhdpi/quick_action_recent.png new file mode 100644 index 00000000..344c1c14 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/quick_action_recent.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/quick_action_search.png b/android/app/src/main/res/drawable-xxxhdpi/quick_action_search.png new file mode 100644 index 00000000..5ad86b97 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/quick_action_search.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_download.imageset/Contents.json b/ios/Runner/Assets.xcassets/quick_action_download.imageset/Contents.json new file mode 100644 index 00000000..af3e9ab5 --- /dev/null +++ b/ios/Runner/Assets.xcassets/quick_action_download.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon@3x.png" + } + ], + "info": { + "version": 1, + "author": "xcode" + }, + "properties": { + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/quick_action_download.imageset/icon.png b/ios/Runner/Assets.xcassets/quick_action_download.imageset/icon.png new file mode 100644 index 00000000..9866dead Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_download.imageset/icon.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_download.imageset/icon@2x.png b/ios/Runner/Assets.xcassets/quick_action_download.imageset/icon@2x.png new file mode 100644 index 00000000..0534d044 Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_download.imageset/icon@2x.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_download.imageset/icon@3x.png b/ios/Runner/Assets.xcassets/quick_action_download.imageset/icon@3x.png new file mode 100644 index 00000000..3488946c Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_download.imageset/icon@3x.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/Contents.json b/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/Contents.json new file mode 100644 index 00000000..af3e9ab5 --- /dev/null +++ b/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon@3x.png" + } + ], + "info": { + "version": 1, + "author": "xcode" + }, + "properties": { + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/icon.png b/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/icon.png new file mode 100644 index 00000000..735e5ea6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/icon.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/icon@2x.png b/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/icon@2x.png new file mode 100644 index 00000000..fa67c15d Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/icon@2x.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/icon@3x.png b/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/icon@3x.png new file mode 100644 index 00000000..917f59da Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_favorites.imageset/icon@3x.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_recent.imageset/Contents.json b/ios/Runner/Assets.xcassets/quick_action_recent.imageset/Contents.json new file mode 100644 index 00000000..af3e9ab5 --- /dev/null +++ b/ios/Runner/Assets.xcassets/quick_action_recent.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon@3x.png" + } + ], + "info": { + "version": 1, + "author": "xcode" + }, + "properties": { + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/quick_action_recent.imageset/icon.png b/ios/Runner/Assets.xcassets/quick_action_recent.imageset/icon.png new file mode 100644 index 00000000..d85c4878 Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_recent.imageset/icon.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_recent.imageset/icon@2x.png b/ios/Runner/Assets.xcassets/quick_action_recent.imageset/icon@2x.png new file mode 100644 index 00000000..4dd4aec6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_recent.imageset/icon@2x.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_recent.imageset/icon@3x.png b/ios/Runner/Assets.xcassets/quick_action_recent.imageset/icon@3x.png new file mode 100644 index 00000000..8726d44c Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_recent.imageset/icon@3x.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_search.imageset/Contents.json b/ios/Runner/Assets.xcassets/quick_action_search.imageset/Contents.json new file mode 100644 index 00000000..af3e9ab5 --- /dev/null +++ b/ios/Runner/Assets.xcassets/quick_action_search.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "icon.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "icon@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "icon@3x.png" + } + ], + "info": { + "version": 1, + "author": "xcode" + }, + "properties": { + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/quick_action_search.imageset/icon.png b/ios/Runner/Assets.xcassets/quick_action_search.imageset/icon.png new file mode 100644 index 00000000..ae6c162f Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_search.imageset/icon.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_search.imageset/icon@2x.png b/ios/Runner/Assets.xcassets/quick_action_search.imageset/icon@2x.png new file mode 100644 index 00000000..2ec2cca2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_search.imageset/icon@2x.png differ diff --git a/ios/Runner/Assets.xcassets/quick_action_search.imageset/icon@3x.png b/ios/Runner/Assets.xcassets/quick_action_search.imageset/icon@3x.png new file mode 100644 index 00000000..c525ed79 Binary files /dev/null and b/ios/Runner/Assets.xcassets/quick_action_search.imageset/icon@3x.png differ diff --git a/lib/main.dart b/lib/main.dart index 81747797..65c2ab70 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:app/audio_handler.dart'; import 'package:app/providers/providers.dart'; import 'package:app/ui/app.dart'; +import 'package:app/utils/quick_actions.dart'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:get_storage/get_storage.dart'; @@ -10,6 +11,7 @@ import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; late KoelAudioHandler audioHandler; +final quickActions = KoelQuickActions(); List _providers = [ Provider(create: (_) => AuthProvider()), @@ -104,6 +106,10 @@ Future main() async { await GetStorage.init('Preferences'); await GetStorage.init(DownloadProvider.serializedPlayableContainer); + // Register early so a cold launch via a shortcut is captured and buffered + // until the main screen is ready to act on it. + quickActions.initialize(); + runApp( MultiProvider( providers: _providers, diff --git a/lib/ui/screens/main.dart b/lib/ui/screens/main.dart index 30bf3419..08628b5d 100644 --- a/lib/ui/screens/main.dart +++ b/lib/ui/screens/main.dart @@ -5,10 +5,13 @@ import 'package:app/constants/constants.dart'; import 'package:app/enums.dart'; import 'package:app/main.dart'; import 'package:app/mixins/stream_subscriber.dart'; +import 'package:app/models/models.dart'; import 'package:app/providers/providers.dart'; import 'package:app/ui/screens/screens.dart'; import 'package:app/ui/widgets/widgets.dart'; +import 'package:app/utils/quick_actions.dart'; import 'package:app/utils/route_state.dart'; +import 'package:audio_service/audio_service.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -23,8 +26,9 @@ class MainScreen extends StatefulWidget { _MainScreenState createState() => _MainScreenState(); } -class _MainScreenState extends State { +class _MainScreenState extends State with StreamSubscriber { static const tabBarHeight = 60.0; + static const _searchTabIndex = 1; late int _selectedIndex; var _isOffline = AppState.get('mode', AppMode.online) == AppMode.offline; @@ -68,7 +72,90 @@ class _MainScreenState extends State { context.read().scheduleSync(); - WidgetsBinding.instance.addPostFrameCallback((_) => _restoreRoutes()); + _setUpQuickActions(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _restoreRoutes(); + + final pendingAction = quickActions.consumePendingAction(); + if (pendingAction != null) _handleQuickAction(pendingAction); + }); + } + + @override + void dispose() { + unsubscribeAll(); + super.dispose(); + } + + void _setUpQuickActions() { + void refreshShortcuts(MediaItem? item) { + quickActions.setShortcuts( + recentSubtitle: KoelQuickActions.recentSubtitle( + artist: item?.artist, + title: item?.title, + ), + ); + } + + refreshShortcuts(audioHandler.mediaItem.value); + subscribe(audioHandler.mediaItem.listen(refreshShortcuts)); + subscribe(quickActions.actions.listen(_handleQuickAction)); + } + + Future _handleQuickAction(String type) async { + switch (type) { + case KoelQuickActions.search: + _gotoSearchAndFocus(); + break; + case KoelQuickActions.playFavorites: + final favoriteProvider = context.read(); + await _shufflePlay(() => favoriteProvider.fetch()); + break; + case KoelQuickActions.playDownloaded: + final downloadProvider = context.read(); + await _shufflePlay(() async => downloadProvider.playables); + break; + case KoelQuickActions.playRecent: + await _resumeQueue(); + break; + } + } + + void _gotoSearchAndFocus() { + if (_selectedIndex != _searchTabIndex) { + setState(() => _selectedIndex = _searchTabIndex); + RouteState.setTabIndex(_searchTabIndex); + } + quickActions.requestSearchFocus(); + } + + Future _shufflePlay( + Future> Function() loadPlayables) async { + try { + final playables = await loadPlayables(); + if (playables.isNotEmpty) { + await audioHandler.replaceQueue(playables, shuffle: true); + } + } catch (_) { + // A quick action fails quietly (e.g. a favourites fetch while offline). + } + } + + Future _resumeQueue() async { + try { + // On a cold launch the persisted queue is restored asynchronously, so + // wait briefly for it before resuming. + if (audioHandler.queue.value.isEmpty) { + await audioHandler.queue + .firstWhere((items) => items.isNotEmpty) + .timeout(const Duration(seconds: 5), onTimeout: () => []); + } + + if (audioHandler.queue.value.isNotEmpty) await audioHandler.play(); + } catch (_) { + // A quick action fails quietly. + } } void _restoreRoutes() { diff --git a/lib/ui/screens/search.dart b/lib/ui/screens/search.dart index a71e3b29..22e87562 100644 --- a/lib/ui/screens/search.dart +++ b/lib/ui/screens/search.dart @@ -1,4 +1,6 @@ import 'package:app/constants/constants.dart'; +import 'package:app/main.dart'; +import 'package:app/mixins/stream_subscriber.dart'; import 'package:app/models/models.dart'; import 'package:app/providers/providers.dart'; import 'package:app/ui/widgets/widgets.dart'; @@ -17,7 +19,7 @@ class SearchScreen extends StatefulWidget { _SearchScreenState createState() => _SearchScreenState(); } -class _SearchScreenState extends State { +class _SearchScreenState extends State with StreamSubscriber { var _hasFocus = false; var _initial = true; var _playables = []; @@ -38,6 +40,27 @@ class _SearchScreenState extends State { _focusNode.addListener(() { setState(() => _hasFocus = _focusNode.hasFocus); }); + + // Focus the field when reached via the "Search" quick action, whether this + // screen was already alive or is being built as a result of the action. + if (quickActions.consumePendingSearchFocus()) _focusSearchField(); + subscribe( + quickActions.searchFocusRequests.listen((_) => _focusSearchField()), + ); + } + + void _focusSearchField() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + unsubscribeAll(); + _focusNode.dispose(); + _controller.dispose(); + super.dispose(); } _search(String keywords) => diff --git a/lib/utils/quick_actions.dart b/lib/utils/quick_actions.dart new file mode 100644 index 00000000..f27d5c5c --- /dev/null +++ b/lib/utils/quick_actions.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:quick_actions/quick_actions.dart'; + +/// Wraps the home-screen quick actions (long-press the app icon) and relays +/// them to the app. Actions received before a listener is ready (e.g. a cold +/// launch opened via a shortcut) are buffered and consumed once the app is up. +class KoelQuickActions { + static const search = 'koel_action_search'; + static const playFavorites = 'koel_action_play_favorites'; + static const playDownloaded = 'koel_action_play_downloaded'; + static const playRecent = 'koel_action_play_recent'; + + final QuickActions _quickActions; + final _actions = StreamController.broadcast(); + final _searchFocusRequests = StreamController.broadcast(); + + String? _pendingAction; + var _searchFocusPending = false; + + KoelQuickActions({QuickActions quickActions = const QuickActions()}) + : _quickActions = quickActions; + + /// Emits a shortcut type each time the user triggers a quick action while the + /// app is running. + Stream get actions => _actions.stream; + + /// Emits whenever the Search field should grab focus. + Stream get searchFocusRequests => _searchFocusRequests.stream; + + void initialize() { + _quickActions.initialize((type) { + _pendingAction = type; + _actions.add(type); + }); + } + + /// Returns and clears an action received before a listener attached (a cold + /// launch via a shortcut), or null if there was none. + String? consumePendingAction() { + final action = _pendingAction; + _pendingAction = null; + return action; + } + + void requestSearchFocus() { + _searchFocusPending = true; + _searchFocusRequests.add(null); + } + + /// True once if a focus request arrived before the Search screen was built. + bool consumePendingSearchFocus() { + final pending = _searchFocusPending; + _searchFocusPending = false; + return pending; + } + + Future setShortcuts({String? recentSubtitle}) { + return _quickActions + .setShortcutItems(shortcutItems(recentSubtitle: recentSubtitle)); + } + + /// The shortcut items to expose. "Play Most Recent" is only offered when + /// there is a recent track to describe. + static List shortcutItems({String? recentSubtitle}) { + return [ + const ShortcutItem( + type: search, + localizedTitle: 'Search', + icon: 'quick_action_search', + ), + const ShortcutItem( + type: playFavorites, + localizedTitle: 'Play Favorite Songs', + icon: 'quick_action_favorites', + ), + const ShortcutItem( + type: playDownloaded, + localizedTitle: 'Play Downloaded', + icon: 'quick_action_download', + ), + if (recentSubtitle != null && recentSubtitle.isNotEmpty) + ShortcutItem( + type: playRecent, + localizedTitle: 'Play Most Recent', + localizedSubtitle: recentSubtitle, + icon: 'quick_action_recent', + ), + ]; + } + + /// Formats a "Artist - Title" label for the most-recent track, or just the + /// title when there's no artist. + static String? recentSubtitle({String? artist, String? title}) { + if (title == null || title.isEmpty) return null; + return (artist == null || artist.isEmpty) ? title : '$artist - $title'; + } + + void dispose() { + _actions.close(); + _searchFocusRequests.close(); + } +} diff --git a/pubspec.lock b/pubspec.lock index ef4278ec..e2133c33 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -737,6 +737,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + quick_actions: + dependency: "direct main" + description: + name: quick_actions + sha256: "7e35dd6a21f5bbd21acf6899039eaf85001a5ac26d52cbd6a8a2814505b90798" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + quick_actions_android: + dependency: transitive + description: + name: quick_actions_android + sha256: "6fbdda87fbedd0602b91f35d870c2cbcb6d053bc863efb76c6e7f60f1d8e3fd6" + url: "https://pub.dev" + source: hosted + version: "1.0.30" + quick_actions_ios: + dependency: transitive + description: + name: quick_actions_ios + sha256: be1496e7ca1debc86d9ea08e56325649fbc5abb2b6930690c97ba0dae59992b1 + url: "https://pub.dev" + source: hosted + version: "1.2.4" + quick_actions_platform_interface: + dependency: transitive + description: + name: quick_actions_platform_interface + sha256: "1fec7068db5122cd019e9340d3d7be5d36eab099695ef3402c7059ee058329a4" + url: "https://pub.dev" + source: hosted + version: "1.1.0" rxdart: dependency: "direct dev" description: @@ -1071,5 +1103,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index a4686a1c..cc21f1af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: faker: any ulid: ^2.0.1 lucide_icons_flutter: ^3.1.14+2 + quick_actions: ^1.1.0 dev_dependencies: fake_async: ^1.2.0 mockito: ^5.0.12 diff --git a/test/utils/quick_actions_test.dart b/test/utils/quick_actions_test.dart new file mode 100644 index 00000000..64e3e45c --- /dev/null +++ b/test/utils/quick_actions_test.dart @@ -0,0 +1,127 @@ +import 'package:app/utils/quick_actions.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:quick_actions_platform_interface/quick_actions_platform_interface.dart'; + +class FakeQuickActionsPlatform extends QuickActionsPlatform + with MockPlatformInterfaceMixin { + QuickActionHandler? handler; + List items = []; + + @override + Future initialize(QuickActionHandler handler) async { + this.handler = handler; + } + + @override + Future setShortcutItems(List items) async { + this.items = items; + } + + @override + Future clearShortcutItems() async { + items = []; + } +} + +void main() { + late FakeQuickActionsPlatform platform; + late KoelQuickActions actions; + + setUp(() { + platform = FakeQuickActionsPlatform(); + QuickActionsPlatform.instance = platform; + actions = KoelQuickActions(); + }); + + tearDown(() => actions.dispose()); + + group('shortcutItems', () { + test('offers Search, Favorites and Downloaded without a recent track', () { + final items = KoelQuickActions.shortcutItems(); + + expect( + items.map((item) => item.type), + [ + KoelQuickActions.search, + KoelQuickActions.playFavorites, + KoelQuickActions.playDownloaded, + ], + ); + expect( + items.map((item) => item.icon), + [ + 'quick_action_search', + 'quick_action_favorites', + 'quick_action_download', + ], + ); + }); + + test('adds Play Most Recent with a subtitle when a track is known', () { + final items = KoelQuickActions.shortcutItems(recentSubtitle: 'Skid Row - 18 and Life'); + + expect(items, hasLength(4)); + expect(items.last.type, KoelQuickActions.playRecent); + expect(items.last.localizedTitle, 'Play Most Recent'); + expect(items.last.localizedSubtitle, 'Skid Row - 18 and Life'); + }); + }); + + group('recentSubtitle', () { + test('joins artist and title', () { + expect( + KoelQuickActions.recentSubtitle(artist: 'Skid Row', title: '18 and Life'), + 'Skid Row - 18 and Life', + ); + }); + + test('falls back to the title when there is no artist', () { + expect(KoelQuickActions.recentSubtitle(artist: '', title: 'Untitled'), + 'Untitled'); + expect(KoelQuickActions.recentSubtitle(title: 'Untitled'), 'Untitled'); + }); + + test('is null without a title', () { + expect(KoelQuickActions.recentSubtitle(artist: 'X', title: null), isNull); + expect(KoelQuickActions.recentSubtitle(artist: 'X', title: ''), isNull); + }); + }); + + test('setShortcuts forwards the built items to the platform', () async { + await actions.setShortcuts(recentSubtitle: 'Skid Row - 18 and Life'); + + expect(platform.items.map((item) => item.type), [ + KoelQuickActions.search, + KoelQuickActions.playFavorites, + KoelQuickActions.playDownloaded, + KoelQuickActions.playRecent, + ]); + }); + + test('buffers an action received before a listener and streams it', () async { + actions.initialize(); + expect(platform.handler, isNotNull); + + final streamed = expectLater( + actions.actions, + emits(KoelQuickActions.playFavorites), + ); + platform.handler!(KoelQuickActions.playFavorites); + await streamed; + + expect(actions.consumePendingAction(), KoelQuickActions.playFavorites); + expect(actions.consumePendingAction(), isNull); + }); + + test('tracks a pending search-focus request', () async { + expect(actions.consumePendingSearchFocus(), isFalse); + + final focused = expectLater(actions.searchFocusRequests, emits(null)); + actions.requestSearchFocus(); + await focused; + + expect(actions.consumePendingSearchFocus(), isTrue); + expect(actions.consumePendingSearchFocus(), isFalse); + }); +}