Compose 動画プレーヤーを使用してアプリにピクチャー イン ピクチャー(PIP)を追加する

ピクチャー イン ピクチャー(PIP)は、主に動画の再生に使用される特別なタイプのマルチウィンドウ モードです。ユーザーは、画面の隅に固定された小さなウィンドウで動画を視聴しながら、メイン画面でアプリ間を移動したり、コンテンツをブラウジングしたりできます。

PIP は、Android 7.0 で使用可能になったマルチウィンドウ API を利用して、固定された動画オーバーレイ ウィンドウを提供します。PIP をアプリに追加するには、アクティビティを登録し、必要に応じてアクティビティを PIP モードに切り替えて、アクティビティが PIP モードのときに UI 要素が非表示になり、動画の再生が続行されるようにする必要があります。

このガイドでは、Compose の動画実装を使用して、Compose の PIP をアプリに追加する方法について説明します。これらのベスト プラクティスの実例については、Socialite アプリをご覧ください。

PIP 用にアプリを設定する

AndroidManifest.xml ファイルのアクティビティ タグで、次の操作を行います。

  1. アプリで PIP を使用することを宣言するには、supportsPictureInPicture を追加して true に設定します。
  2. configChanges を追加して orientation|screenLayout|screenSize|smallestScreenSize に設定すると、アクティビティがレイアウト構成の変更を処理するように指定できます。こうすることで、PIP モードの遷移中にレイアウト変更が発生しても、アクティビティが再起動されなくなります。

      <activity
        android:name=".SnippetsActivity"
        android:exported="true"
        android:supportsPictureInPicture="true"
        android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize"
        android:theme="@style/Theme.Snippets">
    

Compose コードで、次の操作を行います。

  1. この拡張機能を Context に追加してください。アクティビティにアクセスするには、ガイド全体を通してこの拡張機能を複数回使用します。
    internal fun Context.findActivity(): ComponentActivity {
        var context = this
        while (context is ContextWrapper) {
            if (context is ComponentActivity) return context
            context = context.baseContext
        }
        throw IllegalStateException("Picture in picture should be called in the context of an Activity")
    }

Android 12 より前のバージョンで休暇アプリに PIP を追加する

Android 12 より前のバージョン用に PIP を追加するには、addOnUserLeaveHintProvider を使用します。Android 12 より前のバージョンで PIP を追加する手順は次のとおりです。

  1. バージョン ゲートを追加して、このコードに R までバージョン O でのみアクセスできるようにします。
  2. Context をキーとして DisposableEffect を使用します。
  3. DisposableEffect 内で、ラムダを使用して onUserLeaveHintProvider がトリガーされたときの動作を定義します。ラムダで、findActivity() に対して enterPictureInPictureMode() を呼び出し、PictureInPictureParams.Builder().build() を渡します。
  4. findActivity() を使用して addOnUserLeaveHintListener を追加し、ラムダを渡します。
  5. onDispose で、findActivity() を使用して removeOnUserLeaveHintListener を追加し、ラムダを渡します。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
    Build.VERSION.SDK_INT < Build.VERSION_CODES.S
) {
    val context = LocalContext.current
    DisposableEffect(context) {
        val onUserLeaveBehavior: () -> Unit = {
            context.findActivity()
                .enterPictureInPictureMode(PictureInPictureParams.Builder().build())
        }
        context.findActivity().addOnUserLeaveHintListener(
            onUserLeaveBehavior
        )
        onDispose {
            context.findActivity().removeOnUserLeaveHintListener(
                onUserLeaveBehavior
            )
        }
    }
} else {
    Log.i(PIP_TAG, "API does not support PiP")
}

Android 12 以降で休暇アプリに PIP を追加する

Android 12 以降、PictureInPictureParams.Builder は、アプリの動画プレーヤーに渡される修飾子を介して追加されます。

  1. modifier を作成し、それに対して onGloballyPositioned を呼び出します。レイアウト座標は後のステップで使用します。
  2. PictureInPictureParams.Builder() の変数を作成します。
  3. SDK が S 以降かどうかを確認する if ステートメントを追加します。その場合は、setAutoEnterEnabled をビルダーに追加して true に設定し、スワイプで PIP モードを開始します。これにより、enterPictureInPictureMode を実行するよりもスムーズなアニメーションになります。
  4. findActivity() を使用して setPictureInPictureParams() を呼び出します。builder に対して build() を呼び出し、それを渡します。

val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setAutoEnterEnabled(true)
    }
    context.findActivity().setPictureInPictureParams(builder.build())
}
VideoPlayer(pipModifier)

ボタンで PIP を追加する

ボタンを押して PIP モードに切り替えるには、findActivity()enterPictureInPictureMode() を呼び出します。

パラメータは以前の PictureInPictureParams.Builder の呼び出しで設定済みであるため、ビルダーに新しいパラメータを設定する必要はありません。ただし、ボタンのクリック時にパラメータを変更する場合は、ここで設定できます。

val context = LocalContext.current
Button(onClick = {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        context.findActivity().enterPictureInPictureMode(
            PictureInPictureParams.Builder().build()
        )
    } else {
        Log.i(PIP_TAG, "API does not support PiP")
    }
}) {
    Text(text = "Enter PiP mode!")
}

PIP モードで UI を処理する

PIP モードに入ると、UI を PIP モードで、どのように見えるかを指定しない限り、アプリの UI 全体が PIP ウィンドウに入ります。

まず、アプリが PIP モードになっているかどうかを把握する必要があります。そのためには、OnPictureInPictureModeChangedProvider を使用します。次のコードは、アプリが PIP モードかどうかを示しています。

@Composable
fun rememberIsInPipMode(): Boolean {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val activity = LocalContext.current.findActivity()
        var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) }
        DisposableEffect(activity) {
            val observer = Consumer<PictureInPictureModeChangedInfo> { info ->
                pipMode = info.isInPictureInPictureMode
            }
            activity.addOnPictureInPictureModeChangedListener(
                observer
            )
            onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
        }
        return pipMode
    } else {
        return false
    }
}

これで、rememberIsInPipMode() を使用して、アプリが PIP モードになったときに表示する UI 要素を切り替えられるようになりました。

val inPipMode = rememberIsInPipMode()

Column(modifier = modifier) {
    // This text will only show up when the app is not in PiP mode
    if (!inPipMode) {
        Text(
            text = "Picture in Picture",
        )
    }
    VideoPlayer()
}

アプリが適切なタイミングで PIP モードに入るようにしてください

次のような場合は、アプリを PIP モードにしないでください。

  • 動画が停止または一時停止した場合。
  • アプリのページと動画プレーヤーが異なる。

アプリが PIP モードに入るタイミングを制御するには、mutableStateOf を使用して、動画プレーヤーの状態を追跡する変数を追加します。

動画が再生されているかどうかに応じて状態を切り替える

動画プレーヤーが再生されているかどうかに基づいて状態を切り替えるには、動画プレーヤーにリスナーを追加します。プレーヤーが再生しているかどうかに基づいて、状態変数の状態を切り替えます。

player.addListener(object : Player.Listener {
    override fun onIsPlayingChanged(isPlaying: Boolean) {
        shouldEnterPipMode = isPlaying
    }
})

プレーヤーが離されたかどうかに基づいて状態を切り替える

プレーヤーが離されたら、状態変数を false に設定します。

fun releasePlayer() {
    shouldEnterPipMode = false
}

状態を使用して PIP モードに入るかどうかを定義する(Android 12 より前)

  1. PIP 12 より前のバージョンを追加する場合、DisposableEffect を使用するため、newValue を状態変数として、rememberUpdatedState によって新しい変数を作成する必要があります。これにより、更新されたバージョンが DisposableEffect 内で使用されます。
  2. OnUserLeaveHintListener がトリガーされたときの動作を定義するラムダで、enterPictureInPictureMode() の呼び出しの周りに状態変数を含む if ステートメントを追加します。

    val currentShouldEnterPipMode by rememberUpdatedState(newValue = shouldEnterPipMode)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
        Build.VERSION.SDK_INT < Build.VERSION_CODES.S
    ) {
        val context = LocalContext.current
        DisposableEffect(context) {
            val onUserLeaveBehavior: () -> Unit = {
                if (currentShouldEnterPipMode) {
                    context.findActivity()
                        .enterPictureInPictureMode(PictureInPictureParams.Builder().build())
                }
            }
            context.findActivity().addOnUserLeaveHintListener(
                onUserLeaveBehavior
            )
            onDispose {
                context.findActivity().removeOnUserLeaveHintListener(
                    onUserLeaveBehavior
                )
            }
        }
    } else {
        Log.i(PIP_TAG, "API does not support PiP")
    }

状態を使用して PIP モードに入るかどうかを定義する(Android 12 以降)

状態変数を setAutoEnterEnabled に渡して、適切なタイミングでアプリが PIP モードに入るようにします。

val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()

    // Add autoEnterEnabled for versions S and up
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setAutoEnterEnabled(shouldEnterPipMode)
    }
    context.findActivity().setPictureInPictureParams(builder.build())
}

VideoPlayer(pipModifier)

setSourceRectHint を使用してスムーズなアニメーションを実装する

setSourceRectHint API は、PIP モードに入る際のアニメーションをより滑らかにします。Android 12 以降では、PIP モードを終了するときに、よりスムーズなアニメーションも作成されます。この API を PiP ビルダーに追加して、PIP への移行後に表示されるアクティビティの領域を指定します。

  1. setSourceRectHint()builder に追加するのは、アプリが PIP モードに入る必要があることを状態で定義されている場合のみです。これにより、アプリが PIP に入る必要がないときに sourceRect が計算されなくなります。
  2. sourceRect 値を設定するには、修飾子の onGloballyPositioned 関数から渡された layoutCoordinates を使用します。
  3. builder に対して setSourceRectHint() を呼び出し、sourceRect 変数を渡します。

val context = LocalContext.current

val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()
    if (shouldEnterPipMode) {
        val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
        builder.setSourceRectHint(sourceRect)
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setAutoEnterEnabled(shouldEnterPipMode)
    }
    context.findActivity().setPictureInPictureParams(builder.build())
}

VideoPlayer(pipModifier)

setAspectRatio を使用して PIP ウィンドウのアスペクト比を設定する

PIP ウィンドウのアスペクト比を設定するには、特定のアスペクト比を選択するか、sourceRect 変数の幅と高さを使用してアスペクト比を設定します。

val context = LocalContext.current

val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()

    if (shouldEnterPipMode) {
        val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
        builder.setSourceRectHint(sourceRect)
        builder.setAspectRatio(
            Rational(sourceRect.width(), sourceRect.height())
        )
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setAutoEnterEnabled(shouldEnterPipMode)
    }
    context.findActivity().setPictureInPictureParams(builder.build())
}

VideoPlayer(pipModifier)

リモート アクションを追加する

PIP ウィンドウにコントロール(再生、一時停止など)を追加する場合は、追加するコントロールごとに RemoteAction を作成します。

  1. ブロードキャスト コントロール用の定数を追加します。
    // Constant for broadcast receiver
    const val ACTION_BROADCAST_CONTROL = "broadcast_control"
    
    // Intent extras for broadcast controls from Picture-in-Picture mode.
    const val EXTRA_CONTROL_TYPE = "control_type"
    const val EXTRA_CONTROL_PLAY = 1
    const val EXTRA_CONTROL_PAUSE = 2
  2. PIP ウィンドウのコントロール用に RemoteActions のリストを作成します。
  3. 次に、BroadcastReceiver を追加し、onReceive() をオーバーライドして、各ボタンのアクションを設定します。DisposableEffect を使用してレシーバとリモート アクションを登録します。プレーヤーが不要になったら、レシーバーの登録を解除します。
    @RequiresApi(Build.VERSION_CODES.O)
    @Composable
    fun PlayerBroadcastReceiver(player: Player?) {
        val isInPipMode = rememberIsInPipMode()
        if (!isInPipMode || player == null) {
            // Broadcast receiver is only used if app is in PiP mode and player is non null
            return
        }
        val context = LocalContext.current
    
        DisposableEffect(player) {
            val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() {
                override fun onReceive(context: Context?, intent: Intent?) {
                    if ((intent == null) || (intent.action != ACTION_BROADCAST_CONTROL)) {
                        return
                    }
    
                    when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
                        EXTRA_CONTROL_PAUSE -> player.pause()
                        EXTRA_CONTROL_PLAY -> player.play()
                    }
                }
            }
            ContextCompat.registerReceiver(
                context,
                broadcastReceiver,
                IntentFilter(ACTION_BROADCAST_CONTROL),
                ContextCompat.RECEIVER_NOT_EXPORTED
            )
            onDispose {
                context.unregisterReceiver(broadcastReceiver)
            }
        }
    }
  4. リモート アクションのリストを PictureInPictureParams.Builder に渡します。
    val context = LocalContext.current
    
    val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
        val builder = PictureInPictureParams.Builder()
        builder.setActions(
            listOfRemoteActions()
        )
    
        if (shouldEnterPipMode) {
            val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
            builder.setSourceRectHint(sourceRect)
            builder.setAspectRatio(
                Rational(sourceRect.width(), sourceRect.height())
            )
        }
    
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            builder.setAutoEnterEnabled(shouldEnterPipMode)
        }
        context.findActivity().setPictureInPictureParams(builder.build())
    }
    VideoPlayer(modifier = pipModifier)

次のステップ

このガイドでは、Android 12 以前と Android 12 以降の両方で Compose に PIP を追加するベスト プラクティスを学習しました。

  • Compose PIP の実際のベスト プラクティスについては、Socialite アプリをご覧ください。
  • 詳しくは、PIP 設計ガイダンスをご覧ください。