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

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

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

本指南介绍如何在 Compose 中使用 Compose 视频实现将 PiP 添加到您的应用程序中。请参阅 Socialite 应用程序,了解这些最佳实践的实际应用。

为 PiP 设置您的应用程序

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

  1. 添加 supportsPictureInPicture 并将其设置为 true,声明您将在应用程序中使用 PiP。
  2. 添加 configChanges 并将其设置为 orientation|screenLayout|screenSize|smallestScreenSize,以指定您的 Activity 处理布局配置更改。这样,当在 PiP 模式转换期间发生布局更改时,您的 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 之前退出应用程序时添加 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_TAG, "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. 由于在 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_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 之后可见的 Activity 区域。

  1. 仅当状态定义应用程序应该进入 PiP 模式时,才将 setSourceRectHint() 添加到构建器中。这避免了在应用程序不需要进入 PiP 时计算 sourceRect
  2. 要设置 sourceRect 值,请使用 layoutCoordinates,这些坐标来自修饰符上的 onGloballyPositioned 函数。
  3. 在构建器上调用 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 播放器中进行的操作。

添加远程操作

如果您想在 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 && 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)

后续步骤

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

  • 请参阅 Socialite 应用程序,了解 Compose PiP 最佳实践的实际应用。
  • 有关更多信息,请参阅 PiP 设计指南