Help us understand the problem. What is going on with this article?

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

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

※あれのイメージ

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

PreviewSeekBar

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

ExoPlayer

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

:computer:環境構築


新規にプロジェクトを作成し、必要なライブラリを [module]/build.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スマートフォンの横画面フルスクリーンで動画を視聴する想定で実装していきます。
動画のコンテンツに関してはこちらを利用させて頂きました :bow:

  • ActionBar付きのstyleを NoActionBar に変更する
style.xml
<resources>
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
      ...
    </style>
</resources>
  • レイアウトファイルに PlayerView を配置
<?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_constraintDimensionRatio16:9 に設定する事で縦のサイズをいい感じに 横との比率が9 で調整してくれます。

  • Activityの実装
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

<uses-permission android:name="android.permission.INTERNET"/>

を忘れずに追加して実行して、動画が再生されればOKです :sparkles:


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

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

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 で指定してやります。

    <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


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

今のままだとプレビューが表示されないので、実装を追加します。
最終的なプレビューのイメージは以下の様になります。
test2.gif

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

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

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:

:eyes: その他


コントローラーの表示/非表示を制御したい場合

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

android - How do I make the Exoplayer controls always visible? - Stack Overflow
↑こちらにある通り、show_timeout0 で設定すると常に表示された状態になります。
あとは PlayerView#showController / PlayerView#hideController で表示を切り替える事ができます。

:bomb: バッドノウハウ


  • app:controller_layout_id にレイアウトファイルを指定してもSeekBarとExoPlayerが同期しない :fearful:

  • 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


Slowhand0309
Android, iOS, Rails, C/C++, Typescript
sikmi
しくみ製作所株式会社は、世の中の「しくみ」を素敵にするためのソフトウェア開発集団です。オフィスのない弊社は、メンバー全員リモートワークです!
https://sikmi.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away