-
-
Notifications
You must be signed in to change notification settings - Fork 480
Tutorial
This tutorial walks you through the steps to create a Flutter app that uses audio_service
to play audio in the background.
You should already have followed the project setup instructions in the audio_service
README. It will also help if you are familiar with Dart isolates.
This plugin runs all of your audio code in a separate "background" isolate that will survive the destruction of the main isolate that runs your Flutter UI. This allows your audio code to continue playing even when the UI is gone, or even destroyed and reclaimed from memory. Throughout this document, the code that runs in the main isolate will be referred to as your UI, and the code that runs in the background isolate will be referred to as the background audio task.
Since isolates do not share memory, your UI and background audio task will communicate via message passing. A number of standard messages are defined for communicating from the UI to the background audio task, and these are defined as static methods in the AudioService
class. They include:
start
play
pause
skipToNext
skipToPrevious
stop
The start
method is special in that it also creates the background isolate.
Messages are received and handled by your background audio task by defining a subclass of BackgroundAudioTask
and overriding corresponding callback methods:
onStart
onPlay
onPause
onSkipToNext
onSkipToPrevious
onStop
You need only override the particular callbacks that are useful in your app. There are additional standard messages, but if you need to send a message that is not among the predefined standard messages, you can use customAction/onCustomAction
. None of these callbacks do anything by default, and it is entirely up to your app what code you want to execute in onPlay
, onPause
, onSkipToNext
, and so on. This is where you actually write your code to play audio, or pause audio, or skip between different items.
Conversely, there are also a number of standard messages for communicating from the background audio task to the UI, and these are defined as static methods in the AudioServiceBackground
class. They include:
setState
setQueue
setMediaItem
Your background audio task should call setState
to send a message to the UI whenever it changes audio playback states. The defined playback states are playing
, paused
, buffering
, skippingToNext
and others defined in the BasicPlaybackState
enum. When your UI receives this message, it should update the UI to reflect the new state. Your background audio task should call setQueue
to send a message to the UI whenever the queue (aka "playlist") changes, so that the UI can update its display (if you have one), and you should call setMediaItem
to send a message to the UI whenever the currently playing item changes so that the UI can display this updated information. None of these messages are absolutely required, you will use only the particular messages that you need to communicate state changes that are useful to your app.
Your UI receives these messages not via callbacks but instead by listening to the following streams:
AudioService.playbackStateStream
AudioService.queueStream
AudioService.currentMediaItemStream
This plugin defines a standard set of messages for the following reason: this set of messages can also be understood by other user interfaces besides your Flutter UI, including: smart watches, Android Auto, headphones with play/pause/skip buttons, lock screen, Android media notifications, and the iOS control center. Thus, when your background audio task sends the setState
message to indicate a transition from playing
to paused
, this message will be received not only your Flutter UI, but also your lock screen, your car's Android Auto, and so on. Conversely, if you press the play button on one of these other UIs (e.g. on your watch), this message will be delivered to your background audio task allowing you to process play/pause requests from any connected UI.
Your Flutter UI can only send and receive messages to and from the background audio task while it is connected. Typically, your Flutter UI should only remain connected while it is visible, and while not connected, it will stop receiving messages. When your Flutter UI becomes visible, you should call AudioService.connect
, and when your Flutter UI is gone, you should call AudioService.disconnect
.
Let's start with a simple app with a button to start playing an MP3, and a button to stop playing.
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Example',
theme: ThemeData(primarySwatch: Colors.blue),
home: AudioServiceWidget(child: MainScreen()),
);
}
}
The AudioServiceWidget
should be inserted near the top of the widget tree to maintain the audio service connection across every route in your app.
Next, define the main screen with two buttons:
class MainScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Example")),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RaisedButton(child: Text("Start"), onPressed: start),
RaisedButton(child: Text("Stop"), onPressed: stop),
],
),
),
);
}
start() =>
AudioService.start(backgroundTaskEntrypoint: _backgroundTaskEntrypoint);
stop() => AudioService.stop();
}
With the UI more or less complete, we now must implement the background audio task which runs in the background isolate. To do this, we define _backgroundTaskEntrypoint
which must be a top-level or static function that initialises the isolate by calling AudioServiceBackground.run
as the very first line:
_backgroundTaskEntrypoint() {
AudioServiceBackground.run(() => AudioPlayerTask());
}
Now you can define your audio task which encapsulates all of your audio playing code:
import 'package:just_audio/just_audio.dart';
class AudioPlayerTask extends BackgroundAudioTask {
final _audioPlayer = AudioPlayer();
final _completer = Completer();
@override
Future<void> onStart() async {
await _audioPlayer.setUrl("https://exampledomain.com/song.mp3");
_audioPlayer.play();
await _completer.future;
}
@override
void onStop() {
_audioPlayer.stop();
_completer.complete();
}
}
When the UI calls AudioService.start
, the background isolate is created, and your onStart
callback is called within that isolate. audio_service
will shut down the background isolate as soon as the future returned by your onStart
method completes. When the UI calls AudioService.stop
, your onStop
callback is called within the background isolate. In the above example, we use onStart
to start audio playback, and onStop
to stop audio playback and also complete the future that causes the background isolate to shut down.
You are free to implement the background audio task callbacks in any way that is appropriate for your app. For example, the following alternative task will use text-to-speech to speak the numbers 1 to 10:
import 'package:flutter_tts/flutter_tts.dart';
class AudioPlayerTask extends BackgroundAudioTask {
final _tts = FlutterTts();
bool _finished = false;
@override
Future<void> onStart() async {
for (var n = 1; !_finished && n <= 10; n++) {
_tts.speak("$n");
await Future.delayed(Duration(seconds: 1);
}
}
@override
void onStop() {
_finished = true;
}
}
In the previous example, each time the user presses start and stop, audio_service
has to spin up a new isolate and shut it down. This comes with a lot of overheads since audio_service
will interface with the platform to properly set up the background execution environment. Instead, an app will typically provide a pause button which puts your app into a state that is not completely dead and is ready to quickly resume playback on demand via a play button.
Let's add play and pause buttons to our app:
class MainScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
...
RaisedButton(child: Text("Play"), onPressed: play),
RaisedButton(child: Text("Pause"), onPressed: pause),
...
}
...
play() => AudioService.play();
pause() => AudioService.pause();
}
Now let's implement the callbacks in the background audio task:
class AudioPlayerTask extends BackgroundAudioTask {
...
@override
void onPlay() {
_audioPlayer.play();
}
@override
void onPause() {
_audioPlayer.pause();
}
}
That's it!
There's one problem with the previous example: The 4 buttons are always visible. Ideally, the visible buttons should reflect the current state of the background audio task. Let's modify the example to manage state changes.
For each state that our app can be in, we need to define the set of media controls that can be used to control playback. This is necessary so that other UIs outside of your Flutter UI will know how they can interact with your audio task. E.g. In Android, this list of controls will be displayed on the lock screen and in a notification as clickable buttons. Here are the controls we will define for our app:
final playControl = MediaControl(
androidIcon: 'drawable/ic_action_play_arrow',
label: 'Play',
action: MediaAction.play,
);
final pauseControl = MediaControl(
androidIcon: 'drawable/ic_action_pause',
label: 'Pause',
action: MediaAction.pause,
);
final stopControl = MediaControl(
androidIcon: 'drawable/ic_action_stop',
label: 'Stop',
action: MediaAction.stop,
);
(See the project README for how to set up the icons for Android.)
Now let's update the background audio task to broadcast appropriate state changes along with the set of media controls that should be available in each state:
class AudioPlayerTask extends BackgroundAudioTask {
final AudioPlayer _audioPlayer = AudioPlayer();
final _completer = Completer();
@override
Future<void> onStart() async {
// Broadcast that we're playing, and what controls are available.
AudioServiceBackground.setState(
controls: [pauseControl, stopControl],
basicState: BasicPlaybackState.playing);
await _audioPlayer.setUrl("https://exampledomain.com/song.mp3");
_audioPlayer.play();
await _completer.future;
// Broadcast that we've stopped.
AudioServiceBackground.setState(
controls: [], basicState: BasicPlaybackState.playing);
}
@override
void onStop() {
_audioPlayer.stop();
_completer.complete();
}
@override
void onPlay() {
// Broadcast that we're playing, and what controls are available.
AudioServiceBackground.setState(
controls: [pauseControl, stopControl],
basicState: BasicPlaybackState.playing);
_audioPlayer.play();
}
@override
void onPause() {
// Broadcast that we're paused, and what controls are available.
AudioServiceBackground.setState(
controls: [playControl, stopControl],
basicState: BasicPlaybackState.playing);
_audioPlayer.pause();
}
}
All UIs should now reflect these state changes, except for the Flutter UI itself for which we'll need to write our own code to make that happen. To do that, we will listen to the AudioService.playbackStateStream
. Flutter provides an easy way to make a widget responsive to the changing values of a stream using StreamBuilder
:
class MainScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Example")),
body: Center(
child: StreamBuilder<PlaybackState>(
stream: AudioService.playbackStateStream,
builder: (context, snapshot) {
final state =
snapshot.data?.basicState ?? BasicPlaybackState.stopped;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (state == BasicPlaybackState.playing)
RaisedButton(child: Text("Pause"), onPressed: pause)
else
RaisedButton(child: Text("Play"), onPressed: play),
if (state != BasicPlaybackState.stopped)
RaisedButton(child: Text("Stop"), onPressed: stop),
],
);
},
),
),
);
}
play() async {
if (await AudioService.running) {
AudioService.play();
} else {
AudioService.start(backgroundTaskEntrypoint: _backgroundTaskEntrypoint);
}
}
pause() => AudioService.pause();
stop() => AudioService.stop();
}
In the above example, we show either the "play" or the "pause" button depending on the current state, but never both at the same time. We show the "stop" button only if we are currently not stopped. We also have merged the "start" button into the "play" button so that "play" will first check if the service is already running, and if it is, it calls the play
method, and otherwise calls the start
method.
We have now come full circle. In the Flutter UI, we define buttons that send messages to the background audio task. The background audio task plays audio and modifies playback according to those messages it receives. The background audio task also broadcasts state changes back to the Flutter UI, where the UI updates itself according to the new state.
Using this message passing system, your Flutter UI will also update correctly in response to pressing the play/pause buttons on your headset, your smart watch, and other compatible UIs.
For a more complete example with a queue, skip buttons and seeking, you are encouraged to refer to audio_service/example
.