Skip to content
ryanheise edited this page Jul 12, 2021 · 23 revisions

Tutorial

This tutorial walks you through the steps to create a Flutter app that uses audio_service to play audio in the background.

There are two tutorials below, one for the current 0.17.x release and one for the 0.18.x preview (recommended).

0.17.x tutorial

Prerequisites

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.

Concepts

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 and stop methods are special in that they also set up and tear down 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 overall playback state is a collection of different states including whether or not audio is playing, whether or not audio is buffering, the playback position, the current playback speed and others. 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.

Note: The media notification will not show until after the first time you call setState(playing: true, ...) AND after you provide information about the currently playing item by calling setMediaItem.

Your Flutter 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==true to playing==false, 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. You can wrap your application in an AudioServiceWidget to automatically maintain a connection. Otherwise you can manually call AudioService.connect when your UI becomes visible and AudioService.disconnect when your UI is gone.

Starting and stopping a background audio task

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 automatically 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: [
            ElevatedButton(child: Text("Start"), onPressed: start),
            ElevatedButton(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 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(Map<String, dynamic> params) async {
    // Connect to the URL
    await _audioPlayer.setUrl("https://exampledomain.com/song.mp3");
    // Now we're ready to play
    _audioPlayer.play();
  }

  @override
  Future<void> onStop() async {
    // Stop playing audio
    await _audioPlayer.stop();
    // Shut down this background task
    await super.onStop();
  }
}

When the UI calls AudioService.start, the background isolate is created, and your onStart callback is called within that isolate. When the UI calls AudioService.stop, your onStop callback is called within the background isolate and the isolate is destroyed as soon as this method completes. In the above example, we use onStart to start audio playback, and onStop to stop audio playback.

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;
  Completer _completer = Completer();

  @override
  Future<void> onStart() async {
    for (var n = 1; !_finished && n <= 10; n++) {
      _tts.speak("$n");
      await Future.delayed(Duration(seconds: 1);
    }
    _completer.complete();
  }

  @override
  void onStop() async {
    // Stop speaking the numbers
    _finished = true;
    // Wait for `onStart` to complete
    await _completer.future;
    // Now we're ready to let the isolate shut down
    await super.onStop();
  }
}

Pausing and resuming playback

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) {
            ...
            ElevatedButton(child: Text("Play"), onPressed: play),
            ElevatedButton(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
  Future<void> onPlay() async {
    await _audioPlayer.play();
  }

  @override
  Future<void> onPause() async {
    await _audioPlayer.pause();
  }
}

That's it!

Updating the UI in response to state changes

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.

In the following code, we will update the background audio task to broadcast 3 different kinds of state:

  1. playing (true or false)
  2. processingState (connecting, ready to play or stopped)
  3. controls (the set of controls visible in the iOS control center and Android notification)

Let's update the background audio task to broadcast appropriate state changes:

class AudioPlayerTask extends BackgroundAudioTask {
  final AudioPlayer _audioPlayer = AudioPlayer();

  @override
  Future<void> onStart(Map<String, dynamic> params) async {
    // Broadcast that we're connecting, and what controls are available.
    AudioServiceBackground.setState(
        controls: [MediaControl.pause, MediaControl.stop],
        playing: true,
        processingState: AudioProcessingState.connecting);
    // Connect to the URL
    await _audioPlayer.setUrl("https://exampledomain.com/song.mp3");

    // Now we're ready to play
    _audioPlayer.play();
    // Broadcast that we're playing, and what controls are available.
    AudioServiceBackground.setState(
        controls: [MediaControl.pause, MediaControl.stop],
        playing: true,
        processingState: AudioProcessingState.ready);
  }

  @override
  Future<void> onStop() async {
    // Stop playing audio.
    _audioPlayer.stop();
    // Broadcast that we've stopped.
    await AudioServiceBackground.setState(
        controls: [],
        playing: false,
        processingState: AudioProcessingState.stopped);
    // Shut down this background task
    await super.onStop();
  }

  @override
  Future<void> onPlay() async {
    // Broadcast that we're playing, and what controls are available.
    AudioServiceBackground.setState(
        controls: [MediaControl.pause, MediaControl.stop],
        playing: true,
        processingState: AudioProcessingState.ready);
    // Start playing audio.
    await _audioPlayer.play();
  }

  @override
  Future<void> onPause() async {
    // Broadcast that we're paused, and what controls are available.
    AudioServiceBackground.setState(
        controls: [MediaControl.play, MediaControl.stop],
        playing: false,
        processingState: AudioProcessingState.ready);
    // Pause the audio.
    await _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 playing = snapshot.data?.playing ?? false;
            final processingState = snapshot.data?.processingState
                ?? AudioProcessingState.stopped;
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                if (playing)
                  ElevatedButton(child: Text("Pause"), onPressed: pause)
                else
                  ElevatedButton(child: Text("Play"), onPressed: play),
                if (processingState != BasicPlaybackState.stopped)
                  ElevatedButton(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.

Conclusion

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.

0.18.x tutorial

Prerequisites

You should already have followed the project setup instructions in the audio_service one-isolate README.

Concepts

This plugin encapsulates all of your audio code in an object called an audio handler. This object handles requests to perform media actions such as play, pause and seek. These requests are handled in a uniform way from potentially multiple clients such as the Flutter UI, the notification, a smart watch, a headset, or Android Auto. When your Flutter UI is absent, either in the background or with the screen off, your audio handler may still continue to respond to these other clients.

You define an audio handler by creating a subclass of BaseAudioHandler and overriding the methods for the media actions that your app wants to handle. Some common methods to override include:

  • play
  • pause
  • seek
  • skipToNext
  • skipToPrevious
  • stop

Your implementations of these methods decides what audio your app will play, whether that is music, a podcast, a text-to-speech reading of an audio book, or even voice instructions by a fitness trainer app.

Your audio handler is also responsible for broadcasting state changes to its clients so that they can update their UI accordingly. For example, your audio handler is responsible for broadcasting whether playback is currently in the playing or paused state, whether audio is currently buffering, and metadata (title, artist, duration) about the currently playing media item. The state information your audio handler broadcasts will be used by clients such as the notification and lock screen to display correct metadata and playback state to the user, as well as by your own Flutter UI in app. Your audio handler broadcasts state changes via streams. The more common streams for broadcasting state are:

  • playbackState
  • queue
  • mediaItem

You add an event to a stream (i.e. broadcast an event) by using code such as queue.add(myCurrentMediaItem) where myCurrentMediaItem is an object containing metadata about the current item being played (title, artist, etc.). You should broadcast to mediaItem whenever the currently playing item changes, you should broadcast to queue whenever the playlist changes, and you should broadcast to playbackState when the state of playback changes (e.g. playing vs paused). Broadcasting this information allows, for example, the notification to display the correct metadata and correct state for its buttons.

Playing and pausing audio

Let's start with a simple app that plays a single mp3 file with a button to play and another button to pause.

During your app's initialisation, you initialise audio_service with your audio handler:

late AudioHandler _audioHandler; // singleton.
Future<void> main() async {
  _audioHandler = await AudioService.init(
    builder: () => AudioPlayerHandler(),
    config: AudioServiceConfig(
      androidNotificationChannelId: 'com.mycompany.myapp.channel.audio',
      androidNotificationChannelName: 'Audio playback',
      androidNotificationOngoing: true,
    ),
  );
  runApp(MyApp());
}

This initialisation code provides your audio handler class, which will be AudioPlayerHandler, and the resulting instantiated handler object is stored into a global singleton called _audioHandler (although in your app you may instead prefer to use a service locator or dependency injection.)

Your audio handler class is defined as a subclass of BaseAudioHandler:

import 'package:just_audio/just_audio.dart';

class AudioPlayerHandler extends BaseAudioHandler {
  final _player = AudioPlayer();

  AudioPlayerHandler() {
    _player.setUrl("https://exampledomain.com/song.mp3");
  }

  @override
  Future<void> play() => _player.play();

  @override
  Future<void> pause() => _player.pause();
}

This simple handler "handles" only the play and pause media actions, and it does so by asking the just_audio _player object to play/pause the audio it has loaded.

With the audio logic out of the way, let's now define our user interface with two buttons for playing and pausing:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(title: 'Example', home: MainScreen());
  }
}

class MainScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Example")),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(child: Text("Play"), onPressed: _audioHandler.play),
            ElevatedButton(child: Text("Pause"), onPressed: _audioHandler.pause),
          ],
        ),
      ),
    );
  }
}

You are free to implement the audio handler methods in any way that is appropriate for your app. For example, the following alternative audio handler will use text-to-speech to speak the numbers 1 to 10:

import 'package:flutter_tts/flutter_tts.dart';

class AudioPlayerHandler extends BaseAudioHandler {
  final _tts = FlutterTts();
  bool _finished = false;

  @override
  Future<void> play() async {
    for (var n = 1; !_finished && n <= 10; n++) {
      _tts.speak("$n");
      await Future.delayed(Duration(seconds: 1));
    }
  }

  @override
  Future<void> stop() async {
    // Stop speaking the numbers
    _finished = true;
  }
}

Note: stop is another media action that stops audio playback completely so that it can't be resumed from the current position.

That's it!

Updating the UI in response to state changes

There's one problem with the previous example: The 2 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.

In the following code, we will update the audio handler to broadcast 3 different kinds of state:

  1. playing (true or false)
  2. processingState (connecting, ready to play or stopped)
  3. controls (the set of controls visible in the iOS control center and Android notification)

Let's update the audio handler to broadcast appropriate state changes. First, in the constructor, let's broadcast that we're loading the audio:

class AudioPlayerHandler extends BaseAudioHandler {
  final _player = AudioPlayer();

  @override
  AudioPlayerHandler() {
    // Broadcast that we're loading, and what controls are available.
    playbackState.add(playbackState.value.copyWith(
        controls: [MediaControl.play],
        processingState: AudioProcessingState.loading,
    ));
    // Connect to the URL
    _audioPlayer.setUrl("https://exampledomain.com/song.mp3").then((_) {
      // Broadcast that we've finished loading
      _playbackState.add(playbackState.value.copyWith(
        processingState: AudioProcessingState.ready,
      );
    });
  }

Note that for the parameter to playbackState.add() we use the playbackState.value.copyWith() method to create a copy of the current state while only specifying the parts of the state that have changed. At the beginning, the processingState will initially pass through a loading state before arriving at ready-to-play. We also broadcast that we initially want just a MediaControl.play button to be available in the user interface. This request will be respected by the system notification, and we will later also update our own Flutter UI to do something similar.

Next, in the play and pause methods, let's broadcast that the playing state has changed. When we're playing, we want only the pause button to be available, and while we're paused, we want only the play button to be available:

  @override
  Future<void> play() async {
    playbackState.add(playbackState.value.copyWith(
      playing: true,
      controls: [MediaControl.pause],
    ));
    await _player.play();
  }

  @override
  Future<void> pause() async {
    playbackState.add(playbackState.value.copyWith(
      playing: false,
      controls: [MediaControl.play],
    ));
    await _player.pause();
  }
}

All clients will now respect 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 playbackState stream. 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: _audioHandler.playbackState,
          builder: (context, snapshot) {
            final playing = snapshot.data?.playing ?? false;
            final processingState = snapshot.data?.processingState
                ?? AudioProcessingState.idle;
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                if (playing)
                  ElevatedButton(child: Text("Pause"), onPressed: _audioHandler.pause)
                else
                  ElevatedButton(child: Text("Play"), onPressed: _audioHandler.play),
              ],
            );
          },
        ),
      ),
    );
  }
}

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.

Releasing resources

When the app has finished playing audio, it is good practice to release any system resources held for audio playback, and you may also want to deactivate the system notification. The most appropriate media action to handle this case is stop which can be implemented in your audio handler as follows:

class AudioPlayerHandler extends BaseAudioHandler {

  ...

  @override
  Future<void> stop() async {
    // Release any audio decoders back to the system
    await _player.stop();

    // Set the audio_service state to `idle` to deactivate the notification.
    playbackState.add(playbackState.value.copyWith(
      processingState: AudioProcessingState.idle,
    ));
  }
}

Conclusion

We have now come full circle. In the Flutter UI, we define buttons that send requests to the audio handler. The audio handler plays audio and modifies playback according to those requests it receives. The audio handler also broadcasts state changes back to the Flutter UI, where the UI updates itself according to the new state.

By making your audio handler the single source of truth for state and the single responsible party for audio logic, your Flutter UI will also update correctly in response to pressing the play/pause buttons on any client: 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.

problema con la cache 'package:audio_service/audio_service.dart': Failed assertion: line 917 pos 12: '_cacheManager == null': is not true.

Clone this wiki locally