ExoPlayerとComposeでオーディオプレーヤーを作った際のTips

これはmikanのアドベントカレンダー 10日目の記事です。

9日目は@mizorinからmikanのフルリモート環境を支える工夫でした。 そういえば、Gatherを使ってみようかという話が結構前にあったなーと懐かしい気持ちで当時を振り返りながら、気づけば今では当たり前の習慣になっていてもはや生活の一部となっています。こんな便利な仕組みをどうやって作ったのかという背景が知れるのでぜひぜひこちらもみていってください。

はじめに

こんにちは。 mikanでAndroidエンジニアをしております、zukkeyといいます。

今回は、今年リリースされた機能の一つで、特定の教材のみでmikanで利用できる 「オーディオプレーヤー」 の機能を実装する際に、学び得た知見を書いていこうと思います。

本記事で、対象となる読者は次のとおりです。

対象読者

  • ComposeとExoPlayerを組み合わせた場合のハマったことや実装のちょっとしたTipsについて知りたい方
  • ある程度Composeを触ったことがあるという方

オーディオプレーヤーとは

mikanでは、オーディオ再生が可能な一部教材で使える聞き流し機能のことを「オーディオプレイヤー」と定義しています。

ExoPlayerとは

ExoPlayerとは、動画や音声を扱う際に利用するライブラリです。 ExoPlayerに関しては、詳細な公式ドキュメントが用意されているので、もしわからないことがあればこちらを参照すると良いです。 初めて実装する方はまずコードラボを、サンプルも用意されており、ExoPlayerの使用方法についてはこちらの方が丁寧かと思いますので割愛します。

今回、オーディオプレーヤーを実装するにあたって、Composeと組み合わせた際に4つTipsがあったのでそれらについて紹介させていただこうと思います。

目次

画面を跨いでオーディオを操作したい場合のTips

現在のmikanのオーディオプレーヤー部分の画面は次のとおりです。 トラック一覧画面からプレーヤー詳細画面への動きを載せてみました。

今回機能を実装する際に、Composeで作ってはいるものの、一部Composeに置き換えることができないという状態でした。

現状、mikanのアプリのトップ画面はComposeを使わない実装のままなので、プレーヤー詳細画面を開く時には、別途BottomSheetDialogFragmentを利用しなくてはなりませんでした。

プレーヤー詳細画面のBottomSheetDialogFragment内部では、Composeを利用しています。

この実装の場合だと、画面を跨いで再生位置、再生状態、どのトラックを選択しているかを保持する必要があります。

そのため、mikanではactivityViewModelsとして、オーディオ状態を管理するViewModelを用意するようにしています。

一部でかつ画面を跨いで状態を保存したい時は、activityViewModelsで状態を管理すると簡潔に実装できます。

トラック再生の落とし穴にハマらないためのTips

今回のオーディオプレーヤーを実装する際に、要件として、3秒早送りと巻き戻し、トラックの切り替え部分がありました。

実際にmikanアプリの挙動は次のとおりです。

この挙動を実装する際に、ExoPlayer側で用意されているseekTo関数を利用すると実現できます。

今考えると初歩的なミスではあるのですが、トラックの切り替え時にseekTo(windowIndex, positionMs)を利用している際にハマってしまっていたことがありました。

トラックはトラックに関する情報を持っていて、その中にTrackNumberがあるので、最初のうちはTrackNumberでseekTo(trackNumber, 0)のように呼び出すようにしていました。

しかし、教材によっては構造が異なることから次のようなパターンになっています。

  • 教材
    • アルバム1
      • トラック
    • アルバム2
      • トラック
    • アルバム3
      • トラック

mikanアプリでは、この構造の場合にTrackNumberは必ずしも同じではなく0 ~ 10、11 ~ 21のように変わります。

ExoPlayerにセットしている時には、アルバムそれぞれ全てに設定するのではなくトラックごとにセットしているので、アルバム1では0 ~ 11でたまたまindexがあっていたとしても、アルバム2では11 ~ 21のようになるので、TrackNumberでトラック切り替えを行おうとするとズレが出てきてIllegalSeekPositionExceptionが起きてしまいます。

そのため、seekTo(windowIndex, positionMs)を使う際には、必ずExoPlayerにセットしたリストのindexと合うようにしましょう。

この画面ではオーディオ処理を止めたいのだけど、Composeではどうやったらいいのと困った時のTips

mikanアプリでは、トラック一覧画面では再生してていいけど、アルバム一覧画面では音声を止めて欲しい、という要件がありました。

実際のアプリの挙動はこちらです。これは、トラック一覧画面からアルバム一覧画面へと戻る時の挙動です。

実はこの2画面、Composeで作っています。1つのFragmentにてComposeのスクリーンを切り替えているという感じです。

Composeでの遷移周りは、NavHostとrememberNavControllerで実装すると思います。

特定の画面で、挙動を変えたいということがユースケースとして出てくるのではないかと思います。mikanアプリでもこの音声を止めてほしいという要件がそうでした。

こういった場合に使うのが、addOnDestinationChangedListenerです。これは、rememberNavControllerの現在の表示しているスクリーンがどこであるかを検知できます。

実際にコードで書くと次のとおりです。

val navController = rememberNavController().apply {
    if (currentDestination?.route == AudioScreenType.ALBUM_SCREEN.routeName) {
        // 一番最初の初期状態の時に処理を変えたい場合はこちらに書く
    }
}
navController.addOnDestinationChangedListener { _, destination, _ ->
    if (destination.route == AudioScreenType.ALBUM_SCREEN.routeName) {
        // アルバム一覧画面へきた時の処理を書く
        // ここでExoPlayerの操作をしてしまうのがおすすめ
    }
}

destination.routeは、NavHostにてNavGraphBuilder.composableで指定したroute名と同じですので、単純にdestination.routeと同じであるかどうかの分岐を書くだけでよいです。

ただ、こちら注意点としましては、切り替えるたびに実行されることになりますので、適宜要件に応じてフラグを使うなり、ロジックを持っている側で弾いて早期リターンするなどが必要になってきます。

YouTubeっぽいボトムプレーヤーを作りたい場合のTips

YouTubeのアプリを再生して、バックキーなどの戻る操作をすると、下にミニプレーヤーが表示されると思います。

mikanでは、「ボトムプレーヤー」と呼んでいて、今回この挙動を実現して欲しいという要件がありました。

この挙動、実はComposeでswipeable修飾子を使うことで簡単に作れます。

実際にmikanで実装してみたパターンはこちらになります。

公式ドキュメントにも記載されてある通り、swipeable修飾子を利用することで「スワイプして閉じる」パターンを実装することができます。

swipeable修飾子は、rememberSwipeableStateを用いて状態を作成し、記憶することができます。

この挙動を実装するための全体のコードはこちらです。

val swipeableState = rememberSwipeableState(initialValue = States.EXPANDED, confirmStateChange = { state ->
    if (state == States.COLLAPSED) {
        // ボトムプレーヤーを閉じた時の処理を書く
    }
    true
})

BoxWithConstraints(
        modifier = Modifier
            .fillMaxWidth()
            .align(Alignment.BottomCenter)
    ) {
        val constraintsScope = this
        val maxHeight = with(LocalDensity.current) {
            constraintsScope.maxHeight.toPx()
        }
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .swipeable(
                    orientation = Orientation.Vertical,
                    state = swipeableState,
                    thresholds = { _, _ -> FractionalThreshold(0.5f) },
                    anchors = mapOf(
                        0f to States.EXPANDED,
                        maxHeight to States.COLLAPSED,
                    )
                )
                .offset {
                    IntOffset(
                        x = 0,
                        y = if (swipeableState.offset.value.roundToInt() < 0) 0 else swipeableState.offset.value.roundToInt()
                    )
                }
                .clickable(
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null
                ) {
                    // タップした際の処理
                }
        ) {
            // ボトムプレーヤーのViewのComposableをここに入れる。今回は割愛。
        }
}

ポイントとなる点は、2つあります。

1つ目は、スワイプの状態を定義するのに、rememberSwipeableStateでスワイプの状態を作成し、状態とアンカーのペアを設定する必要があります。

まず、BoxWithConstraintsを利用して、ボトムプレーヤー自体のViewの最大の高さを取得します。次のコードの部分になります。

BoxWithConstraints(
        modifier = Modifier
            .fillMaxWidth()
            .align(Alignment.BottomCenter)
    ) {
        val constraintsScope = this
        val maxHeight = with(LocalDensity.current) {
            constraintsScope.maxHeight.toPx()
        }
}

次に、アンカーとswipeableの状態をペアに設定します。

ボトムプレーヤーのViewをBoxで囲い、swipeable修飾子で、anchorsにて初期位置を0と拡大を示すスワイプ状態(Expanded)と、最大の高さつまり下に移動した際にボトムプレーヤーの高さ分動いて欲しいので最大の高さを下に動かした時のスワイプ状態(COLLAPSED)をペアにして設定します。

今回は縦方向に動いて欲しいので、orientationでは、Orientation.Verticalを設定します。

thresholdsは状態がどこで変わるかの閾値を設定します。今回は半分で良いということだったので、0.5fを指定しました。

こうすることで、rememberSwipeableStateが記憶している縦方向の値をもとにスワイプの状態が紐づきました。

BoxWithConstraints(
        // 省略
    ) {
        // 省略
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .swipeable(
                    orientation = Orientation.Vertical,
                    state = swipeableState,
                    thresholds = { _, _ -> FractionalThreshold(0.5f) },
                    anchors = mapOf(
                        0f to States.EXPANDED,
                        maxHeight to States.COLLAPSED,
                    )
                )
            // 省略
        ) {
            // ボトムプレーヤーのViewのComposableをここに入れる。今回は割愛。
        }
}

2つ目は、offset修飾子で要素となるViewの位置を設定し、制限をかけることです。

今回は、縦方向つまり、yを可変な値に設定することで縦スワイプの移動を実現できます。しかし、1点だけ注意点があります。

次のように書いてしまった場合は、挙動が若干異なってしまいます。

BoxWithConstraints(
        // 省略
    ) {
        // 省略
        Box(
            modifier = Modifier  // わかりやすいように一部省略
                .offset {
                    IntOffset(
                        x = 0,
                        y = swipeableState.offset.value.roundToInt()
                    )
                } 
        // 省略
        ) {
            // ボトムプレーヤーのViewのComposableをここに入れる。今回は割愛。
        }
}

一瞬、うまくいったように見えるのですが、この場合だと上方向の制限がかかっていないので、上に引っ張ると隙間が開いてしまうという不具合が出てしまいます。

そのため、正確には次のように上方向、つまり0より小さい場合は初期位置から移動しないように0を指定して書くことで、期待しているスワイプの挙動を実現することができます。

BoxWithConstraints(
        // 省略
    ) {
        // 省略
        Box(
            modifier = Modifier  // わかりやすいように一部省略
                .offset {
                    IntOffset(
                        x = 0,
                        y = if (swipeableState.offset.value.roundToInt() < 0) 0 else swipeableState.offset.value.roundToInt()
                    )
                } 
        // 省略
        ) {
            // ボトムプレーヤーのViewのComposableをここに入れる。今回は割愛。
        }
}

おわりに

今回は、オーディオプレーヤーをExoPlayerとComposeで作った際に学んだTipsの紹介でした。

オーディオプレーヤーは、音声を繰り返し聞き流しているだけで頭に残ってくるので、覚えられるいい機能なのではないかと思います。音声を聞いて自分でも発音すると覚えるスピードが上がりそうです。ただ、私の場合は、実装するときのハマってつらい記憶も思い出しそうというのが、難点です。

同じようにして、プレーヤーを作ろうとしている方の参考になれば幸いです。

さいごに、mikanでは、一緒に英語学習をより良いものにしていくべくサービスを作っていく仲間を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください!他の職種の方もぜひぜひ、ご応募ください〜〜

03. Android エンジニア

現在デザイナーを特に募集中です!ぜひぜひご応募いただけますと嬉しいです〜〜!

04. デザイナー