使用 Compose 播放器为您的应用添加画中画 (PiP)

画中画 (PiP) 是一种特殊类型的多窗口模式,主要用于视频播放。它允许用户在一个固定在屏幕角落的小窗口中观看视频,同时在应用程序之间导航或浏览主屏幕上的内容。

PiP 利用 Android 7.0 中提供的多窗口 API 提供固定的视频叠加窗口。要向您的应用添加 PiP,您需要注册您的活动,根据需要将您的活动切换到 PiP 模式,并确保在活动处于 PiP 模式时 UI 元素隐藏且视频播放继续。

本指南介绍如何在 Compose 中向您的应用添加 PiP,以及使用 Compose 视频实现。请参阅 Socialite 应用以查看这些最佳实践。

设置您的应用以使用 PiP

在您的 AndroidManifest.xml 文件的活动标签中,执行以下操作

  1. 添加 supportsPictureInPicture 并将其设置为 true 以声明您将在应用中使用 PiP。
  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. 添加版本门,以便仅在版本 O 到 R 中访问此代码。
  2. 使用 DisposableEffect 并将 Context 作为键。
  3. DisposableEffect 内部,使用 lambda 定义触发 onUserLeaveHintProvider 时的行为。在 lambda 中,在 findActivity() 上调用 enterPictureInPictureMode() 并传入 PictureInPictureParams.Builder().build()
  4. 使用 findActivity() 添加 addOnUserLeaveHintListener 并传入 lambda。
  5. onDispose 中,使用 findActivity() 添加 removeOnUserLeaveHintListener 并传入 lambda。

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 info", "API does not support PiP")
}

在离开应用时为 Android 12 及更高版本添加 PiP

Android 12 及更高版本,PictureInPictureParams.Builder 是通过传递给应用的视频播放器的修饰符添加的。

  1. 创建一个 modifier 并对其调用 onGloballyPositioned。布局坐标将在后面的步骤中使用。
  2. PictureInPictureParams.Builder() 创建一个变量。
  3. 添加一个 if 语句以检查 SDK 是否为 S 或更高版本。如果是,则向构建器添加 setAutoEnterEnabled 并将其设置为 true 以在滑动时进入 PiP 模式。这提供了比通过 enterPictureInPictureMode 进入 PiP 模式更流畅的动画。
  4. 使用 findActivity() 调用 setPictureInPictureParams()。在构建器上调用 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. 由于 Android 12 之前的版本添加 PiP 使用 DisposableEffect,因此您需要通过 rememberUpdatedState 创建一个新变量,并将 newValue 设置为状态变量。这将确保在 DisposableEffect 中使用更新的版本。
  2. 在定义触发 OnUserLeaveHintListener 时的行为的 lambda 中,在对 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 info", "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. 仅当状态定义应用应进入 PiP 模式时,才向 builder 添加 setSourceRectHint()。这避免了在应用不需要进入 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 窗口的纵横比,您可以选择特定的纵横比或使用播放器的视频尺寸的宽度和高度。如果您使用的是 media3 播放器,请在设置纵横比之前检查播放器是否不为空,以及播放器的视频尺寸是否不等于 VideoSize.UNKNOWN

val context = LocalContext.current

val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()
    if (shouldEnterPipMode && player != null && player.videoSize != VideoSize.UNKNOWN) {
        val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
        builder.setSourceRectHint(sourceRect)
        builder.setAspectRatio(
            Rational(player.videoSize.width, player.videoSize.height)
        )
    }

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

VideoPlayer(pipModifier)

如果您使用的是自定义播放器,请使用特定于您的播放器的语法设置播放器的高度和宽度上的纵横比。请注意,如果您的播放器在初始化期间调整大小,如果它超出纵横比的有效范围,您的应用将崩溃。您可能需要在可以计算纵横比的时间周围添加检查,类似于 media3 播放器的方式。

添加远程操作

如果您想向 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. 为您的画中画窗口中的控件创建一个 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 && player != null && player.videoSize != VideoSize.UNKNOWN) {
            val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
            builder.setSourceRectHint(sourceRect)
            builder.setAspectRatio(
                Rational(player.videoSize.width, player.videoSize.height)
            )
        }
    
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            builder.setAutoEnterEnabled(shouldEnterPipMode)
        }
        context.findActivity().setPictureInPictureParams(builder.build())
    }
    VideoPlayer(modifier = pipModifier)

后续步骤

在本指南中,您学习了在 Compose 中添加画中画的最佳实践,包括 Android 12 之前和 Android 12 之后。