シンプル＆爆速：just_audioとRiverpodで完成する音声再生アプリ

## 背景と実施したこと

Flutterアプリにて音声再生機能を作りたくなったので作ってみました。
ググってみると音声再生機能のコード例はたくさんある一方で、昨今主流のRiverpod2.0系での書き方の例はあまりなかったので、ここに残しておきたいと思います。


主なパッケージver情報は以下の通りです。
【音声再生】
audio_video_progress_bar: ^2.0.1
just_audio: ^0.9.36
audio_session: ^0.1.18
rxdart: ^0.27.7

【状態管理】
flutter_riverpod: ^2.4.9
riverpod_annotation: ^2.3.3
build_runner: ^2.4.7
riverpod_generator: ^2.3.9

【DB操作】
supabase_flutter: ^1.10.25

【その他】
Flutter: 3.13.1



[参考にした記事]
[just_audioで音を再生する](https://zenn.dev/r0227n/articles/085c234061235e "just_audioで音を再生する")
[just_audioを使って音声ファイルを再生する](https://qiita.com/heyhey1028/items/4d342e24cc168db378e1 "just_audioを使って音声ファイルを再生する")
[Best Flutter music streaming options](https://blog.logrocket.com/best-flutter-music-streaming-options/#flutter-music-streaming-packages-comparison "Best Flutter music streaming options")
[just_audio code example](https://github.com/ryanheise/just_audio/blob/minor/just_audio/example/lib/main.dart "just_audio code example")
[Riverpod (リバーポッド) 完全ガイド](https://www.youtube.com/watch?v=0HVru4WtdWo "Riverpod (リバーポッド) 完全ガイド")


## 概要・ポイント
+ 音声を再生自体はjust_audioを使うと楽。若干面倒なのはシークバーの部分。
+ シークバーは自作してもいいがaudio_video_progress_barを使うと楽。
+ rxdartで合成したStreamをriverpodで管理してシークバーの挙動を実現できる。


## 前提
### アプリの全体像
現在作成しているのは以下のような挙動のアプリです。
<iframe width="560" height="315" src="https://www.youtube.com/embed/ZHeccWB_W6M?si=aZV1qn9OrQq637UI" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
このうち音声再生部分を抜粋して本記事の中で解説します。
見てわかる通り、音声再生ページはシークバーと再生ボタンと一時停止ボタンのシンプルな構成にしています。
シークバーを任意の位置に動かすことで再生位置を変更し、音声再生画面から一つ前に戻ると音声再生を停止する仕様です。

※細かなエラーハンドリングができていない点はご容赦ください。

### データベースとストレージ
DB＆StorageはSupabaseを使っています。Storageにmp3ファイルを格納し、Audio_Contentテーブルに対象のIDとURLを格納しています。
ちなみにテスト用のmp3ファイルはこちらから拝借しました。
https://www.ne.jp/asahi/music/myuu/wave/wave.htm


## 考え方
今回必要な状態管理は主に以下の２つです。
1. 音源をDBから取得してプレイヤーにセットアップするまでの状態管理
1. 音楽再生プレイヤー各種イベント状況の状態管理

### 状態管理① 音源をDBから取得してプレイヤーにセットアップするまでの状態管理
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635205/e46c3b6e-c26a-1f7e-bb0d-820b1ad01e5f.png)
DBからファイル情報を読み取るまではCircularProgressIndicatorを表示します。
読み込みと再生準備が完了後が再生・一時停止・シークバーを表示します。
音声ファイルの読み取り、再生、停止などはjust_audioのパッケージにすべて含まれています。
あまり難しいことを考えることなく対象ファイルのURLをセットして、お目当てのメソッドを使うだけで音声再生はできちゃいます。すごい。

### 状態管理② 音楽再生プレイヤー各種イベント状況の状態管理
これは以下のデータ群に大別できます。
+ どこまで再生したているかのposition
+ 音声ファイルの長さ
+ プレイヤーの各種イベント
    + 読み込み中
    + 再生準備完了
    + 再生完了　などなど
    
これらの情報をUIに詰め込みシークバーを実現します。
シークバーの実装はaudio_video_progress_barを使います。
audio_video_progress_barの公式ページにはjust_audioを使ったサンプルコードも載っているため使いやすいです。

## 実装
### 音源をDBから取得してプレイヤーにセットアップするまで
関連する実装は以下の通りです。

```dart:page_audio.dart
final supabase = Supabase.instance.client;
late AudioPlayer player;

//準備
Future<List<AudioInfo>> setupAudio(String id) async {
  player = AudioPlayer();
  final session = await AudioSession.instance;
  await session.configure(const AudioSessionConfiguration.music());
  
  final audioInfoList = await getAudioInfo(id);
  await loadAudioFile(audioInfoList[0].audioUrl);
      
  return audioInfoList;
}

//読み込み
Future<void> loadAudioFile(String url) async {
  try {
    await player.setUrl(url);
  } catch (e) {
    print(e.toString());
  }
}

//対象レコードの取得および初期セットができているかの状態
@riverpod
class AudioInfoStateNotifier extends _$AudioInfoStateNotifier {
  @override
  Future<List<AudioInfo>> build(String id) async {
    final setupState = await setupAudio(id);
    return setupState;
  }
}

//レコード取得
Future<List<AudioInfo>> getAudioInfo(String id) async {
  final audioInfoList = await supabase
      .from('Audio_Content')
      .select<List<Map<String, dynamic>>>('content_id,audio_url')
      .eq('content_id', id)
      .then((data) => data.map((json) => AudioInfo.fromJson(json)).toList());

  return audioInfoList;
}

class AudioInfo {
  final String contentId;
  final String audioUrl;

  AudioInfo({
    required this.contentId,
    required this.audioUrl,
  });

  factory AudioInfo.fromJson(Map<String, dynamic> json) {
    return AudioInfo(
      contentId: json['content_id'],
      audioUrl: json['audio_url'],
    );
  }
}
```
riverpodで管理するAudioInfoStateNotifier のクラスの中で、seAudioを呼び、以下の流れで処理が進みます。
①セッションにどのような音声（音楽なのか人の声なのかなど）を再生するかセットする。
⇒OSに対してこの情報を渡すことでいろんなハンドリングが楽になるようです。
（例：音声再生中にデバイスに電話がかかて来た時にどうするかなど）
このコードでは最低限のことしかしていないですが、詳細を知りたい方は
[audio_sessionの公式ページ](https://pub.dev/packages/audio_session "audio_session") をご覧ください。

②getAudioInfoでSupabaseからのレコード取得する。
⇒対象ファイルのURLを取得します。タイトルや説明文がある場合は一緒に格納しておくべきですね。
本筋から外れますが、以下のようにSelectする際に型を指定して任意のクラスのインスタンスにしておくと楽です。

```dart:page_audio.dart
//レコード取得
Future<List<AudioInfo>> getAudioInfo(String id) async {
  final audioInfoList = await supabase
      .from('Audio_Content')
      .select<List<Map<String, dynamic>>>('content_id,audio_url')
      .eq('content_id', id)
      .then((data) => data.map((json) => AudioInfo.fromJson(json)).toList());

  return audioInfoList;
}
```
③ 取得したレコードに基づいて音声ファイルを読み込む。
⇒レコードの中にはURLが入っていますので、loadAudioFileで読み込ませます。

### 音楽再生プレイヤー各種イベント状況の状態管理
関連する実装は以下の通りです。

```dart:page_audio.dart
class ProgressBarState {
  const ProgressBarState(
      {required this.progress, required this.buffered, this.total});
  final Duration progress;
  final Duration buffered;
  final Duration? total;
}

@riverpod
class ProgressBarStateNotifier extends _$ProgressBarStateNotifier {
  @override
  Stream<ProgressBarState> build() {
    ref.onDispose(() {
      player.dispose();
    });

    Stream<ProgressBarState>? durationState = Rx.combineLatest2(
        player.positionStream,
        player.playbackEventStream,
        (position, playbackEvent) => ProgressBarState(
            progress: position,
            buffered: playbackEvent.bufferedPosition,
            total: playbackEvent.duration));
    return durationState;
  }
}
```
目的としてはシークバーを実現すための状態管理なので、まずはProgressBarStateクラスを定義します。
さらに特筆すべきポイントはRx.combineLatest2の処理です。
rxdartのパッケージを使って実現していますが、ここでやっていることはStreamの合成です。
positionStreamとplaybackEventStreamを合成して新たに、Stream<ProgressBarState>というデータ型のdurationStateを作り出しています。
このパッケージの根底にはリアクティブプログラミングという考え方がありますが、詳細の説明は本記事では割愛します。聞きなれていない人は「非同期のデータ・イベントに対して効果的な方法論」くらいで理解しておくといいかもです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635205/1c85c78a-9866-5551-b0b6-95929cadc885.png)



### UI側の実装
UI側の実装は以下の通りです。
```dart:page_audio.dart
class AudioPage extends ConsumerWidget {
  final String? id;
  const AudioPage({super.key, this.id});

  //再生
  Future<void> _playSoundFile() async {
    try {

      await player.setSpeed(1.0);
      await player.play();
    } catch (e) {
      print('Error playing audio: $e');
    }
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final audioInfoState = ref.watch(audioInfoStateNotifierProvider(id!));
    final progressBarState = ref.watch(progressBarStateNotifierProvider);

    final progressBar = progressBarState.when(
      data: (bar) {
        return ProgressBar(
          progress: bar.progress,
          buffered: bar.buffered,
          total: bar.total ?? Duration.zero,
          onSeek: (value) => player.seek(value),
        );
      },
      error: (error, stackTrace) => Text(error.toString()),
      loading: () => const CircularProgressIndicator(),
    );
    final widget = audioInfoState.when(
        loading: () {
          return Scaffold(
              appBar: AppBar(
                title: const Text('音声案内ページ'),
              ),
              body: const Center(child: CircularProgressIndicator()));
        },
        error: (error, stackTrace) => Text(error.toString()),
        data: (d) {
          return Scaffold(
            appBar: AppBar(
              title: Text('音声案内ページ_$id'),
            ),
            body: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                SizedBox(
                  width: 300,
                  child: progressBar,
                ),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    ElevatedButton(
                      child: const Text('▶'),
                      onPressed: () async {
                        await _playSoundFile();
                      },
                    ),
                    const SizedBox(
                      width: 10,
                    ),
                    ElevatedButton(
                      child: const Text('||'),
                      onPressed: () async {
                        await player.pause();
                      },
                    ),
                  ],
                ),
                
              ],
            ),
            endDrawer: HeaderMenu(),
          );
        });
    return widget;
  }
}
```
このあたりは特段難しいことはしてはいません。
おなじみの以下の処理にて各状態をwatchして、whenを使って出し分けをしています。
```dart
final audioInfoState = ref.watch(audioInfoStateNotifierProvider(id!));
final progressBarState = ref.watch(progressBarStateNotifierProvider);
```

シークバーの部分は以下のようにかなりシンプルに書けます。すごいですよね。
onSeekは任意の場所にシークバーを動かした際の処理です。
```dart
    final progressBar = progressBarState.when(
      data: (bar) {
        return ProgressBar(
          progress: bar.progress,
          buffered: bar.buffered,
          total: bar.total ?? Duration.zero,
          onSeek: (value) => player.seek(value),
        );
      },
```

## まとめ
最後に全体の実装を載せておきます。
<details><summary>page_audio.dart</summary><div>

```python
import 'dart:async';
import 'package:audio_video_progress_bar/audio_video_progress_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:toyama_app/widget/header_menu.dart';
import 'package:just_audio/just_audio.dart';
import 'package:audio_session/audio_session.dart';
import 'package:rxdart/rxdart.dart';
part 'page_audio.g.dart';

final supabase = Supabase.instance.client;
late AudioPlayer player;

//準備フェーズ
Future<List<AudioInfo>> setupAudio(String id) async {
  player = AudioPlayer();
  final session = await AudioSession.instance;
  await session.configure(const AudioSessionConfiguration.music());
  
  final audioInfo = await getAudioInfo(id);
  await loadAudioFile(audioInfo[0].audioUrl);

  return audioInfo;
}

//読み込み
Future<void> loadAudioFile(String url) async {
  try {
    await player.setUrl(url);
  } catch (e) {
    print(e.toString());
  }
}

//対象レコードの取得および初期セットができているかの状態
@riverpod
class AudioInfoStateNotifier extends _$AudioInfoStateNotifier {
  @override
  Future<List<AudioInfo>> build(String id) async {
    final setupState = await setupAudio(id);

    return setupState;
  }
}

Future<List<AudioInfo>> getAudioInfo(String id) async {
  final audioInfoList = await supabase
      .from('Audio_Content')
      .select<List<Map<String, dynamic>>>('content_id,audio_url')
      .eq('content_id', id)
      .then((data) => data.map((json) => AudioInfo.fromJson(json)).toList());

  return audioInfoList;
}

class AudioInfo {
  final String contentId;
  final String audioUrl;

  AudioInfo({
    required this.contentId,
    required this.audioUrl,
  });

  factory AudioInfo.fromJson(Map<String, dynamic> json) {
    return AudioInfo(
      contentId: json['content_id'],
      audioUrl: json['audio_url'],
    );
  }
}

class ProgressBarState {
  const ProgressBarState(
      {required this.progress, required this.buffered, this.total});
  final Duration progress;
  final Duration buffered;
  final Duration? total;
}

@riverpod
class ProgressBarStateNotifier extends _$ProgressBarStateNotifier {
  @override
  Stream<ProgressBarState> build() {
    ref.onDispose(() {
      player.dispose();
    });

    Stream<ProgressBarState>? durationState = Rx.combineLatest2(
        player.positionStream,
        player.playbackEventStream,
        (position, playbackEvent) => ProgressBarState(
            progress: position,
            buffered: playbackEvent.bufferedPosition,
            total: playbackEvent.duration));
    return durationState;
  }
}

class AudioPage extends ConsumerWidget {
  final String? id;
  const AudioPage({super.key, this.id});

  //再生
  Future<void> _playSoundFile() async {
    try {

      await player.setSpeed(1.0);
      await player.play();
    } catch (e) {
      print('Error playing audio: $e');
    }
  }

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final audioInfoState = ref.watch(audioInfoStateNotifierProvider(id!));
    final progressBarState = ref.watch(progressBarStateNotifierProvider);

    final progressBar = progressBarState.when(
      data: (bar) {
        return ProgressBar(
          progress: bar.progress,
          buffered: bar.buffered,
          total: bar.total ?? Duration.zero,
          onSeek: (value) => player.seek(value),
        );
      },
      error: (error, stackTrace) => Text(error.toString()),
      loading: () => const CircularProgressIndicator(),
    );
    final widget = audioInfoState.when(
        loading: () {
          return Scaffold(
              appBar: AppBar(
                title: const Text('音声案内ページ'),
              ),
              body: const Center(child: CircularProgressIndicator()));
        },
        error: (error, stackTrace) => Text(error.toString()),
        data: (d) {
          return Scaffold(
            appBar: AppBar(
              title: Text('音声案内ページ_$id'),
            ),
            body: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                SizedBox(
                  width: 300,
                  child: progressBar,
                ),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    ElevatedButton(
                      child: const Text('▶'),
                      onPressed: () async {
                        await _playSoundFile();
                      },
                    ),
                    const SizedBox(
                      width: 10,
                    ),
                    ElevatedButton(
                      child: const Text('||'),
                      onPressed: () async {
                        await player.pause();
                      },
                    ),
                  ],
                ),
                
              ],
            ),
            endDrawer: HeaderMenu(),
          );
        });
    return widget;
  }
}
```
</div></details>


非常にシンプルかつ簡単に音声再生機能を作ることができますね。
今の実装はページを離れる際にplayerをdisposeしていますが、バックグラウンド再生なども少し手を加えると実現できそうなので時間があるときに挑戦してみようと思います。

以上、どなたかの参考になれば幸いです。