diff --git a/android/app/build.gradle b/android/app/build.gradle index 357625727..2dd697dbd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -69,6 +69,9 @@ android { signingConfig = signingConfigs.release } } + buildFeatures { + viewBinding true + } } flutter { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 878c83b9c..cdc0b38cc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -72,6 +72,18 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 157eaa82e..addead05b 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6c7bd4e4a..aaa411f2d 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index 45e78390f..7a0e8593e 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -1,4 +1,5 @@ @android:color/white + #C3232323 diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 000000000..0bb60c137 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml new file mode 100644 index 000000000..d8a7d0994 --- /dev/null +++ b/android/app/src/main/res/values/attrs.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 67655744c..b6f1e21f6 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,5 +1,6 @@ + #7C4DFF @android:color/black - #7C4DFF + #C3FFFFFF diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 000000000..dd9448782 --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,6 @@ + + + 48dp + 16dp + 8dp + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..282b89c5e --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Music player + Previous track + Play + Next track + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 70c5f6a4e..c72ed0338 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,6 +1,5 @@ - #7c4dff + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..4eb55d518 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/app/src/main/res/xml-v31/music_player_info.xml b/android/app/src/main/res/xml-v31/music_player_info.xml new file mode 100644 index 000000000..9d734b753 --- /dev/null +++ b/android/app/src/main/res/xml-v31/music_player_info.xml @@ -0,0 +1,15 @@ + + diff --git a/android/app/src/main/res/xml/music_player_info.xml b/android/app/src/main/res/xml/music_player_info.xml new file mode 100644 index 000000000..0b76b4843 --- /dev/null +++ b/android/app/src/main/res/xml/music_player_info.xml @@ -0,0 +1,12 @@ + + diff --git a/lib/logic/app_widget.dart b/lib/logic/app_widget.dart new file mode 100644 index 000000000..2ccfba2fd --- /dev/null +++ b/lib/logic/app_widget.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:home_widget/home_widget.dart'; + +import '../sweyer.dart'; + +/// Controller for native app widgets. +class AppWidgetControl extends Control { + static AppWidgetControl instance = AppWidgetControl(); + @visibleForTesting + static const appWidgetName = 'MusicPlayerAppWidget'; + static const _songUriKey = 'song'; + static const _playingKey = 'playing'; + + StreamSubscription? _currentSongListener; + StreamSubscription? _playingStateListener; + + /// The last song content uri sent to the widget. + String? _lastSongContentUri; + + /// The last playing state sent to the widget. + bool? _lastPlayingState; + + @override + void init() { + super.init(); + _lastSongContentUri = null; + _lastPlayingState = null; + _currentSongListener = + PlaybackControl.instance.onSongChange.listen((song) => update(song, MusicPlayer.instance.playing)); + _playingStateListener = + MusicPlayer.instance.playingStream.listen((playing) => update(PlaybackControl.instance.currentSong, playing)); + } + + @override + void dispose() { + _currentSongListener?.cancel(); + _playingStateListener?.cancel(); + super.dispose(); + } + + /// Update the widgets with the current [song] and [playing] state. + Future update(Song song, bool playing) async { + if (playing == _lastPlayingState && song.contentUri == _lastSongContentUri) { + return; + } + _lastSongContentUri = song.contentUri; + _lastPlayingState = playing; + await HomeWidget.saveWidgetData(_songUriKey, song.contentUri); + await HomeWidget.saveWidgetData(_playingKey, playing); + await HomeWidget.updateWidget(name: appWidgetName); + } +} diff --git a/lib/logic/logic.dart b/lib/logic/logic.dart index 60d8e40f3..e27606ae4 100644 --- a/lib/logic/logic.dart +++ b/lib/logic/logic.dart @@ -4,6 +4,7 @@ library; export 'models/models.dart'; export 'player/music_player.dart'; +export 'app_widget.dart'; export 'control.dart'; export 'device_info.dart'; export 'palette.dart'; diff --git a/lib/logic/player/content.dart b/lib/logic/player/content.dart index 8ad324b29..c91210643 100644 --- a/lib/logic/player/content.dart +++ b/lib/logic/player/content.dart @@ -253,6 +253,7 @@ class ContentControl extends Control { await MusicPlayer.instance.init(); await FavoritesControl.instance.init(); PlayerInterfaceColorStyleControl.instance.init(); + AppWidgetControl.instance.init(); } _initializeCompleter = null; } @@ -280,6 +281,7 @@ class ContentControl extends Control { MusicPlayer.instance.dispose(); FavoritesControl.instance.dispose(); PlayerInterfaceColorStyleControl.instance.dispose(); + AppWidgetControl.instance.dispose(); } super.dispose(); } diff --git a/pubspec.lock b/pubspec.lock index 16b15d36d..3475076e0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -584,6 +584,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + home_widget: + dependency: "direct main" + description: + name: home_widget + sha256: b313e3304c0429669fddf1286e1fbf61a64b873f38ba30b3eb890ef0d7560b12 + url: "https://pub.dev" + source: hosted + version: "0.7.0" hooks_riverpod: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5fe8e8c4f..5a1de5cd3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: freezed_annotation: ^2.2.0 json_annotation: ^4.7.0 memoize: ^3.0.0 + home_widget: ^0.7.0 # quick_actions: ^0.6.0 # TODO: quick actions are blocked on https://github.com/ryanheise/audio_service/issues/671 diff --git a/test/observer/app_widget.dart b/test/observer/app_widget.dart new file mode 100644 index 000000000..1af32a6da --- /dev/null +++ b/test/observer/app_widget.dart @@ -0,0 +1,37 @@ +import 'dart:collection'; + +import 'package:flutter/services.dart'; + +import '../test.dart'; + +/// An observer for platform app widget channel requests. +class AppWidgetChannelObserver { + /// The method channel used by the flutter `home_widget` package + static const MethodChannel _channel = MethodChannel('home_widget'); + + /// The list of requests made to save widget data. + List<(String, dynamic)> get saveWidgetDataLog => UnmodifiableListView(_saveWidgetDataLog); + final List<(String, dynamic)> _saveWidgetDataLog = []; + + /// The list of requests made to update a widget type. + List get updateWidgetRequests => UnmodifiableListView(_updateWidgetRequests); + final List _updateWidgetRequests = []; + + /// Create a new app widget observer, which automatically + /// unregisters any previously created observer. + AppWidgetChannelObserver(TestWidgetsFlutterBinding binding) { + binding.defaultBinaryMessenger.setMockMethodCallHandler(_channel, (call) async { + switch (call.method) { + case 'saveWidgetData': + var arguments = Map.castFrom(call.arguments); + _saveWidgetDataLog.add((arguments['id'] as String, arguments['data'])); + return true; + case 'updateWidget': + var arguments = Map.castFrom(call.arguments); + _updateWidgetRequests.add(arguments['name'] as String); + return true; + } + return null; // Ignore unimplemented method calls. + }); + } +} diff --git a/test/routes/home_route_test.dart b/test/routes/home_route_test.dart index 60f0e45a6..c30a2ce00 100644 --- a/test/routes/home_route_test.dart +++ b/test/routes/home_route_test.dart @@ -6,6 +6,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:sweyer/constants.dart'; import 'package:sweyer/routes/settings_route/theme_settings.dart'; +import '../observer/app_widget.dart'; import '../observer/observer.dart'; import '../test.dart'; @@ -16,7 +17,7 @@ void main() { group('permissions screen', () { testWidgets('shows if no permissions were granted and pressing the button requests permissions', - (WidgetTester tester) async { + (WidgetTester tester) async { late PermissionsChannelObserver permissionsObserver; await setUpAppTest(() { permissionsObserver = PermissionsChannelObserver(tester.binding); @@ -133,6 +134,12 @@ void main() { testWidgets('home screen - shows when permissions are granted and not searching for tracks', (WidgetTester tester) async { + late AppWidgetChannelObserver appWidgetChannelObserver; + // Wait for and discard widget events from previous tests, since we don't wait for all async actions to complete. + await tester.runAsync(() => tester.pump()); + await setUpAppTest(() { + appWidgetChannelObserver = AppWidgetChannelObserver(tester.binding); + }); await tester.runAppTest(() async { expect(Permissions.instance.granted, true); expect(find.byType(Home), findsOneWidget); @@ -141,6 +148,10 @@ void main() { tester.getRect(find.byType(App)).height, reason: 'Player route must be offscreen', ); + // Wait for widget events from the app startup of this event to reach the app widget channel observer. + await tester.runAsync(() => tester.pump()); + expect(appWidgetChannelObserver.saveWidgetDataLog, [("song", songWith().contentUri), ("playing", false)]); + expect(appWidgetChannelObserver.updateWidgetRequests, [AppWidgetControl.appWidgetName]); }); }); diff --git a/test/routes/player_route_test.dart b/test/routes/player_route_test.dart index 90fcd58dd..b74b54d9d 100644 --- a/test/routes/player_route_test.dart +++ b/test/routes/player_route_test.dart @@ -1,6 +1,7 @@ import 'package:back_button_interceptor/back_button_interceptor.dart'; import 'package:flutter/material.dart'; +import '../observer/app_widget.dart'; import '../test.dart'; void main() { @@ -85,7 +86,7 @@ void main() { /// 1 - from [SongTile] /// 2 - from [TrackPanel] /// 3 and 4 - from [PlayerRoute] - it shows current and previous song arts and animates between them - /// 5 - also from [PlayerRoute] invisible overlay, used to extract art color + /// 5 - also from [PlayerRoute] invisible overlay, used to extract art color final currentSong = PlaybackControl.instance.currentSong; expect(find.text(currentSong.title), findsNWidgets(3)); @@ -134,18 +135,27 @@ void main() { await setUpAppTest(() { FakeSweyerPluginPlatform.instance.songs = songs.toList(); }); - PlaybackControl.instance.changeSong(songs[1]); + await tester.runAsync(() async => PlaybackControl.instance.changeSong(songs[1])); await tester.runAppTest(() async { + final appWidgetChannelObserver = AppWidgetChannelObserver(tester.binding); // Expand the route await tester.expandPlayerRoute(); expect(PlaybackControl.instance.currentSong, songs[1]); + expect(appWidgetChannelObserver.saveWidgetDataLog, []); + expect(appWidgetChannelObserver.updateWidgetRequests, []); - await tester.tap(find.byIcon(Icons.skip_previous_rounded)); + await tester.runAsync(() => tester.tap(find.byIcon(Icons.skip_previous_rounded))); expect(PlaybackControl.instance.currentSong, songs.first); + expect(appWidgetChannelObserver.saveWidgetDataLog, [("song", songs.first.contentUri), ("playing", false)]); + expect(appWidgetChannelObserver.updateWidgetRequests, [AppWidgetControl.appWidgetName]); - await tester.tap(find.byIcon(Icons.skip_previous_rounded)); + await tester.runAsync(() => tester.tap(find.byIcon(Icons.skip_previous_rounded))); expect(PlaybackControl.instance.currentSong, songs.last); + expect(appWidgetChannelObserver.saveWidgetDataLog, + [("song", songs.first.contentUri), ("playing", false), ("song", songs.last.contentUri), ("playing", false)]); + expect(appWidgetChannelObserver.updateWidgetRequests, + [AppWidgetControl.appWidgetName, AppWidgetControl.appWidgetName]); }); }); @@ -158,23 +168,34 @@ void main() { await setUpAppTest(() { FakeSweyerPluginPlatform.instance.songs = songs.toList(); }); - PlaybackControl.instance.changeSong(songs[1]); + await tester.runAsync(() async => PlaybackControl.instance.changeSong(songs[1])); await tester.runAppTest(() async { + final appWidgetChannelObserver = AppWidgetChannelObserver(tester.binding); // Expand the route await tester.expandPlayerRoute(); expect(PlaybackControl.instance.currentSong, songs[1]); + expect(appWidgetChannelObserver.saveWidgetDataLog, []); + expect(appWidgetChannelObserver.updateWidgetRequests, []); - await tester.tap(find.byIcon(Icons.skip_next_rounded)); + await tester.runAsync(() => tester.tap(find.byIcon(Icons.skip_next_rounded))); expect(PlaybackControl.instance.currentSong, songs.last); + expect(appWidgetChannelObserver.saveWidgetDataLog, [("song", songs.last.contentUri), ("playing", false)]); + expect(appWidgetChannelObserver.updateWidgetRequests, [AppWidgetControl.appWidgetName]); - await tester.tap(find.byIcon(Icons.skip_next_rounded)); + await tester.runAsync(() => tester.tap(find.byIcon(Icons.skip_next_rounded))); expect(PlaybackControl.instance.currentSong, songs.first); + expect(appWidgetChannelObserver.saveWidgetDataLog, + [("song", songs.last.contentUri), ("playing", false), ("song", songs.first.contentUri), ("playing", false)]); + expect(appWidgetChannelObserver.updateWidgetRequests, + [AppWidgetControl.appWidgetName, AppWidgetControl.appWidgetName]); }); }); testWidgets('play/pause button works', (WidgetTester tester) async { await tester.runAppTest(() async { + await tester.runAsync(() => tester.pump()); // Wait for widget events from start to process. + final appWidgetChannelObserver = AppWidgetChannelObserver(tester.binding); // Expand the route await tester.expandPlayerRoute(); @@ -185,14 +206,22 @@ void main() { expect(MusicPlayer.instance.playing, false); expect(MusicPlayer.handler!.running, false); + expect(appWidgetChannelObserver.saveWidgetDataLog, []); + expect(appWidgetChannelObserver.updateWidgetRequests, []); - await tester.tap(button); + await tester.runAsync(() => tester.tap(button)); expect(MusicPlayer.instance.playing, true); expect(MusicPlayer.handler!.running, true); + expect(appWidgetChannelObserver.saveWidgetDataLog, [("song", songWith().contentUri), ("playing", true)]); + expect(appWidgetChannelObserver.updateWidgetRequests, [AppWidgetControl.appWidgetName]); - await tester.tap(button); + await tester.runAsync(() => tester.tap(button)); expect(MusicPlayer.instance.playing, false); expect(MusicPlayer.handler!.running, true, reason: 'Handler should only stop when stopped, not when paused'); + expect(appWidgetChannelObserver.saveWidgetDataLog, + [("song", songWith().contentUri), ("playing", true), ("song", songWith().contentUri), ("playing", false)]); + expect(appWidgetChannelObserver.updateWidgetRequests, + [AppWidgetControl.appWidgetName, AppWidgetControl.appWidgetName]); await tester.pumpAndSettle(); }); diff --git a/test/test.dart b/test/test.dart index e945a296c..1dd51700c 100644 --- a/test/test.dart +++ b/test/test.dart @@ -21,6 +21,7 @@ import 'package:sweyer_plugin/sweyer_plugin_platform_interface.dart'; export 'fakes/fakes.dart'; +import 'observer/app_widget.dart'; import 'observer/observer.dart'; import 'test.dart'; import 'test_description.dart'; @@ -116,6 +117,7 @@ Future setUpAppTest([VoidCallback? configureFakes]) async { DeviceInfoControl.instance = FakeDeviceInfoControl(); FavoritesControl.instance = FakeFavoritesControl(); PermissionsChannelObserver(binding); // Grant all permissions by default. + AppWidgetChannelObserver(binding); // Ignore app widget updates by default. SweyerPluginPlatform.instance = FakeSweyerPluginPlatform(binding); QueueControl.instance = FakeQueueControl(); ThemeControl.instance = FakeThemeControl();