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.
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="de.johrpan.musicus">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name="io.flutter.app.FlutterApplication"
|
||||
android:label="Musicus"
|
||||
|
|
@ -21,6 +24,18 @@
|
|||
</intent-filter>
|
||||
</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
|
||||
android:name="flutterEmbedding"
|
||||
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();
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Backend> {
|
||||
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,6 +107,7 @@ class BackendState extends State<Backend> {
|
|||
Future<void> _load() async {
|
||||
_moorIsolate = await _createMoorIsolate();
|
||||
final dbConnection = await _moorIsolate.connect();
|
||||
player.setup();
|
||||
db = Database.connect(dbConnection);
|
||||
|
||||
_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
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
runApp(AudioServiceWidget(
|
||||
child: Backend(
|
||||
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) {
|
||||
if (selected == 0) {
|
||||
backend.startPlayer();
|
||||
backend.player.start();
|
||||
} else if (selected == 1) {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
|||
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<ProgramScreen> {
|
|||
},
|
||||
onChangeEnd: (pos) {
|
||||
seeking = false;
|
||||
backend.seekTo(pos);
|
||||
backend.player.seekTo(pos);
|
||||
},
|
||||
onChanged: (pos) {
|
||||
setState(() {
|
||||
|
|
@ -68,7 +68,16 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
|||
children: <Widget>[
|
||||
Padding(
|
||||
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(),
|
||||
IconButton(
|
||||
|
|
@ -83,7 +92,16 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
|||
Spacer(),
|
||||
Padding(
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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<PlayPauseButton>
|
|||
icon: AnimatedIcons.play_pause,
|
||||
progress: playPauseAnimation,
|
||||
),
|
||||
onPressed: backend.playPause,
|
||||
onPressed: backend.player.playPause,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class PlayerBar extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
StreamBuilder(
|
||||
stream: backend.position,
|
||||
stream: backend.player.normalizedPosition,
|
||||
builder: (context, snapshot) => LinearProgressIndicator(
|
||||
value: snapshot.data,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ environment:
|
|||
sdk: ">=2.3.0 <3.0.0"
|
||||
|
||||
dependencies:
|
||||
audio_service:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
moor:
|
||||
|
|
|
|||