Add player skeleton and playback service
This introduces a dependency on audio_service and implements the playback service using that package. The UI was adapted to the new interface.
|  | @ -50,6 +50,8 @@ android { | ||||||
|             // TODO: Add your own signing config for the release build. |             // TODO: Add your own signing config for the release build. | ||||||
|             // Signing with the debug keys for now, so `flutter run --release` works. |             // Signing with the debug keys for now, so `flutter run --release` works. | ||||||
|             signingConfig signingConfigs.debug |             signingConfig signingConfigs.debug | ||||||
|  |             // See https://github.com/ryanheise/audio_service/blob/master/README.md#android-setup | ||||||
|  |             shrinkResources false | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,6 +1,9 @@ | ||||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | <manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||||||
|     package="de.johrpan.musicus"> |     package="de.johrpan.musicus"> | ||||||
| 
 | 
 | ||||||
|  |     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> | ||||||
|  |     <uses-permission android:name="android.permission.WAKE_LOCK" /> | ||||||
|  | 
 | ||||||
|     <application |     <application | ||||||
|         android:name="io.flutter.app.FlutterApplication" |         android:name="io.flutter.app.FlutterApplication" | ||||||
|         android:label="Musicus" |         android:label="Musicus" | ||||||
|  | @ -21,6 +24,18 @@ | ||||||
|             </intent-filter> |             </intent-filter> | ||||||
|         </activity> |         </activity> | ||||||
| 
 | 
 | ||||||
|  |         <service android:name="com.ryanheise.audioservice.AudioService"> | ||||||
|  |             <intent-filter> | ||||||
|  |                <action android:name="android.media.browse.MediaBrowserService" /> | ||||||
|  |             </intent-filter> | ||||||
|  |         </service> | ||||||
|  | 
 | ||||||
|  |         <receiver android:name="androidx.media.session.MediaButtonReceiver" > | ||||||
|  |             <intent-filter> | ||||||
|  |                <action android:name="android.intent.action.MEDIA_BUTTON" /> | ||||||
|  |             </intent-filter> | ||||||
|  |         </receiver> | ||||||
|  | 
 | ||||||
|         <meta-data |         <meta-data | ||||||
|             android:name="flutterEmbedding" |             android:name="flutterEmbedding" | ||||||
|             android:value="2" /> |             android:value="2" /> | ||||||
|  |  | ||||||
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-hdpi/ic_notification.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 674 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-hdpi/ic_pause.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 140 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-hdpi/ic_play.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 272 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-hdpi/ic_stop.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 102 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-mdpi/ic_notification.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 485 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-mdpi/ic_pause.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 108 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-mdpi/ic_play.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 159 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-mdpi/ic_stop.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 92 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xhdpi/ic_notification.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xhdpi/ic_pause.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 162 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xhdpi/ic_play.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 288 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xhdpi/ic_stop.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 114 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xxhdpi/ic_notification.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xxhdpi/ic_pause.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 202 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xxhdpi/ic_play.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 547 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xxhdpi/ic_stop.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 196 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xxxhdpi/ic_notification.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xxxhdpi/ic_pause.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 244 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xxxhdpi/ic_play.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 488 B | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable-xxxhdpi/ic_stop.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 244 B | 
|  | @ -93,13 +93,13 @@ class _ContentState extends State<Content> with SingleTickerProviderStateMixin { | ||||||
|     super.didChangeDependencies(); |     super.didChangeDependencies(); | ||||||
| 
 | 
 | ||||||
|     backend = Backend.of(context); |     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) { |     if (playerActiveSubscription != null) { | ||||||
|       playerActiveSubscription.cancel(); |       playerActiveSubscription.cancel(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     playerActiveSubscription = backend.playerActive.listen((active) => |     playerActiveSubscription = backend.player.active.listen((active) => | ||||||
|         active ? playerBarAnimation.forward() : playerBarAnimation.reverse()); |         active ? playerBarAnimation.forward() : playerBarAnimation.reverse()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -8,10 +8,10 @@ import 'package:moor/moor.dart'; | ||||||
| import 'package:moor_ffi/moor_ffi.dart'; | import 'package:moor_ffi/moor_ffi.dart'; | ||||||
| import 'package:path/path.dart' as p; | import 'package:path/path.dart' as p; | ||||||
| import 'package:path_provider/path_provider.dart' as pp; | import 'package:path_provider/path_provider.dart' as pp; | ||||||
| import 'package:rxdart/rxdart.dart'; |  | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; | import 'package:shared_preferences/shared_preferences.dart'; | ||||||
| 
 | 
 | ||||||
| import 'database.dart'; | import 'database.dart'; | ||||||
|  | import 'player.dart'; | ||||||
| 
 | 
 | ||||||
| // The following code was taken from | // The following code was taken from | ||||||
| // https://moor.simonbinder.eu/docs/advanced-features/isolates/ and just | // https://moor.simonbinder.eu/docs/advanced-features/isolates/ and just | ||||||
|  | @ -81,9 +81,7 @@ class Backend extends StatefulWidget { | ||||||
| class BackendState extends State<Backend> { | class BackendState extends State<Backend> { | ||||||
|   static const _platform = MethodChannel('de.johrpan.musicus/platform'); |   static const _platform = MethodChannel('de.johrpan.musicus/platform'); | ||||||
| 
 | 
 | ||||||
|   final playerActive = BehaviorSubject.seeded(false); |   final player = Player(); | ||||||
|   final playing = BehaviorSubject.seeded(false); |  | ||||||
|   final position = BehaviorSubject.seeded(0.0); |  | ||||||
| 
 | 
 | ||||||
|   BackendStatus status = BackendStatus.loading; |   BackendStatus status = BackendStatus.loading; | ||||||
|   Database db; |   Database db; | ||||||
|  | @ -109,6 +107,7 @@ class BackendState extends State<Backend> { | ||||||
|   Future<void> _load() async { |   Future<void> _load() async { | ||||||
|     _moorIsolate = await _createMoorIsolate(); |     _moorIsolate = await _createMoorIsolate(); | ||||||
|     final dbConnection = await _moorIsolate.connect(); |     final dbConnection = await _moorIsolate.connect(); | ||||||
|  |     player.setup(); | ||||||
|     db = Database.connect(dbConnection); |     db = Database.connect(dbConnection); | ||||||
| 
 | 
 | ||||||
|     _shPref = await SharedPreferences.getInstance(); |     _shPref = await SharedPreferences.getInstance(); | ||||||
|  | @ -142,32 +141,6 @@ class BackendState extends State<Backend> { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   void startPlayer() { |  | ||||||
|     playerActive.add(true); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   void playPause() { |  | ||||||
|     playing.add(!playing.value); |  | ||||||
|     if (playing.value) { |  | ||||||
|       simulatePlay(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   void seekTo(double pos) { |  | ||||||
|     position.add(pos); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   Future<void> 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 |   @override | ||||||
|   void dispose() { |   void dispose() { | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|  |  | ||||||
|  | @ -1,10 +1,13 @@ | ||||||
|  | import 'package:audio_service/audio_service.dart'; | ||||||
| import 'package:flutter/widgets.dart'; | import 'package:flutter/widgets.dart'; | ||||||
| 
 | 
 | ||||||
| import 'app.dart'; | import 'app.dart'; | ||||||
| import 'backend.dart'; | import 'backend.dart'; | ||||||
| 
 | 
 | ||||||
| void main() { | void main() { | ||||||
|   runApp(Backend( |   runApp(AudioServiceWidget( | ||||||
|  |     child: Backend( | ||||||
|       child: App(), |       child: App(), | ||||||
|  |     ), | ||||||
|   )); |   )); | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										249
									
								
								lib/player.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -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<PlaybackState> _stateStreamSubscription; | ||||||
|  |   StreamSubscription<MediaItem> _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<void> 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<void> 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<void> _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<void> 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<void> 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(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -34,7 +34,7 @@ class HomeScreen extends StatelessWidget { | ||||||
|             ], |             ], | ||||||
|             onSelected: (selected) { |             onSelected: (selected) { | ||||||
|               if (selected == 0) { |               if (selected == 0) { | ||||||
|                 backend.startPlayer(); |                 backend.player.start(); | ||||||
|               } else if (selected == 1) { |               } else if (selected == 1) { | ||||||
|                 Navigator.push( |                 Navigator.push( | ||||||
|                   context, |                   context, | ||||||
|  |  | ||||||
|  | @ -26,7 +26,7 @@ class _ProgramScreenState extends State<ProgramScreen> { | ||||||
|       positionSubscription.cancel(); |       positionSubscription.cancel(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     positionSubscription = backend.position.listen((pos) { |     positionSubscription = backend.player.normalizedPosition.listen((pos) { | ||||||
|       if (!seeking) { |       if (!seeking) { | ||||||
|         setState(() { |         setState(() { | ||||||
|           position = pos; |           position = pos; | ||||||
|  | @ -56,7 +56,7 @@ class _ProgramScreenState extends State<ProgramScreen> { | ||||||
|               }, |               }, | ||||||
|               onChangeEnd: (pos) { |               onChangeEnd: (pos) { | ||||||
|                 seeking = false; |                 seeking = false; | ||||||
|                 backend.seekTo(pos); |                 backend.player.seekTo(pos); | ||||||
|               }, |               }, | ||||||
|               onChanged: (pos) { |               onChanged: (pos) { | ||||||
|                 setState(() { |                 setState(() { | ||||||
|  | @ -68,7 +68,16 @@ class _ProgramScreenState extends State<ProgramScreen> { | ||||||
|               children: <Widget>[ |               children: <Widget>[ | ||||||
|                 Padding( |                 Padding( | ||||||
|                   padding: const EdgeInsets.only(left: 24.0), |                   padding: const EdgeInsets.only(left: 24.0), | ||||||
|                   child: Text('4:00'), |                   child: StreamBuilder<Duration>( | ||||||
|  |                     stream: backend.player.position, | ||||||
|  |                     builder: (context, snapshot) { | ||||||
|  |                       if (snapshot.hasData) { | ||||||
|  |                         return DurationText(snapshot.data); | ||||||
|  |                       } else { | ||||||
|  |                         return Container(); | ||||||
|  |                       } | ||||||
|  |                     }, | ||||||
|  |                   ), | ||||||
|                 ), |                 ), | ||||||
|                 Spacer(), |                 Spacer(), | ||||||
|                 IconButton( |                 IconButton( | ||||||
|  | @ -83,7 +92,16 @@ class _ProgramScreenState extends State<ProgramScreen> { | ||||||
|                 Spacer(), |                 Spacer(), | ||||||
|                 Padding( |                 Padding( | ||||||
|                   padding: const EdgeInsets.only(right: 20.0), |                   padding: const EdgeInsets.only(right: 20.0), | ||||||
|                   child: Text('10:30'), |                   child: StreamBuilder<Duration>( | ||||||
|  |                     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<ProgramScreen> { | ||||||
|     positionSubscription.cancel(); |     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'); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -30,13 +30,13 @@ class _PlayPauseButtonState extends State<PlayPauseButton> | ||||||
|     super.didChangeDependencies(); |     super.didChangeDependencies(); | ||||||
| 
 | 
 | ||||||
|     backend = Backend.of(context); |     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) { |     if (playingSubscription != null) { | ||||||
|       playingSubscription.cancel(); |       playingSubscription.cancel(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     playingSubscription = backend.playing.listen((playing) => |     playingSubscription = backend.player.playing.listen((playing) => | ||||||
|         playing ? playPauseAnimation.forward() : playPauseAnimation.reverse()); |         playing ? playPauseAnimation.forward() : playPauseAnimation.reverse()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -47,7 +47,7 @@ class _PlayPauseButtonState extends State<PlayPauseButton> | ||||||
|         icon: AnimatedIcons.play_pause, |         icon: AnimatedIcons.play_pause, | ||||||
|         progress: playPauseAnimation, |         progress: playPauseAnimation, | ||||||
|       ), |       ), | ||||||
|       onPressed: backend.playPause, |       onPressed: backend.player.playPause, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -16,7 +16,7 @@ class PlayerBar extends StatelessWidget { | ||||||
|           mainAxisSize: MainAxisSize.min, |           mainAxisSize: MainAxisSize.min, | ||||||
|           children: <Widget>[ |           children: <Widget>[ | ||||||
|             StreamBuilder( |             StreamBuilder( | ||||||
|               stream: backend.position, |               stream: backend.player.normalizedPosition, | ||||||
|               builder: (context, snapshot) => LinearProgressIndicator( |               builder: (context, snapshot) => LinearProgressIndicator( | ||||||
|                 value: snapshot.data, |                 value: snapshot.data, | ||||||
|               ), |               ), | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ environment: | ||||||
|   sdk: ">=2.3.0 <3.0.0" |   sdk: ">=2.3.0 <3.0.0" | ||||||
| 
 | 
 | ||||||
| dependencies: | dependencies: | ||||||
|  |   audio_service: | ||||||
|   flutter: |   flutter: | ||||||
|     sdk: flutter |     sdk: flutter | ||||||
|   moor: |   moor: | ||||||
|  |  | ||||||
 Elias Projahn
						Elias Projahn