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();