MediaCodecの非同期処理で動画再生

# はじめに
Androidで動画再生アプリを作る時には[VideoView](https://developer.android.com/reference/android/widget/VideoView)を使うと簡単に実装できるんですが、これだとコマ送りや再生速度の変更など欲しい機能が全然できなくて、調べてみたら[MediaCodec](https://developer.android.com/reference/android/media/MediaCodec)を使う必要があることがわかりました。
だけどこれ、色んなことができる代わりに色んなことを全部自分で実装しないといけなくて使用するのが大変なんですね。。。
そのせいなのか分からないですが、あまり使用者がいないのか公式ドキュメント以外に記事が全然無い。
特にまとめ的なものとか概要、全体図がわかるようなものが無いんです。
なので僕のようにこれから勉強しようとする人にはなかなか辛いんです(TдT)

まだ作りかけですが、MediaCodecを使って実装したサンプルを晒しておきます。
詳細は後述しますが、この記事のタイトルにあるように非同期処理で実装したサンプルになります。
https://github.com/dego-96/SlowMoviePlayer/tree/v0.1

ということで、自分なりに調べて勉強した結果をここに備忘録としてまとめておきます。



## MediaCodecとMediaExtractor

今回作ろうとしたアプリは動画"再生"アプリなので、Decode機能にフォーカスして書きます。
最初は[MediaCodec](https://developer.android.com/reference/android/media/MediaCodec)だけでできると思ってたんですが、動画ファイルの取得から始まり、再生、停止、一時停止、再生位置変更(シーク)など、動画アプリで必要な機能を実現するためには[MediaExtractor](https://developer.android.com/reference/android/media/MediaExtractor)と[MediaCodec](https://developer.android.com/reference/android/media/MediaCodec)の２つを使用する必要があります。こいつらが実行する内容は概ね以下の感じです。


### MediaExtractor
動画ファイルからサイズや再生時間の取得、再生位置の管理などを行う。
動画の実質的な管理を行うのはこいつ。

### MediaCodec
圧縮データと表示する映像データとの変換を行う。
動画のどのあたりを再生してるとか考えずに単に変換だけを行う。
SurfaceViewに変換した映像データを表示させるのはこいつ。


# デコードの流れ

デコード処理はざっくり以下の流れで行う。
1〜4は初期化処理で、動画再生中は5〜8までを繰り返すことになる。
9〜10は終了処理なので忘れずに実行しましょう。

1. 動画ファイルから動画データを取得
1. MediaCodecインスタンスの生成
1. コールバック関数の登録
1. `MediaCodec.start()`
1. `MediaCodec.queueInputBuffer()`
1. `MediaCodec.releaseOutputBuffer()`  ※こいつで映像データを出力(描画)する
1. 動画再生中は5,6を繰り返す
1. 最後まで再生したことを検知
1. `MediaCodec.stop()`
1. `MediaCodec.release()`

ちなみに当然のことだけど、再生処理をメインスレッドで実行すると再生中は操作できなくなってしまうので、通常は別スレッドで実行します。別スレッドはMediaCodecが必要なタイミングでコールバック関数を呼んでくれる**非同期モード**と、必要に応じて自前で実装する**同期モード**の２種類あります。ググって出てくるサンプルコードはなぜか同期モードばかりなので、ここでは非同期モードでのサンプルを載せておきます。

一時停止とかシークとかする場合はこの流れを改造して実装することになります。

あと、上記4.の`MediaCodec.start()`は再生処理の最初に実行しても良いんですが、それだと再生ボタンを押すまでは画面が真っ黒になってしまうので最初のフレームを描画させるまでは初期化処理に含めたほうがいいと思います。



# 非同期処理で再生

デコードの流れを実装するサンプル（最低限だけ抜粋したもの）は以下のようになります。
Androidのライフサイクルとの兼ね合いとかはアプリによって変わったりするので書きません。
（というか、どのタイミングがベストなのか知らない。。。）

初期化処理はこんな感じに

```java
MediaCodec mDecoder;
MediaExtractor mExtractor;

try {
    mExtractor = new MediaExtractor();
    mExtractor.setDataSource(mFilePath);

    for (int index = 0; index < mExtractor.getTrackCount(); index++) {
        MediaFormat format = mExtractor.getTrackFormat(index);
        String mime = format.getString(MediaFormat.KEY_MIME);
        if (mime != null && mime.startsWith("video/")) {
            mExtractor.selectTrack(index);
            mDecoder = MediaCodec.createDecoderByType(mime);

            try {
                Log.d(TAG, "format: " + format);
                mDecoder.configure(format, surface, null, 0);
            } catch (IllegalStateException e) {
                e.printStackTrace();
            }
        }
    }
    mDecoder.setCallback(mediaCodecCallback);
} catch (IOException e) {
    e.printStackTrace();
}
mDecoder.start();
```

初期化処理が正常に完了して上記コードの最後にある`start()`が実行されると、必要なタイミングでコールバック関数が呼び出されます。
MediaCodecに登録するコールバックはこんな感じに

```java
mediaCodecCallback = new MediaCodec.Callback() {
    @Override
    public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) {
        ByteBuffer inputBuffer = codec.getInputBuffer(index);
        int sampleSize = 0;
        if (inputBuffer != null) {
            sampleSize = mExtractor.readSampleData(inputBuffer, 0);
        }
        if (sampleSize > 0) {
            aCodec.queueInputBuffer(aInputBufferId, 0, sampleSize, mExtractor.getSampleTime(), 0);
        } else {
            aCodec.queueInputBuffer(aInputBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
        }
        mExtractor.advance();
    }

    @Override
    public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) {
        codec.releaseOutputBuffer(index, true);
    }

    @Override
    public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) {
        /* エラー処理 */
    }

    @Override
    public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) {
        /* フォーマット変更 */
    }
};
```

このコードの詳細はちゃんと分かってない部分とかあるので説明しませんが、1つだけ注意事項があります。
公式ドキュメントにはちゃんと書いてないんですが、onInputBufferAvailableの最後にある`advance()`を呼んでやらないといけません。このメソッドは読み込むデータを次に進めるためのもので、呼ばないと延々と最初のフレームを描画し続けることになります。

# 問題点

非同期モードで実装してみてわかったことですが、次の２つの問題が有りました。

- やや早い速度で再生される（一部の動画のみ？）
- 再生速度の変更ができない

早くなってしまう分には`System.nanoTime()`とか待ち処理を入れて調整できるんですが、２倍速とか1/2倍速で再生させようとした時には同期モードで実装して自前で速度調整する必要がありそうです。
だからサンプルは同期モードばっかりなのかな？？？