ExoPlayer+Android TV - 動画の先読み, UI

[前回(Jetpack Compose, ExoPlayer3, HLSでAndroid TV用のアプリを作成した)](https://qiita.com/daredeshow/items/35c0f560af25bd0037a3)に引き続き

ExoPlayerのデフォルトUIはスマートフォンでは割とうまく動くけどD-Pad入力しかないAndroid TVではうまく使えず、結局UIを自作する必要があるので作りました・・・
あと動画の先読み(バッファリング)もやった

ソースコード
https://gitlab.com/mkiuchi/exoplayer-demo/-/tree/forwardbuffer?ref_type=heads

できたもの
<iframe width="560" height="315" src="https://www.youtube.com/embed/ALEunVMC5Wk?si=6N4WvDsWHRUVEzVC" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# 1. 動画の先読み(バッファリング)

CMや気に入らないシーンをばばばと飛ばすためには先読みバッファはあったほうがいい。

[DefaultLoadControl](https://developer.android.com/reference/kotlin/androidx/media3/exoplayer/DefaultLoadControl)を使う。ドキュメントを素直に読んでると[Download Service](https://developer.android.com/media/media3/exoplayer/downloading-media)使うのかなーと思ったりする(し作例もぽつぽつある)けど、これはデバイス内に動画をダウンロードして再生するオフライン再生機能なので今回やりたいことではない。

以下のような感じにする。各オプションの説明は[ドキュメント](https://developer.android.com/reference/kotlin/androidx/media3/exoplayer/DefaultLoadControl)を参照。

```kotlin:MainActivity.kt(抜粋, 省略ないものは上のリポジトリ参照)
// バッファ秒数
val forwardBufferMs: Int = 15 * 60 * 1000
val backBufferMs: Int = 10 * 60 * 1000
// バッファの設定
val loadControl = DefaultLoadControl.Builder()
    .setBufferDurationsMs(
        /* minBufferMs = */ 50000,      // プレイヤーが再生を開始するまでバッファする秒数(default=50000)
        /* maxBufferMs = */ forwardBufferMs, // 最大バッファ秒数(default=50000)
        /* bufferForPlaybackMs = */ 2500,   // シーク後に再生を再開するために必要なバッファ秒数(default=2500)
        /* bufferForPlaybackAfterRebufferMs = */ 5000 // リバッファの際に再生を再開するために必要な秒数(default=5000)
    )
    .setBackBuffer(
        /* backBufferDurationMs = */ backBufferMs, // 巻き戻し用バッファの秒数(default=0)
        /* retainBackBufferFromKeyframe = */ false // バッファの確保をキーフレームから行うかどうか(default=false)
    )
    .build()
// ExoPlayerインスタンスの作成
ExoPlayer.Builder(context)
    .setSeekBackIncrementMs(15*1000) // 巻き戻し時の秒数
    .setSeekForwardIncrementMs(15*1000) // 早送り時の秒数
    .setLoadControl(loadControl) // バッファをセット
    .build()
    .apply {
        // (略)
    }
```

明確な説明はないけど、たぶんメモリ上にバッファとして蓄えられるのでI/OとしてはDownload Serviceで使う内蔵ストレージよりは速いはず。はずなんだけど、実デバイスで使ってみるともっさり感が否めない。多分CPUが貧弱なのではないかと思う。Android StudioのDevice Emulatorはかなり速いので、それを基準にして作ってしまうと実デバイスで実行したときにあれ？となる。Android TVのYouTubeアプリはシーク時に動画は一時停止したままで、サムネイルを変化させることでシーク位置をユーザに決めさせている。このほうがサクサク感あるかもしれない。

:::note info
2025/03/02 追記
> Android TVのYouTubeアプリはシーク時に動画は一時停止したままで、サムネイルを変化させることでシーク位置をユーザに決めさせている。このほうがサクサク感あるかもしれない。

実際に試作してみたところやはりExoPlayerで都度シークするのではなく、YouTube方式の方がサクサク動作する。キーパッドの入力のたびにExoPlayer側をseekさせるのはUXとしてはよくないと思う
:::

`maxBufferMs`は設定しても設定値を再生開始直後から全力バッファする動作にはなっていないみたい。動画の再生がすすむにつれで最大バッファ量までバッファするようになる。なんとかしたい場合は`minBufferMs`, `bufferForPlaybackMs`をいじればいいのかなと思う。

# 2. UI

で、UIを作る。お手本としてみんなおなじみ[JetStreamCompose](https://github.com/android/tv-samples/tree/main/JetStreamCompose/jetstream)をじーっと見て、使う。けど、よくわかんないので自分なりに削ぎ落としたものにして、少しYouTubeアプリに寄せてみた。

Composableの構成は以下の図のように入れ子になっている。D-Padの入力を受け付けるコンポーザブルと、その結果が反映されるコンポーザブルが異なるところが少しややこしいところ。

![Screenshot from 2025-01-09 21-59-34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52237/270df87e-cb3c-db1d-ada5-36ddf3c37c8b.png)

## 2.1. D-Pad の入力を受け付け

- [MainActivity.kt](https://gitlab.com/mkiuchi/exoplayer-demo/-/blob/forwardbuffer/app/src/main/java/org/example/exoplayer_demo/MainActivity.kt?ref_type=heads)
- [ModifierUtils.kt](https://gitlab.com/mkiuchi/exoplayer-demo/-/blob/forwardbuffer/app/src/main/java/org/example/exoplayer_demo/components/ModifierUtils.kt?ref_type=heads)

これはほぼ[JetStreamCompose](https://github.com/android/tv-samples/tree/main/JetStreamCompose/jetstream)のコピペ。[カスタム修飾子(Modifier)](https://developer.android.com/develop/ui/compose/custom-modifiers)を使って`Modifier.dPadEvents`, `Modifier.handleDPadKeyEvents`, `Modifier.handleDPadEvents`関数を作成してD-Padの入力イベントに対応した動作を定義する。なんで`Modifier.handleDPadKeyEvents(onPreviewKeyEvent)`, `Modifier.handleDPadEvents(onKeyEvent)`の2つを定義しているかは(コメントには解説があるけど)よくわからなかった。

D-Padの上、右、左、中央を押すとコントローラを可視化するメソッドを呼び出す。右か左を押すと動画のシーク(早送り、巻き戻し)を行う。中央を押すと動画の一時停止、再開を行う。

## 2.2. コントローラの可視化
- [ControllerState.kt](https://gitlab.com/mkiuchi/exoplayer-demo/-/blob/forwardbuffer/app/src/main/java/org/example/exoplayer_demo/components/ControllerState.kt?ref_type=heads)
- [MovieController.kt](https://gitlab.com/mkiuchi/exoplayer-demo/-/blob/forwardbuffer/app/src/main/java/org/example/exoplayer_demo/MovieController.kt?ref_type=heads)

`ControllerState`クラスをインスタンス化したものをrememberで保存しておいて、可視化するときには`showControls()`メソッドを呼び出す。`showControls()`では内部変数`_controlsVisible`をtrueにセットする。

そうすると、`MovieController`コンポーザブルの中の`state`にも反映されるので、`state.controlsVisible`の変化をトリガとして、`CinematicBackground`コンポーザブルと`MovieControls`(の親)コンポーザブルが可視化される。

## 2.3. 一定時間なにも操作しなかったらコントローラを不可視に変更
- [ControllerState.kt](https://gitlab.com/mkiuchi/exoplayer-demo/-/blob/forwardbuffer/app/src/main/java/org/example/exoplayer_demo/components/ControllerState.kt?ref_type=heads)

`ControllerState`の中には[Channel](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/-channel/)が定義してあって、`showControl()`呼び出し時に指定した秒が[フローとして](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/consume-as-flow.html)`observe()`内で処理され、指定秒数経過後に、内部変数`_controlsVisible`をfalseにセットすることでコントローラを不可視に変更する。

## 2.4. 再生状態のアップデート
- [MainActivity.kt](https://gitlab.com/mkiuchi/exoplayer-demo/-/blob/forwardbuffer/app/src/main/java/org/example/exoplayer_demo/MainActivity.kt?ref_type=heads)

これはMainActivityの中で無限ループで値を取得して、子コンポーザブルに渡している。

# 3. おわりです

Googleプロダクトあるあるだけど、サンプルが凝ってててこずった・・・。なんか[Shaka Playerのこと](https://qiita.com/daredeshow/items/eda7578ec6c032752c48#shaka)を思い出したわ笑