こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。
今回は Jetpack Compose で動画表示の対応をしたため、その内容をお伝えします。
※ キャプチャの動画はこちらを使用させていただきました。
https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4
一番シンプルな実装
AndroidView の中に PlayerView を実装して表示をします。
val context = LocalContext.current val exoPlayer = remember(context) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(Uri.parse(url))) prepare() seekTo(0L) } } Box( modifier = Modifier .fillMaxWidth() .wrapContentHeight() ) { // PlayerView AndroidView( modifier = Modifier .fillMaxWidth() .wrapContentHeight(), factory = { context -> PlayerView(context).apply { this.player = exoPlayer } } ) }
以上が動画表示の実装になります。
・・・と言いたいところですが、この実装では操作ボタンやシークバーといった動画パーツがデフォルトのものしか表示できません。
今回はこちらをさらに拡張して、カスタマイズした操作ボタン・シークバーを表示していきたいと思います。
仕様
- デフォルトの動画パーツを使用しない
- 再生・停止ボタン、動画の経過時間、シークバーを動画の下部に表示する
- シークバーで操作した時にその時間の動画まで移動する
実装
※ 見やすさを重視するため、処理の共通化やメソッド分けを崩して表示しています。実際に実装する場合は設計や ReCompose の動作に気をつけて実装してください。
ExoPlayer を作成
VideoState を定義
まず ExoPlayer を管理するために、VideoState を定義します。下記の 4 種類を用意しました。
enum class VideoState { LOADING, PAUSE, START, ENDED }
ExoPlayer の生成
ExoPlayer を Builder で生成するところは先ほどの「シンプルな実装」と同じになります。また動画の状態によって動画パーツの状態・表示切り替えをするため、addListener
で Listener を設定し、onPlaybackStateChanged
で状態を通知できるようにします。そして先ほど作成した VideoState に変換して、保持します。
var videoState by remember { mutableStateOf(VideoState.LOADING) } val context = LocalContext.current val exoPlayer = remember(context) { ExoPlayer.Builder(context).build().apply { setMediaItem(MediaItem.fromUri(Uri.parse(url))) prepare() seekTo(0L) } } exoPlayer.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) val playWhenReady = exoPlayer.playWhenReady val newState = when { playbackState == Player.STATE_ENDED && playWhenReady -> VideoState.ENDED playbackState == Player.STATE_BUFFERING || playbackState == Player.STATE_IDLE -> VideoState.LOADING playbackState == Player.STATE_READY && playWhenReady -> VideoState.START playbackState == Player.STATE_READY -> VideoState.PAUSE else -> null } if (newState != null && newState != videoState) { videoState = newState } } })
Compose のレイアウトを作成
PlayerView
PlayerView は先ほどの「シンプルな実装」と同様に AndroidView 内で生成します。先ほどと違うところは、PlayerView の useController
を false
にします。こちらが true
である場合に、デフォルトの動画パーツが表示されてしまうため、ここで OFF にします。
AndroidView( modifier = Modifier .fillMaxWidth() .wrapContentHeight(), factory = { context -> ContextThemeWrapper() PlayerView(context).apply { useController = false this.player = exoPlayer } } )
動画パーツ(シークバーや再生・停止ボタン)
動画パーツ部分の実装に入ります。今回は「再生・停止ボタン」「動画再生時間」「シークバー」を横に並べる表現をしたいと思います。
再生・停止ボタン
ControllerButton の中に実装を書きました(詳細な実装は省略)。VideoState の状態に応じて再生ボタンか停止ボタンかの表示を切り替え、それぞれのクリックイベントを実装しています。
動画再生時間
currentTime
を remember
で保持しています。後述しますがここには動画の経過時間(ミリ秒)が入っており、それを Date 型に変換した後に"mm:ss" 形式で表示するようにしています。
シークバー
Slider を用います。指定するパラメータは下記になります。
modifier
:レイアウト情報の Modifiervalue
:currentTime
を渡しています。後述する ExoPlayer の経過時間と同期させるため、remember
の値を指定します。onValueChange
:シークバーの位置が変更された時の通知。この数値は動画の経過時間(ミリ秒)にあたるため、currentTime
に反映させますvalueRange
:シークバーの値の範囲。シークバーのexoPlayer.duration
で総動画時間を取得し、0 から総時間を指定します。
var currentTime by remember { mutableStateOf(0L) } ~~~~~ Row( modifier = Modifier .fillMaxWidth() .height(42.dp) .background(Color.Black) .padding(start = 8.dp) .align(Alignment.BottomCenter), verticalAlignment = Alignment.CenterVertically ) { ControllerButton( videoState = videoState, onClickStart = { exoPlayer.play() videoState = VideoState.START }, onClickPause = { exoPlayer.pause() videoState = VideoState.PAUSE } ) Text( text = SimpleDateFormat("mm:ss", Locale.JAPAN).format(Date (currentTime)), ~~~ ) Slider( ~~~ value = currentTime.toFloat(), onValueChange = { timeMs -> val time = timeMs.toLong() exoPlayer.seekTo(time) currentTime = time }, valueRange = 0f..exoPlayer.duration.coerceAtLeast(0L).toFloat(), colors = SliderDefaults.colors( thumbColor = Colors.WHITE, activeTrackColor = Colors.VIDEO_SEEKBAR_PLAYED, inactiveTrackColor = Colors.VIDEO_SEEKBAR_BUFFER, ) ) }
ライフサイクルで再生・停止・解放をする
このままの実装では、動画再生中にアプリがバックグラウンドに行った時、動画が再生したままの状態になってしまいます。
LifecycleOwner を監視して、アプリの再開・中断を監視します。また DisposableEffect を用いることで LifecycleOwner が破棄された時のイベントをとることができます。
アプリの再開時には動画を start し、中断時には pause を実行します。破棄された時の onDispose
で ExoPlyer を解放させます。
// Lifecycle DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { if (videoState == VideoState.START) { exoPlayer.play() } } else if (event == Lifecycle.Event.ON_STOP) { exoPlayer.pause() } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { exoPlayer.run { playWhenReady = false clearVideoSurface() release() } lifecycleOwner.lifecycle.removeObserver(observer) } }
再生時間・シークバーの更新
今回カスタマイズすることにより、動画(ExoPlayer)とシークバーの時間が連動していません。この間を繋げるのが先ほど定義した currentTime
になります。再生状態の時に定期的に ExoPlayer の経過時間を取得し、currentTime
に反映させます。currentTime
を設定している Slider は値の変化で更新されるため、シークバーが再生時間に応じて動くことができます。
if (videoState == VideoState.START) { LaunchedEffect(Unit) { repeat(Int.MAX_VALUE) { delay(1000) currentTime = exoPlayer.contentPosition } } }
これで動画の基本的な実装が完了です。
その他の実装
以上で Jetpack Compose で動画を表示する実装になります。他にも、
- Player.Listener の
onIsLoadingChanged
で動画読み込み完了した時、自動で動画を再生する - 動画再生が一定時間経過した後に、
updateTransition
のアニメーションを用いることで、動画パーツを非表示にして動画を見やすくする - 動画再生前にサムネイル画像を、動画再生完了時に End Screen 画像を表示する
といったことをすることで、より高機能な動画が実現できると思います。
まとめ
Jetpack Compose で動画表示の対応内容についてまとめました。以前の動画の Player では動画パーツの更新を SDK 側で行ってくれていましたが、 Jetpack Compose は良くも悪くも自分で実装する必要があります。臨機応変に対応し、Jetpack Compose ライフを楽しみましょう。