Flutter. Воспроизведение аудио с полосой прогресса и немного больше

Flutter. Воспроизведение аудио с полосой прогресса и немного больше

FlutterPulse

Эта статья переведена специально для канала FlutterPulse. В этом канале вы найдёте много интересных вещей, связанных с Flutter. Не забывайте подписываться! 🚀

У моего нового приложения довольно длинные аудиофайлы, и я хочу, чтобы пользователи могли управлять воспроизведением с помощью ползунка, который также будет показывать прогресс.

Что-то похожее на вышеуказанный контроль Medium.

Также я хочу, чтобы страница автоматически прокручивалась вниз, чтобы показать пользователю текст, который сейчас воспроизводится.

Если вы являетесь участником, пожалуйста, продолжайте, иначе, читайте полную историю здесь.

Мы рассмотрим следующее:

· 1. Воспроизведение аудио в Flutter
· 2. Управление аудио с помощью ползунка
· 3. Создание ползунка, показывающего прогресс
· 4. Синхронизация воспроизведения и прокруткиg

1. Воспроизведение аудио в Flutter

У Flutter есть два известных пакета(плагины) для воспроизведения аудио.

Audioplayers:

И just_audio:

У них похожие числа лайков и загрузок.

The just_audio имеет значок избранного Flutter. У меня смешанные чувства по поводу этого избранного Flutter. С одной стороны, вероятно, очень вероятно, что если пакет отмечен как избранный командой Flutter, он не может быть полным мусором. С другой стороны, это влияет на будущие лайки и загрузки, так как многие люди выбирают его вместо конкурента только на этом основании.

Я попробую оба пакета.

flutter pub add audioplayers

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

Обратите внимание, что для just_audio на Windows нам нужно добавить дополнительный пакет.

Сначала я создал папку инфраструктура в своем проекте, чтобы включить все классы, связанные с аудио.

Затем, напишите интерфейс:

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();
}

В общем, аудио можно воспроизводить из:

  1. URL
  2. Файла в файловой системе
  3. Файла в папке assetsприложения

Для моего случая использования мне нужно воспроизводить его из папки assets.

Для этого нужно добавить аудиофайл в папку и pubspec.yaml:

Ещё лучше указать только папку, таким образом нам не нужно указывать каждый аудиофайл:

Вот реализация AudioPlayerInterfaceс использованием пакета auidioplayers:

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;
  }
}

А вот реализация с использованием 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!);
  }
}

👉 Важное отличие:

Пакет audioplayersпо умолчанию предполагает, что аудиофайл находится в папке assets. Пакет just_audioникаких предположений не делает и требует полного пути к файлу.

Поэтому нам нужно добавить строку ниже, чтобы сделать наш API согласованным:

    assetPath = 'assets/$assetPath';

Помимо этого, обе реализации похожи, с незначительными различиями, хотя just_audioвыглядит более простым и лаконичным.

Полный код нашей ViewModel (контроллера).

Мы рассмотрим важные части этого позже.

Полный код View.

Вот такой результат:

Вот как мы воспроизводим аудио.

Контроллер:

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,
                        ),

Просто.

2. Управление аудио с помощью слайдера

Контроллер:

Future<void> seekTo(Duration position) async {
    try {
      // Сначала пауза для более надежного поиска
      final wasPlaying = _isPlaying;
      if (wasPlaying) {
        await audioPlayer.pause();
      }

      // Добавление таймаута для предотвращения зависания
      await audioPlayer.setPosition(position)
          .timeout(const Duration(seconds: 3), onTimeout: () {
        throw TimeoutException('Seek operation timed out');
      });

      // Возобновление, если воспроизводилось ранее
      if (wasPlaying) {
        await audioPlayer.play('audio/text.mp3');
        _updateScroll();
      }
    } catch (e) {
      print('Error seeking: $e');
      // Опционально показать ошибку пользователю
      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()),
                              );
                            },
                          ),

Когда пользователь перемещает слайдер, происходит событие onchanged и вызывается метод controller.seekTo.

Для работы всего этого нам нужно знать длительность аудиофайла. Наш контроллер получает длительность от аудиоплеера:

...  
Duration _duration = Duration.zero;

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

void _setupStreams() {
...

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

Затем мы используем длительность в Slider:

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

3. Создайте слайдер, показывающий прогресс

В контроллере мы слушаем поток позиции, и каждый раз, когда мы получаем новое событие позиции, мы вызываем метод update

@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. Синхронизация воспроизведения и прокрутки

В нашем контроллере мы вызываем метод ниже каждый раз, когда меняется позиция воспроизведения:

  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;
  }

Каждый раз, когда новая scrollPositionна 150 пикселей больше предыдущей, мы прокручиваем страницу вниз.

А этот метод вычисляет высоту виджета Text:

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),
              ),

Сначала мы создаем виджет Text с параметром key, который получает textKey в качестве аргумента. А затем мы используем textKey для получения RenderBox и высоты.

Я думаю, что это может быть слишком сложно для понимания, просто пробежавшись по статье.

Поэтому вот полное приложение в репозитории. Склонируйте его, попробуйте его в действии и используйте в своем проекте. Оно не было тщательно протестировано, но это хорошая отправная точка.

Спасибо за чтение!


Report Page