Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Android Jetpack Compose で動画表示

こんにちは。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 の useControllerfalse にします。こちらが true である場合に、デフォルトの動画パーツが表示されてしまうため、ここで OFF にします。

AndroidView(
  modifier = Modifier
    .fillMaxWidth()
    .wrapContentHeight(),
  factory = { context ->
    ContextThemeWrapper()
    PlayerView(context).apply {
      useController = false
      this.player = exoPlayer
    }
  }
)


動画パーツ(シークバーや再生・停止ボタン)

動画パーツ部分の実装に入ります。今回は「再生・停止ボタン」「動画再生時間」「シークバー」を横に並べる表現をしたいと思います。

再生・停止ボタン

ControllerButton の中に実装を書きました(詳細な実装は省略)。VideoState の状態に応じて再生ボタンか停止ボタンかの表示を切り替え、それぞれのクリックイベントを実装しています。

動画再生時間

currentTimeremember で保持しています。後述しますがここには動画の経過時間(ミリ秒)が入っており、それを Date 型に変換した後に"mm:ss" 形式で表示するようにしています。

シークバー

Slider を用います。指定するパラメータは下記になります。

  • modifier :レイアウト情報の Modifier
  • valuecurrentTime を渡しています。後述する 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 ライフを楽しみましょう。