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.
This commit is contained in:
Elias Projahn 2020-04-18 13:50:38 +02:00
parent e0fc60f9eb
commit 3471fcf78b
31 changed files with 321 additions and 44 deletions

View file

@ -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
} }
} }
} }

View file

@ -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" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

View file

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

View file

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

View file

@ -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
View 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();
}
}

View file

@ -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,

View file

@ -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');
}
}

View file

@ -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,
); );
} }

View file

@ -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,
), ),

View file

@ -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: