ExoPlayerとPreviewSeekBarでプレビューを表示させる

AndroidでYoutube等でよく見かけるシークバーを移動させる時に、
シークバーの上部にプレビューを表示させるあれを試して見たいと思います。

※あれのイメージ
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299271/6530e1e6-9717-d673-e20a-a2a5bcd1f5b1.png" width="420" />

以下のメジャーな2つのライブラリを使って実装を進めて行きます。
### PreviewSeekBar

<a href="https://github.com/rubensousa/PreviewSeekBar"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299271/25d7dd57-77ed-e2f3-ab2d-d4ac973d814c.png" width="460px"></a>

SeekBarの上部にPreview画面を表示させていい感じに表示させるライブラリです。

### ExoPlayer

<a href="https://github.com/google/ExoPlayer"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299271/4186fa0f-fe2c-80a6-ea32-10abdff9adc2.png" width="460px"></a>

ご存知Google製のAndroid用動画Playerです。

###  :computer:環境構築
****

新規にプロジェクトを作成し、必要なライブラリを `[module]/build.gradle` に追加します。

```gradle
android {
    compileOptions {
        targetCompatibility JavaVersion.VERSION_1_8
    }
}
dependencies {
    // ExoPlayer
    def exoplayer_version = '2.8.1'
    implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
    implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"

    // PreviewSeekBar
    implementation 'com.github.rubensousa:previewseekbar:2.0.0'
    implementation 'com.github.rubensousa:previewseekbar-exoplayer:2.8.1.0'
}
```
### :pencil: 実装
****

#### まずはExoPlayerで普通に動画再生できるまで実装

今回はAndroidスマートフォンの横画面フルスクリーンで動画を視聴する想定で実装していきます。
動画のコンテンツに関しては[こちら](https://www.sample-videos.com/)を利用させて頂きました :bow:

- ActionBar付きのstyleを `NoActionBar` に変更する

```xml:style.xml
<resources>
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
      ...
    </style>
</resources>
```

- レイアウトファイルに `PlayerView` を配置

```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:fitsSystemWindows="true"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.google.android.exoplayer2.ui.PlayerView
        android:id="@+id/playerView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintDimensionRatio="16:9"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
```

`app:layout_constraintDimensionRatio` を `16:9` に設定する事で縦のサイズをいい感じに `横との比率が9` で調整してくれます。

- Activityの実装

```kotlin
const val SAMPLE_MP4_URL = "https://www.sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4"

class MainActivity : AppCompatActivity() {

    private var player: ExoPlayer? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // フルスクリーンにする
        window.decorView.apply {
            systemUiVisibility = (
                    View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
                            or View.SYSTEM_UI_FLAG_FULLSCREEN
                            or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                    )
        }
    }

    override fun onStart() {
        super.onStart()
        player = createPlayer()
        playerView.player = player
    }

    override fun onStop() {
        super.onStop()
        player?.release()
    }

    private fun createPlayer(): ExoPlayer {
        val dataSourceFactory = DefaultDataSourceFactory(
            this@MainActivity, DefaultBandwidthMeter(),
            DefaultHttpDataSourceFactory(Util.getUserAgent(this@MainActivity, packageName))
        )
        val videoSource = ExtractorMediaSource.Factory(dataSourceFactory)
            .createMediaSource(Uri.parse(SAMPLE_MP4_URL))

        val videoTrackSelectionFactory = AdaptiveTrackSelection.Factory(DefaultBandwidthMeter())
        val trackSelector = DefaultTrackSelector(videoTrackSelectionFactory)
        return ExoPlayerFactory.newSimpleInstance(this@MainActivity, trackSelector).apply {
            prepare(videoSource)
            playWhenReady = true
        }
    }
}
```
あとは `AndroidManifest.xml` に

```xml
<uses-permission android:name="android.permission.INTERNET"/>
```
を忘れずに追加して実行して、動画が再生されればOKです :sparkles: 

----
#### シークバー動かした時にプレビュー表示させる

本題のプレビュー表示をやっていこうと思います。
まずはプレイヤーのコントローラ部分のレイアウトを作成します。

```xml:controller_view.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_gravity="bottom"
    android:layoutDirection="ltr">

    <LinearLayout
        android:id="@+id/controlsLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#CC000000"
        android:gravity="center"
        android:orientation="horizontal"
        app:layout_constraintBottom_toBottomOf="parent">

        <ImageButton
            android:id="@id/exo_prev"
            style="@style/ExoMediaButton.Previous" />

        <ImageButton
            android:id="@id/exo_rew"
            style="@style/ExoMediaButton.Rewind" />

        <ImageButton
            android:id="@id/exo_play"
            style="@style/ExoMediaButton.Play" />

        <ImageButton
            android:id="@id/exo_pause"
            style="@style/ExoMediaButton.Pause" />

        <ImageButton
            android:id="@id/exo_ffwd"
            style="@style/ExoMediaButton.FastForward" />

        <ImageButton
            android:id="@id/exo_next"
            style="@style/ExoMediaButton.Next" />

    </LinearLayout>

    <TextView
        android:id="@id/exo_position"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:includeFontPadding="false"
        android:textColor="#FFBEBEBE"
        android:textSize="12sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toTopOf="@+id/controlsLayout"
        app:layout_constraintStart_toStartOf="parent"
        tools:text="18:20" />

    <com.github.rubensousa.previewseekbar.exoplayer.PreviewTimeBar
        android:id="@+id/exo_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        app:layout_constraintBottom_toBottomOf="@id/exo_position"
        app:layout_constraintEnd_toStartOf="@id/exo_duration"
        app:layout_constraintStart_toEndOf="@+id/exo_position"
        app:layout_constraintTop_toTopOf="@+id/exo_position"
        app:previewFrameLayout="@id/previewFrameLayout" />

    <TextView
        android:id="@id/exo_duration"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:layout_margin="8dp"
        android:includeFontPadding="false"
        android:textColor="#FFBEBEBE"
        android:textSize="12sp"
        android:textStyle="bold"
        app:layout_constraintBaseline_toBaselineOf="@id/exo_position"
        app:layout_constraintEnd_toEndOf="parent"
        tools:text="25:23" />

    <FrameLayout
        android:id="@+id/previewFrameLayout"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginBottom="16dp"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:padding="5dp"
        android:visibility="invisible"
        app:layout_constraintBottom_toTopOf="@+id/exo_progress"
        app:layout_constraintDimensionRatio="16:9"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintWidth_default="percent"
        app:layout_constraintWidth_percent="0.25"
        tools:visibility="visible">

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="fitXY" />

    </FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
```
作成したコントローラー用のViewを `com.google.android.exoplayer2.ui.PlayerView` の
`controller_layout_id` で指定してやります。

```xml
    <com.google.android.exoplayer2.ui.PlayerView
        android:id="@+id/playerView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:controller_layout_id="@layout/controller_view"
        app:layout_constraintDimensionRatio="16:9"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
```
↓実際に動作させた様子です。
![test1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299271/e5d80c8f-529e-bca0-6c0d-2a8785952a16.gif)

----
#### シークバーをDrag&Dropしている時にプレビューを表示させる

今のままだとプレビューが表示されないので、実装を追加します。
最終的なプレビューのイメージは以下の様になります。
![test2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299271/207b4724-2c3e-3f85-b622-dd84ad61f216.gif)

実際はシークバーの位置に応じたサムネイルの画像を用意したり、バックグラウンドでレンダリングした画像を表示したりする事になるかと思いますが、
今回は背景色が異なる画像を4枚用意して、シークバーの位置に応じてそれぞれの画像を表示させています。

`PreviewView.OnPreviewChangeListener` の実装
シークバーをDrag&Dropした時に呼ばれるListenerを実装してセットしてあげます。

```kotlin
const val SAMPLE_MP4_URL = "https://www.sample-videos.com/video123/mp4/720/big_buck_bunny_720p_20mb.mp4"
const val TOTAL_VIDEO_MSEC = 117000

val DUMMY_THUMBNAILS = listOf(
    "https://placehold.jp/a6eaf5/0a0909/150x150.png",
    "https://placehold.jp/f5baa6/0a0909/150x150.png",
    "https://placehold.jp/abf5a6/0a0909/150x150.png",
    "https://placehold.jp/e0a6f5/0a0909/150x150.png"
)

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        // PreviewTimeBarを取得
        val previewTimeBar = playerView.findViewById<PreviewTimeBar>(R.id.exo_progress)
        previewTimeBar.addOnPreviewChangeListener(onPreviewChangeListener)
    }

    // シークバーをDrag&Dropした時に呼ばれるListener
    private val onPreviewChangeListener = object : PreviewView.OnPreviewChangeListener {
        override fun onPreview(previewView: PreviewView?, progress: Int, fromUser: Boolean) {
            val eachTime = TOTAL_VIDEO_MSEC / DUMMY_THUMBNAILS.size
            val index = progress / eachTime
            val url = DUMMY_THUMBNAILS.elementAtOrNull(index) ?: return
            // progressの位置に応じたサムネイルを表示
            Glide.with(this@MainActivity)
                .load(url)
                .into(imageView)
        }
        override fun onStartPreview(previewView: PreviewView?, progress: Int) {
        }
        override fun onStopPreview(previewView: PreviewView?, progress: Int) {
        }
    }
}
```
サムネイルの表示に関しては自前でprogressの位置から計算してどのサムネイルを表示するか
制御する必要があります。

上記の実装は以下のリポジトリにアップしてます。何かの参考になれば幸いです :sparkles: 
<a href="https://github.com/Slowhand0309/PreviewSeekBarSample"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299271/16509584-87db-fa37-3654-917f56415813.png" width="460px"></a>

###  :eyes: その他
****
#### コントローラーの表示/非表示を制御したい場合
デフォルトの動作としては、初回表示されしばらく経過するとコントローラーが消えます。
消えた際に何かキー入力すると再び表示されますが、常に表示させる場合は以下の設定を行います。

[android - How do I make the Exoplayer controls always visible? - Stack Overflow](https://stackoverflow.com/questions/47380607/how-do-i-make-the-exoplayer-controls-always-visible)
↑こちらにある通り、`show_timeout `  を `0` で設定すると常に表示された状態になります。
あとは `PlayerView#showController` / `PlayerView#hideController` で表示を切り替える事ができます。

###  :bomb: バッドノウハウ
****

- `app:controller_layout_id` にレイアウトファイルを指定してもSeekBarとExoPlayerが同期しない :fearful: 
    - レイアウトファイル内のidをExoPlayerが指定するid名に合わせないといけない
        - [PlayerControlView (ExoPlayer library)](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ui/PlayerControlView.html)

- `getDefaultScrubberColor` メソッドが無いと怒られる :fearful: 

```
     Caused by: java.lang.NoSuchMethodError: No static method getDefaultScrubberColor(I)I in class Lcom/github/rubensousa/previewseekbar/exoplayer/PreviewTimeBar; 
        or its super classes (declaration of 'com.github.rubensousa.previewseekbar.exoplayer.PreviewTimeBar' appears in ...
        at com.github.rubensousa.previewseekbar.exoplayer.PreviewTimeBar.<init>(PreviewTimeBar.java:57)
```
手元の環境だとExoPlayerのVerが `2.10.8` の場合に上記エラーが発生しました。
ExoPlyerのVerをダウングレードすると正しく表示されました。'2.10.8 => 2.8.1'

### :link: 参考になったURL
****
- [PlayerControlView (ExoPlayer library)](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ui/PlayerControlView.html)
- [Yukiの枝折: ConstraintLayout](http://yuki312.blogspot.com/2017/03/constraintlayout.html)