diff --git a/android/app/build.gradle b/android/app/build.gradle index b0d2158..5909b5d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -50,6 +50,8 @@ android { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug + // See https://github.com/ryanheise/audio_service/blob/master/README.md#android-setup + shrinkResources false } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8ec7429..e544927 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable-hdpi/ic_notification.png b/android/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000..a989e37 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_pause.png b/android/app/src/main/res/drawable-hdpi/ic_pause.png new file mode 100644 index 0000000..55f33b2 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_play.png b/android/app/src/main/res/drawable-hdpi/ic_play.png new file mode 100644 index 0000000..326a6aa Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_play.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_stop.png b/android/app/src/main/res/drawable-hdpi/ic_stop.png new file mode 100644 index 0000000..5435114 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_stop.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_notification.png b/android/app/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000..cb6f9d7 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_pause.png b/android/app/src/main/res/drawable-mdpi/ic_pause.png new file mode 100644 index 0000000..e8ff072 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_play.png b/android/app/src/main/res/drawable-mdpi/ic_play.png new file mode 100644 index 0000000..71fff1d Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_play.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_stop.png b/android/app/src/main/res/drawable-mdpi/ic_stop.png new file mode 100644 index 0000000..95e837d Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_stop.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/android/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000..3ef9bc5 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_pause.png b/android/app/src/main/res/drawable-xhdpi/ic_pause.png new file mode 100644 index 0000000..fbdee83 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_play.png b/android/app/src/main/res/drawable-xhdpi/ic_play.png new file mode 100644 index 0000000..62d2067 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_play.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stop.png b/android/app/src/main/res/drawable-xhdpi/ic_stop.png new file mode 100644 index 0000000..3f7f54d Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_stop.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000..1fe62f9 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_pause.png b/android/app/src/main/res/drawable-xxhdpi/ic_pause.png new file mode 100644 index 0000000..8ac598d Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_play.png b/android/app/src/main/res/drawable-xxhdpi/ic_play.png new file mode 100644 index 0000000..22e40a3 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_play.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stop.png b/android/app/src/main/res/drawable-xxhdpi/ic_stop.png new file mode 100644 index 0000000..17da4a3 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_stop.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000..aff53f9 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_pause.png b/android/app/src/main/res/drawable-xxxhdpi/ic_pause.png new file mode 100644 index 0000000..4343502 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_pause.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_play.png b/android/app/src/main/res/drawable-xxxhdpi/ic_play.png new file mode 100644 index 0000000..e9f9281 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_play.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_stop.png b/android/app/src/main/res/drawable-xxxhdpi/ic_stop.png new file mode 100644 index 0000000..20ee1b7 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_stop.png differ diff --git a/lib/app.dart b/lib/app.dart index 6936c52..fc77eb2 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -93,13 +93,13 @@ class _ContentState extends State with SingleTickerProviderStateMixin { super.didChangeDependencies(); backend = Backend.of(context); - playerBarAnimation.value = backend.playerActive.value ? 1.0 : 0.0; + playerBarAnimation.value = backend.player.active.value ? 1.0 : 0.0; if (playerActiveSubscription != null) { playerActiveSubscription.cancel(); } - playerActiveSubscription = backend.playerActive.listen((active) => + playerActiveSubscription = backend.player.active.listen((active) => active ? playerBarAnimation.forward() : playerBarAnimation.reverse()); } diff --git a/lib/backend.dart b/lib/backend.dart index 8d8141c..b36a4fb 100644 --- a/lib/backend.dart +++ b/lib/backend.dart @@ -8,10 +8,10 @@ import 'package:moor/moor.dart'; import 'package:moor_ffi/moor_ffi.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart' as pp; -import 'package:rxdart/rxdart.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'database.dart'; +import 'player.dart'; // The following code was taken from // https://moor.simonbinder.eu/docs/advanced-features/isolates/ and just @@ -81,9 +81,7 @@ class Backend extends StatefulWidget { class BackendState extends State { static const _platform = MethodChannel('de.johrpan.musicus/platform'); - final playerActive = BehaviorSubject.seeded(false); - final playing = BehaviorSubject.seeded(false); - final position = BehaviorSubject.seeded(0.0); + final player = Player(); BackendStatus status = BackendStatus.loading; Database db; @@ -109,11 +107,12 @@ class BackendState extends State { Future _load() async { _moorIsolate = await _createMoorIsolate(); final dbConnection = await _moorIsolate.connect(); + player.setup(); db = Database.connect(dbConnection); _shPref = await SharedPreferences.getInstance(); musicLibraryUri = _shPref.getString('musicLibraryUri'); - + _loadMusicLibrary(); } @@ -142,32 +141,6 @@ class BackendState extends State { } } - void startPlayer() { - playerActive.add(true); - } - - void playPause() { - playing.add(!playing.value); - if (playing.value) { - simulatePlay(); - } - } - - void seekTo(double pos) { - position.add(pos); - } - - Future simulatePlay() async { - while (playing.value) { - await Future.delayed(Duration(milliseconds: 200)); - if (position.value >= 0.99) { - position.add(0.0); - } else { - position.add(position.value + 0.01); - } - } - } - @override void dispose() { super.dispose(); diff --git a/lib/main.dart b/lib/main.dart index a09a429..9b389c5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,13 @@ +import 'package:audio_service/audio_service.dart'; import 'package:flutter/widgets.dart'; import 'app.dart'; import 'backend.dart'; void main() { - runApp(Backend( - child: App(), + runApp(AudioServiceWidget( + child: Backend( + child: App(), + ), )); } diff --git a/lib/player.dart b/lib/player.dart new file mode 100644 index 0000000..0c4ac46 --- /dev/null +++ b/lib/player.dart @@ -0,0 +1,249 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:rxdart/rxdart.dart'; + +/// Entrypoint for the playback service. +void _playbackServiceEntrypoint() { + AudioServiceBackground.run(() => _PlaybackService()); +} + +class Player { + /// The interval between playback position updates in milliseconds. + static const positionUpdateInterval = 250; + + /// Whether the player is active. + /// + /// This means, that there is at least one item in the queue and the playback + /// service is ready to play. + final active = BehaviorSubject.seeded(false); + + /// Whether we are currently playing or not. + /// + /// This will be false, if the player is not active. + final playing = BehaviorSubject.seeded(false); + + /// Current playback position. + /// + /// If the player is not active, this will default to zero. + final position = BehaviorSubject.seeded(const Duration()); + + /// Duration of the current track. + /// + /// If the player is not active, the duration will default to 1 s. + final duration = BehaviorSubject.seeded(const Duration(seconds: 1)); + + /// Playback position normalized to the range from zero to one. + final normalizedPosition = BehaviorSubject.seeded(0.0); + + /// The current position in milliseconds. + int _positionMs = 0; + + StreamSubscription _stateStreamSubscription; + StreamSubscription _mediaItemStreamSubscription; + + /// Update [position] and [normalizedPosition] according to [_positionMs]. + void _updatePosition() { + position.add(Duration(milliseconds: _positionMs)); + normalizedPosition.add(_positionMs / duration.value.inMilliseconds); + } + + /// Set everything to its default because the playback service was stopped. + void _stop() { + active.add(false); + playing.add(false); + position.add(const Duration()); + duration.add(const Duration(seconds: 1)); + normalizedPosition.add(0.0); + _positionMs = 0; + _stateStreamSubscription.cancel(); + _mediaItemStreamSubscription.cancel(); + } + + /// Start playback service. + Future start() async { + if (!AudioService.running) { + await AudioService.start( + backgroundTaskEntrypoint: _playbackServiceEntrypoint, + androidNotificationChannelName: 'Musicus playback', + androidNotificationChannelDescription: + 'Keeps Musicus playing in the background', + androidNotificationIcon: 'drawable/ic_notification', + ); + + setup(); + } + } + + /// Connect listeners and initialize streams. + void setup() { + if (AudioService.running) { + active.add(true); + + _stateStreamSubscription = + AudioService.playbackStateStream.listen((playbackState) { + if (playbackState != null) { + if (playbackState.basicState == BasicPlaybackState.stopped) { + _stop(); + } else { + if (playbackState.basicState == BasicPlaybackState.playing) { + playing.add(true); + _play(); + } else { + playing.add(false); + } + + _positionMs = playbackState.currentPosition; + _updatePosition(); + } + } + }); + + _mediaItemStreamSubscription = + AudioService.currentMediaItemStream.listen((mediaItem) { + if (mediaItem?.duration != null) { + duration.add(Duration(milliseconds: mediaItem.duration)); + } + }); + } + } + + /// Toggle whether the player is playing or paused. + /// + /// If the player is not active, this will do nothing. + Future playPause() async { + if (active.value) { + if (playing.value) { + await AudioService.pause(); + } else { + await AudioService.play(); + } + } + } + + /// Regularly update [_positionMs] while playing. + // TODO: Maybe find a better approach on handling this. + Future _play() async { + while (playing.value) { + await Future.delayed(Duration(milliseconds: positionUpdateInterval)); + _positionMs += positionUpdateInterval; + _updatePosition(); + } + } + + /// Seek to [pos], which is a value between (and including) zero and one. + /// + /// If the player is not active or an invalid value is provided, this will do + /// nothing. + Future seekTo(double pos) async { + if (active.value && pos >= 0.0 && pos <= 1.0) { + final durationMs = duration.value.inMilliseconds; + await AudioService.seekTo((pos * durationMs).floor()); + } + } + + /// Tidy up. + void dispose() { + _stateStreamSubscription.cancel(); + _mediaItemStreamSubscription.cancel(); + + active.close(); + playing.close(); + position.close(); + duration.close(); + normalizedPosition.close(); + } +} + +class _PlaybackService extends BackgroundAudioTask { + static const playControl = MediaControl( + androidIcon: 'drawable/ic_play', + label: 'Play', + action: MediaAction.play, + ); + + static const pauseControl = MediaControl( + androidIcon: 'drawable/ic_pause', + label: 'Pause', + action: MediaAction.pause, + ); + + static const stopControl = MediaControl( + androidIcon: 'drawable/ic_stop', + label: 'Stop', + action: MediaAction.stop, + ); + + static const dummyMediaItem = MediaItem( + id: 'dummy', + album: 'Johannes Brahms', + title: 'Symphony No. 1 in C minor, Op. 68: 1. Un poco sostenuto — Allegro', + duration: 10000, + ); + + final _completer = Completer(); + + int _position; + int _updateTime; + bool _playing = false; + + void _setPosition(int position) { + _position = position; + _updateTime = DateTime.now().millisecondsSinceEpoch; + } + + void _setState() { + AudioServiceBackground.setState( + controls: + _playing ? [pauseControl, stopControl] : [playControl, stopControl], + basicState: + _playing ? BasicPlaybackState.playing : BasicPlaybackState.paused, + position: _position, + updateTime: _updateTime, + ); + + AudioServiceBackground.setMediaItem(dummyMediaItem); + } + + @override + Future onStart() async { + _setPosition(0); + _setState(); + await _completer.future; + } + + @override + void onPlay() { + super.onPlay(); + + _playing = true; + _setState(); + } + + @override + void onPause() { + super.onPause(); + + _playing = false; + _setState(); + } + + @override + void onSeekTo(int position) { + super.onSeekTo(position); + + _setPosition(position); + _setState(); + } + + @override + void onStop() { + AudioServiceBackground.setState( + controls: [], + basicState: BasicPlaybackState.stopped, + ); + + // This will end onStart. + _completer.complete(); + } +} diff --git a/lib/screens/home.dart b/lib/screens/home.dart index ecf5a23..c7ebf2f 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -34,7 +34,7 @@ class HomeScreen extends StatelessWidget { ], onSelected: (selected) { if (selected == 0) { - backend.startPlayer(); + backend.player.start(); } else if (selected == 1) { Navigator.push( context, diff --git a/lib/screens/program.dart b/lib/screens/program.dart index 5eed825..6d0fb97 100644 --- a/lib/screens/program.dart +++ b/lib/screens/program.dart @@ -26,7 +26,7 @@ class _ProgramScreenState extends State { positionSubscription.cancel(); } - positionSubscription = backend.position.listen((pos) { + positionSubscription = backend.player.normalizedPosition.listen((pos) { if (!seeking) { setState(() { position = pos; @@ -56,7 +56,7 @@ class _ProgramScreenState extends State { }, onChangeEnd: (pos) { seeking = false; - backend.seekTo(pos); + backend.player.seekTo(pos); }, onChanged: (pos) { setState(() { @@ -68,7 +68,16 @@ class _ProgramScreenState extends State { children: [ Padding( padding: const EdgeInsets.only(left: 24.0), - child: Text('4:00'), + child: StreamBuilder( + stream: backend.player.position, + builder: (context, snapshot) { + if (snapshot.hasData) { + return DurationText(snapshot.data); + } else { + return Container(); + } + }, + ), ), Spacer(), IconButton( @@ -83,7 +92,16 @@ class _ProgramScreenState extends State { Spacer(), Padding( padding: const EdgeInsets.only(right: 20.0), - child: Text('10:30'), + child: StreamBuilder( + stream: backend.player.duration, + builder: (context, snapshot) { + if (snapshot.hasData) { + return DurationText(snapshot.data); + } else { + return Container(); + } + }, + ), ), ], ), @@ -99,3 +117,19 @@ class _ProgramScreenState extends State { positionSubscription.cancel(); } } + +class DurationText extends StatelessWidget { + final Duration duration; + + DurationText(this.duration); + + @override + Widget build(BuildContext context) { + final minutes = duration.inMinutes; + final seconds = (duration - Duration(minutes: minutes)).inSeconds; + + final secondsString = seconds >= 10 ? seconds.toString() : '0$seconds'; + + return Text('$minutes:$secondsString'); + } +} diff --git a/lib/widgets/play_pause_button.dart b/lib/widgets/play_pause_button.dart index 84703d8..9202201 100644 --- a/lib/widgets/play_pause_button.dart +++ b/lib/widgets/play_pause_button.dart @@ -30,13 +30,13 @@ class _PlayPauseButtonState extends State super.didChangeDependencies(); backend = Backend.of(context); - playPauseAnimation.value = backend.playing.value ? 1.0 : 0.0; + playPauseAnimation.value = backend.player.playing.value ? 1.0 : 0.0; if (playingSubscription != null) { playingSubscription.cancel(); } - playingSubscription = backend.playing.listen((playing) => + playingSubscription = backend.player.playing.listen((playing) => playing ? playPauseAnimation.forward() : playPauseAnimation.reverse()); } @@ -47,7 +47,7 @@ class _PlayPauseButtonState extends State icon: AnimatedIcons.play_pause, progress: playPauseAnimation, ), - onPressed: backend.playPause, + onPressed: backend.player.playPause, ); } diff --git a/lib/widgets/player_bar.dart b/lib/widgets/player_bar.dart index 014555a..b800390 100644 --- a/lib/widgets/player_bar.dart +++ b/lib/widgets/player_bar.dart @@ -16,7 +16,7 @@ class PlayerBar extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ StreamBuilder( - stream: backend.position, + stream: backend.player.normalizedPosition, builder: (context, snapshot) => LinearProgressIndicator( value: snapshot.data, ), diff --git a/pubspec.yaml b/pubspec.yaml index 2f56cb5..061dfc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: sdk: ">=2.3.0 <3.0.0" dependencies: + audio_service: flutter: sdk: flutter moor: