使用 Compose 视频播放器向应用添加画中画 (PiP) 功能

画中画 (PiP) 是一种特殊的多窗口模式,主要用于 视频播放。它可让用户在固定到 浏览各个应用或浏览内容时 主屏幕。

画中画利用 Android 7.0 中提供的多窗口模式 API 来提供固定的视频叠加窗口。如需将画中画添加到您的应用中,您需要注册 activity,根据需要将 activity 切换为画中画模式,并确保当 activity 处于画中画模式时,界面元素处于隐藏状态且视频能够继续播放。

本指南介绍了如何通过 Compose 视频向应用添加 Compose 中的画中画功能 实施。前往 Socialite 应用,查看这些最佳照片 做法。

设置应用以使用画中画功能

AndroidManifest.xml 文件的 activity 标记中,执行以下操作:

  1. 添加 supportsPictureInPicture 并将其设置为 true,以声明您将在应用中使用 PiP。
  2. 添加 configChanges 并将其设置为 orientation|screenLayout|screenSize|smallestScreenSize 来指定 您的 activity 会处理布局配置变更。这样一来,如果在画中画模式转换期间出现布局更改,您的 activity 就不会重新启动。

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

在 Compose 代码中,执行以下操作:

  1. Context 上添加此扩展程序。您将多次使用此附加信息 访问该 activity。
    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 之前的版本添加了“离开时画中画”应用

如需为 Android 12 之前的版本添加画中画,请使用 addOnUserLeaveHintProvider。关注 如需为 Android 12 之前的版本添加画中画,请按以下步骤操作:

  1. 添加版本门控,以便在版本 O 之前仅在版本 R 中访问此代码。
  2. 使用 DisposableEffect 并将 Context 作为键。
  3. DisposableEffect 内,定义在 onUserLeaveHintProvider 是使用 lambda 触发的。在 lambda 中,调用 enterPictureInPictureMode() 调用 findActivity() 并传入 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 之后的版本添加了“离开时画中画”应用

在 Android 12 之后,PictureInPictureParams.Builder 通过 修饰符,传递给应用视频播放器。

  1. 创建一个 modifier 并对其调用 onGloballyPositioned。布局坐标将在后面的步骤中使用。
  2. PictureInPictureParams.Builder() 创建一个变量。
  3. 添加 if 语句以检查 SDK 是否为 S 或更高版本。如果是,请将 setAutoEnterEnabled 转到构建器,并将其设置为 true 以进入画中画模式 模式。这会提供比完整播放的动画更流畅 enterPictureInPictureMode
  4. 使用 findActivity() 调用 setPictureInPictureParams()。拨打 build() 并传入 builder

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)

通过按钮添加画中画

如需通过点击按钮进入画中画模式,请对 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!")
}

在画中画模式下处理界面

当您进入画中画模式时,应用的整个界面都会进入画中画窗口,除非您 指定界面在进入和退出画中画模式时的外观。

首先,您需要了解应用何时处于画中画模式。您可以使用 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() 来切换显示哪些界面元素 当应用进入画中画模式时:

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()
}

确保您的应用在适当的时间进入画中画模式

在以下情况下,您的应用不应进入画中画模式:

  • 视频停止或暂停播放。
  • 您当前打开的应用页面与视频播放器的页面不同。

如需控制应用何时进入画中画模式,请添加一个跟踪应用状态的变量 使用 mutableStateOf

根据视频是否正在播放切换状态

如需根据视频播放器是否正在播放切换状态,请添加监听器 视频播放器。根据玩家是否正在玩游戏,切换状态变量的状态:

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

根据播放器是否已释放来切换状态

播放器释放后,将状态变量设置为 false

fun releasePlayer() {
    shouldEnterPipMode = false
}

使用状态来定义是否进入画中画模式(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")
    }

使用状态来定义是否进入画中画模式(在 Android 12 之后的版本)

将状态变量传递到 setAutoEnterEnabled,以便应用仅进入 开启画中画模式:

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 可创建更流畅的进入画中画模式的动画。在 Android 12 及更高版本中,它还针对退出画中画模式创建了更流畅的动画。 将此 API 添加到 PiP 构建器,以指明在转换为画中画后可见的 activity 区域。

  1. 仅当状态定义了setSourceRectHint()builder 应用应进入画中画模式。这样可以避免在应用运行时计算 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 播放器时,检查该播放器是否不为 null,以及该播放器的 在设置宽高比之前,视频大小不等于 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 的计算宽高比 。

添加远程操作

如果您想要向画中画窗口添加控件(播放、暂停等),请创建一个 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 && 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 之后的版本。