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

画中画 (PiP) 是一种特殊的多窗口模式,主要用于视频播放。通过该模式,用户可在固定到屏幕一角的小窗口中观看视频,同时在应用之间进行导航或浏览主屏幕上的内容。

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

本指南介绍了如何通过 Compose 视频实现将 Compose 中的画中画功能添加到应用中。如需了解这些最佳实践的实际运用,请参阅 Socialite 应用。

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

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

  1. 添加 supportsPictureInPicture 并将其设置为 true,以声明您将在应用中使用画中画功能。
  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 中访问到 Android 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_TAG, "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()。对 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)

通过按钮添加画中画

如需通过点击按钮进入画中画模式,请在 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 来实现此目的。以下代码会告知您应用是否处于画中画模式。

@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. 由于在 12 之前版本中添加画中画需要使用 DisposableEffect,因此您需要通过 rememberUpdatedState 创建新变量,并将 newValue 设置为状态变量。这将确保在 DisposableEffect 中使用更新后的版本。
  2. 在用于定义触发 OnUserLeaveHintListener 时行为的 lambda 中,添加一个 if 语句,并在对 enterPictureInPictureMode() 的调用前后添加状态变量:

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

使用状态来定义是否进入画中画模式(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 添加到画中画构建器中,以指示过渡到画中画后可见的 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 设置画中画窗口的宽高比

如需设置画中画窗口的宽高比,您可以选择特定宽高比,也可以使用 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)

添加远程操作

如果您要向画中画窗口添加控件(播放、暂停等),请为要添加的每个控件创建一个 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) {
            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 画中画的最佳实践,请参阅 Socialite 应用。
  • 如需了解详情,请参阅 PiP 设计指南