Flutter. Play audio with the progress bar and a bit more

Yuriy NovikovYuriy Novikov
7 min read

My new app has pretty long audio files, and I want users to be able to control the playing with a slider that will also show the progress.

Originally posted on Medium

Something similar to the above Medium control.

Also, I want the page to scroll automatically down to show the user the text that is playing now.

If you are a member, please continue, otherwise, read the full story here.

We will explore the following:

· 1. Playing audio in Flutter
· 2. Control audio with a slider
· 3. Make a slider showing the progress
· 4. Synchronizing playing and scrolling

1. Playing audio in Flutter

Flutter has two famous packages(plugins) for playing audio.

Audioplayers:

And just_audio:

They have similar numbers of likes and downloads.

The just_audio has a Flutter favorite badge. I have mixed feelings about this Flutter favorite thing. On one side, probably, very likely, if the package is prised as a favorite by the Flutter team, it cannot be complete trash. On the other, it affects future likes and downloads since many people choose it over a competitor based on just that.

I will try both packages.

flutter pub add audioplayers

flutter pub add just_audio
flutter pub add just_audio_windows //required on windows

Note, that for just_audio on Windows, we need to add the additional package.

First, I created the infrastructure folder in my project to include all audio-related classes.

Then, wrote the interface:

abstract interface class AudioPlayerInterface {
  Future<void> play(String assetPath);
  Future<void> pause();
  Future<void> stop();
  Future<Duration> getPosition();
  Future<void> setPosition(Duration position);
  Future<void> dispose();
  Stream<Duration> getPositionStream();
  Stream<bool> getPlayingStream();
  Future<Duration> getDuration();
  Stream<Duration> getDurationStream();
}

In general, audio can be played from:

  1. URL

  2. File in the file system

  3. File in the application assets folder

For my use case, I need to play it from the assets folder.

For that, we need to add the audio file to the folder and pubspec.yaml:

It is even better to specify just the folder, thus we don’t need to specify each audio file:

Here is the implementation of AudioPlayerInterface using the auidioplayers package:

import 'package:audioplayers/audioplayers.dart';
import 'audio_player_interface.dart';

class AudioPlayerAudioplayers implements AudioPlayerInterface {
  final AudioPlayer _audioPlayer = AudioPlayer();
  String? _currentAsset;

  @override
  Future<void> play(String assetPath) async {
    if (_currentAsset != assetPath) {
      _currentAsset = assetPath;
      await _audioPlayer.setSource(AssetSource(assetPath));
    }
    await _audioPlayer.resume();
  }

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

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

  @override
  Future<Duration> getPosition() async {
    return await _audioPlayer.getCurrentPosition() ?? Duration.zero;
  }

  @override
  Future<void> setPosition(Duration position) async {
    await _audioPlayer.seek(position);
  }

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

  @override
  Stream<Duration> getPositionStream() {
    return _audioPlayer.onPositionChanged;
  }

  @override
  Stream<bool> getPlayingStream() {
    return _audioPlayer.onPlayerStateChanged
        .map((state) => state == PlayerState.playing);
  }

  @override
  Future<Duration> getDuration() async {
    return await _audioPlayer.getDuration() ?? Duration.zero;
  }

  @override
  Stream<Duration> getDurationStream() {
    return _audioPlayer.onDurationChanged;
  }
}

And here is the one using just_audio:

import 'package:just_audio/just_audio.dart';
import 'audio_player_interface.dart';

class AudioPlayerJustAudio implements AudioPlayerInterface {
  final AudioPlayer _audioPlayer = AudioPlayer();
  String? _currentAsset;

  @override
  Future<void> play(String assetPath) async {
    assetPath = 'assets/$assetPath';
    if (_currentAsset != assetPath) {
      _currentAsset = assetPath;
      await _audioPlayer.setAsset(assetPath);
    }
    await _audioPlayer.play();
  }

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

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

  @override
  Future<Duration> getPosition() async {
    return _audioPlayer.position;
  }

  @override
  Future<void> setPosition(Duration position) async {
    await _audioPlayer.seek(position);
  }

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

  @override
  Stream<Duration> getPositionStream() {
    return _audioPlayer.positionStream;
  }

  @override
  Stream<bool> getPlayingStream() {
    return _audioPlayer.playingStream;
  }

  @override
  Future<Duration> getDuration() async {
    return _audioPlayer.duration ?? Duration.zero;
  }

  @override
  Stream<Duration> getDurationStream() {
    return _audioPlayer.durationStream
        .where((duration) => duration != null)
        .map((duration) => duration!);
  }
}

👉 Important difference:

The audioplayers package assumes by default that the audio file is under the assets folder. The just_audio doesn’t make any assumption and requires the full path to the file.

So, we need to add the line below to make our API consistent:

    assetPath = 'assets/$assetPath';

Besides this, both implementations are similar, with minor differences, though the just_audio looks more straightforward and concise.

Full code of our ViewModel (controller).

We will review the important parts of it later.

The full code of the View.

And this is the result:

Here is how we play the audio.

Controller:

Future<void> togglePlayPause() async {
    if (_isPlaying) {
      await audioPlayer.pause();
    } else {
      await audioPlayer.play('audio/text.mp3');
      _updateScroll();
    }
  }

View:

                       IconButton(
                          icon: Icon(
                            controller.isPlaying
                                ? Icons.pause_circle_filled
                                : Icons.play_circle_filled,
                            size: 48,
                          ),
                          onPressed: controller.togglePlayPause,
                        ),

Simple.

2. Control audio with a slider

Controller:

Future<void> seekTo(Duration position) async {
    try {
      // First pause to make seeking more reliable
      final wasPlaying = _isPlaying;
      if (wasPlaying) {
        await audioPlayer.pause();
      }

      // Add timeout to prevent hanging
      await audioPlayer.setPosition(position)
          .timeout(const Duration(seconds: 3), onTimeout: () {
        throw TimeoutException('Seek operation timed out');
      });

      // Resume if it was playing before
      if (wasPlaying) {
        await audioPlayer.play('audio/text.mp3');
        _updateScroll();
      }
    } catch (e) {
      print('Error seeking: $e');
      // Optionally show error to user
      Get.snackbar(
        'Error',
        'Failed to seek to position',
        snackPosition: SnackPosition.BOTTOM,
      );
      audioPlayer.stop();
      _isPlaying = false;
    }
  }

View:

                         Slider(
                            value: controller.position.inSeconds.toDouble(),
                            max: controller.duration.inSeconds.toDouble(),
                            onChanged: (value) {
                              controller.seekTo(
                                Duration(seconds: value.toInt()),
                              );
                            },
                          ),

When a user moves the slider the onchanged event occurs and the controller.seekTo method is called.

For the above to work we need to know the duration of an audio file. Our controller takes the duration from audio player:

...  
Duration _duration = Duration.zero;

@override
  void onInit() {
...
    _setupStreams();
...
  }

void _setupStreams() {
...

    audioPlayer.getDurationStream().listen((duration) {
      _duration = duration;
      print('duration ' + _duration.inSeconds.toString());
      update();
    });
  }

We then use the duration in Slider:

                        Slider(
                            value: controller.position.inSeconds.toDouble(),
                            max: controller.duration.inSeconds.toDouble(),

3. Make a slider showing the progress

In the controller, we listen to the position stream and every time we get a new position event we call the update method

@override
  void onInit() {
...
    _setupStreams();
...
  }

void _setupStreams() {
...

    audioPlayer.getPositionStream().listen((pos) {
      _position = pos;
      _updateScroll();
      update();
    });
  }
                    GetBuilder<AudioController>(
                          ...                         
                          child: Slider(
                            value: controller.position.inSeconds.toDouble(),
                            max: controller.duration.inSeconds.toDouble(),
                            onChanged: (value) {
                              controller.seekTo(
                                Duration(seconds: value.toInt()),
                              );
                            },
                          ),

4. Synchronizing playing and scrolling

In our controller, we call the method below every time the playing position changes:


  double lastScrollPosition = 0;
  void _updateScroll() {
    if (!_isPlaying || _totalTextHeight == 0) return;

    final progress = _position.inMilliseconds / _duration.inMilliseconds;
    final scrollPosition = _totalTextHeight * progress;

    if (scrollPosition - lastScrollPosition < 150) {
      return;
    }

    scrollController.animateTo(
      scrollPosition,
      duration: const Duration(milliseconds: 1700),
      curve: Curves.linear,
    );
    lastScrollPosition = scrollPosition;
  }

Every time the new scrollPosition is 150 px bigger than the previous, we scroll down.

And this method calculates the height of the Text widget:

final GlobalKey textKey = GlobalKey();  
...
void onInit() {
    ...
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _measureTextHeight();
    });
  }
...
  void _measureTextHeight() {
    final RenderBox? renderBox = 
        textKey.currentContext?.findRenderObject() as RenderBox?;
    if (renderBox != null) {
      _totalTextHeight = renderBox.size.height;
      print(_totalTextHeight);
    }
  }
              Text(
                '''All things change except barbers, the ways of barbers, and the surroundings of barbers. These never change. What one experiences in a barber's shop the first time he enters one is what he always experiences in barbers' shops afterward till the end of his days. I got shaved this morning as usual. A man approached the door from Jones Street as I approached it from Main—a thing that always happens. I hurried up, but it was of no use; he entered the door one little step ahead of me, and I followed in on his heels and saw him take the only vacant chair, the one presided over by the best barber. It always happens so. I sat down, hoping that I might fall heir to the chair belonging to the better of the remaining two barbers, for he had already begun combing his man's hair, while his comrade was not yet quite done rubbing up and oiling his customer's locks. ''',
                key: controller.textKey,  //<-
                style: const TextStyle(fontSize: 28),
              ),

First, we construct the Text widget with a key parameter that gets the textKey as an argument. And then we use the textKey to get RenderBox and height.

I think it may be too much to grasp just by skimming the article.

So, here is the full app in the repository. Clone it, play with it, and use it in your project. It is not thoroughly tested, but it is a starting point.

This article explores the implementation of audio playback functionality in Flutter using Audioplayers and just_audio packages. It covers setting up an audio player interface, handling audio files from application assets, and implementing playback controls such as play, pause, stop, and seek using a slider that shows audio progress. Additionally, it demonstrates synchronizing playback with automatic scrolling of text. Implementations for both Audioplayers and just_audio packages are provided, comparing their features and differences. The article concludes with a practical example and repository link for further exploration.

Thank you for reading!

0
Subscribe to my newsletter

Read articles from Yuriy Novikov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yuriy Novikov
Yuriy Novikov