Skip to content
AB

Background Audio in Flutter Is More Than Pressing Play

Build one reliable Flutter audio system for the app, lock screen, notification controls, interruptions, and background playback.

Ashish BhakhandPublished

My first audio screens owned an AudioPlayer directly. The page loaded a URL, listened to position updates, and disposed the player when the route closed.

That works until audio should continue after navigation, while the phone is locked, or from notification controls. If several screens each create a player, the app can also play two tracks at once and show conflicting state.

The problem is ownership. A background-capable player belongs to the application, not to one page.

Give each package one job#

just_audio handles playback: loading sources, playing, pausing, seeking, speed, playlists, and player streams.

audio_service connects playback to the operating system: background execution, media notifications, lock-screen controls, headset buttons, and system media state.

Use one application-level audio handler containing one player:

Code
class AppAudioHandler extends BaseAudioHandler with SeekHandler {
  AppAudioHandler(this._player) {
    _player.playbackEventStream.listen(_broadcastState);
  }

  final AudioPlayer _player;

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

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

  @override
  Future<void> seek(Duration position) => _player.seek(position);

  @override
  Future<void> stop() async {
    await _player.stop();
    return super.stop();
  }
}

Initialize it once during startup:

Code
final audioHandler = await AudioService.init(
  builder: () => AppAudioHandler(AudioPlayer()),
  config: const AudioServiceConfig(
    androidNotificationChannelId: 'app.audio',
    androidNotificationChannelName: 'Audio playback',
  ),
);

Every page now controls the same session.

Broadcast the state the system needs#

The operating system cannot inspect AudioPlayer directly. The handler must publish the current item and playback state:

Code
void _broadcastState(PlaybackEvent event) {
  playbackState.add(
    PlaybackState(
      controls: [
        if (_player.playing) MediaControl.pause else MediaControl.play,
        MediaControl.stop,
      ],
      systemActions: const {
        MediaAction.seek,
        MediaAction.seekForward,
        MediaAction.seekBackward,
      },
      processingState: mapProcessingState(_player.processingState),
      playing: _player.playing,
      updatePosition: _player.position,
      bufferedPosition: _player.bufferedPosition,
      speed: _player.speed,
    ),
  );
}

Set a MediaItem when loading a track so the lock screen has a title, artwork, duration, and stable identifier.

Keep widgets away from the player#

Expose a small controller:

Code
abstract interface class AudioController {
  Stream<PlaybackState> get playbackState;
  Stream<Duration> get position;

  Future<void> play(Track track);
  Future<void> pause();
  Future<void> seek(Duration position);
  Future<void> stop();
}

The UI should not know how notification state is created or which package loads the URL. This also makes a BLoC and widget tests much easier to write.

Define navigation behavior explicitly#

Not every sound should survive navigation. A meditation track may continue across tabs and show a mini-player. Audio tied to a lesson may stop when that lesson closes.

Keep one audio engine, but include session context:

Code
await controller.play(
  Track(
    id: lesson.id,
    source: AudioSourceKind.lesson,
    shouldPersistAcrossRoutes: false,
  ),
);

The route can stop only the session it owns. This is safer than creating a second player for temporary audio.

Handle interruptions and cleanup#

Phone calls, another media app, headphone removal, and Bluetooth changes can interrupt playback. Configure the platform audio session and test these cases on devices.

Also decide what stop means:

  • pause keeps the source and position;
  • stop releases playback resources;
  • completion may reset, advance, or retain the final position;
  • logout may clear protected media state.

The player should have one owner and one disposal path. Route widgets should unsubscribe from streams, but they should not dispose an application-scoped handler.

Test more than the play button#

Test:

  • play, pause, seek, speed, completion, and failure;
  • screen lock and backgrounding;
  • notification and headset controls;
  • navigation while playing;
  • interruptions and headphone removal;
  • two screens requesting audio;
  • mini-player state after the original page closes.

Background audio became reliable for me only after the app, UI, and operating system all observed the same handler. The packages provide the platform integration; clear ownership prevents the harder bugs.

← Back to all writing