Cómo agregar pantalla en pantalla (PIP) a tu app con un reproductor de video de Compose

La función pantalla en pantalla (PIP) es un tipo especial de modo multiventana que se usa principalmente para la reproducción de videos. Permite que el usuario vea un video en una ventana pequeña fijada en una esquina de la pantalla mientras navega entre apps o explora contenido en la pantalla principal.

PIP aprovecha las API de multiventana disponibles en Android 7.0 para proporcionar la ventana de video fijada superpuesta. Para agregar PIP a tu app, debes registrar la actividad, cambiar la actividad al modo de PIP según sea necesario y asegurarte de que los elementos de la IU estén ocultos y la reproducción de video continúe cuando la actividad esté en modo de PIP.

En esta guía, se describe cómo agregar PIP en Compose a tu app con una implementación de video de Compose. Consulta la app de Socialite para ver estas prácticas recomendadas en acción.

Cómo configurar tu app para PIP

En la etiqueta de actividad de tu archivo AndroidManifest.xml, haz lo siguiente:

  1. Agrega supportsPictureInPicture y establécelo en true para declarar que usarás PIP en tu app.
  2. Agrega configChanges y establécelo en orientation|screenLayout|screenSize|smallestScreenSize para especificar que tu actividad controla los cambios de configuración de diseño. De esta manera, tu actividad no se reiniciará cuando se produzcan cambios de diseño durante las transiciones del modo de PIP.

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

En tu código de Compose, haz lo siguiente:

  1. Agrega esta extensión a Context. Usarás esta extensión varias veces a lo largo de la guía para acceder a la actividad.
    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")
    }

Agrega la app de PIP cuando se sale de Android 12

Si quieres agregar PIP para versiones anteriores a Android 12, usa addOnUserLeaveHintProvider. Sigue estos pasos para agregar PIP en versiones anteriores a Android 12:

  1. Agrega una puerta de versión para que solo se pueda acceder a este código en las versiones O hasta R.
  2. Usa un objeto DisposableEffect con Context como clave.
  3. Dentro de DisposableEffect, define el comportamiento para cuando onUserLeaveHintProvider se active con una expresión lambda. En la expresión lambda, llama a enterPictureInPictureMode() en findActivity() y pasa PictureInPictureParams.Builder().build().
  4. Agrega addOnUserLeaveHintListener con findActivity() y pasa la lambda.
  5. En onDispose, agrega removeOnUserLeaveHintListener con findActivity() y pasa la 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")
}

Cómo agregar la app de PIP cuando se sale de Android 12

Después de Android 12, el PictureInPictureParams.Builder se agrega a través de un modificador que se pasa al reproductor de video de la app.

  1. Crea una modifier y llama a onGloballyPositioned en ella. Las coordenadas de diseño se usarán en un paso posterior.
  2. Crea una variable para PictureInPictureParams.Builder().
  3. Agrega una sentencia if para verificar si el SDK es S o superior. Si es así, agrega setAutoEnterEnabled al compilador y establécelo en true para ingresar al modo de PIP cuando se desliza el dedo. De esta manera, obtendrás una animación más fluida que si pasas por enterPictureInPictureMode.
  4. Usa findActivity() para llamar a setPictureInPictureParams(). Llama a build() en builder y pásalo.

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)

Cómo agregar PIP con un botón

Para ingresar al modo de PIP con un clic en un botón, llama a enterPictureInPictureMode() en findActivity().

Los parámetros ya se establecen en llamadas anteriores a PictureInPictureParams.Builder, por lo que no necesitas establecer parámetros nuevos en el compilador. Sin embargo, si deseas cambiar algún parámetro cuando se hace clic en el botón, puedes configurarlos aquí.

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

Controla tu IU en modo de PIP

Cuando ingresas al modo de PIP, toda la IU de tu app ingresa a la ventana de PIP, a menos que especifiques cómo debería verse dentro y fuera del modo de PIP.

Primero, debes saber cuándo la app está en modo de PIP o no. Puedes usar OnPictureInPictureModeChangedProvider para lograrlo. En el siguiente código, se indica si tu app está en modo de 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
    }
}

Ahora, puedes usar rememberIsInPipMode() para activar o desactivar los elementos de la IU que se mostrarán cuando la app entre en modo de PIP:

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

Asegúrate de que la app ingrese al modo de PIP en los momentos correctos

Tu app no debe ingresar al modo de PIP en las siguientes situaciones:

  • Si el video se detiene o se pausa
  • Si estás en una página de la app diferente a la del reproductor de video.

Para controlar cuándo tu app ingresa al modo de PIP, agrega una variable que realice un seguimiento del estado del reproductor de video mediante un mutableStateOf.

Activar o desactivar el estado según si se está reproduciendo el video

Para activar o desactivar el estado en función de si el reproductor de video se está reproduciendo, agrega un objeto de escucha en el reproductor. Activa o desactiva el estado de tu variable de estado en función de si el reproductor está reproduciendo o no:

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

Activar o desactivar el estado si se suelta el reproductor

Cuando se libere el reproductor, establece tu variable de estado en false:

fun releasePlayer() {
    shouldEnterPipMode = false
}

Usa el estado para definir si se ingresa el modo de PIP (versiones anteriores a Android 12).

  1. Dado que agregar PIP pre-12 usa un DisposableEffect, debes crear una variable nueva mediante rememberUpdatedState con newValue establecido como la variable de estado. Esto garantizará que la versión actualizada se use en DisposableEffect.
  2. En la expresión lambda que define el comportamiento cuando se activa OnUserLeaveHintListener, agrega una declaración if con la variable de estado alrededor de la llamada a 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")
    }

Usa el estado para definir si se ingresa el modo de PIP (versiones posteriores a Android 12)

Pasa la variable de estado a setAutoEnterEnabled para que tu app solo ingrese al modo de PIP en el momento correcto:

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)

Cómo usar setSourceRectHint para implementar una animación fluida

La API de setSourceRectHint crea una animación más fluida para ingresar al modo de PIP. En Android 12 y versiones posteriores, también crea una animación más fluida para salir del modo de PIP. Agrega esta API al compilador de PIP para indicar el área de la actividad que es visible después de la transición a PIP.

  1. Solo agrega setSourceRectHint() a builder si el estado define que la app debe ingresar al modo de PIP. De esta manera, se evita calcular sourceRect cuando la app no necesita ingresar el modo de PIP.
  2. Para establecer el valor sourceRect, usa los layoutCoordinates que se proporcionan de la función onGloballyPositioned en el modificador.
  3. Llama a setSourceRectHint() en el builder y pasa la variable 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)

Usa setAspectRatio para establecer la relación de aspecto de la ventana de PIP

Para establecer la relación de aspecto de la ventana de PIP, puedes elegir una relación de aspecto específica o usar el ancho y la altura de la variable sourceRect para establecer la relación de aspecto.

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)

Cómo agregar acciones remotas

Si quieres agregar controles (reproducir, pausar, etc.) a tu ventana de PIP, crea un RemoteAction para cada control que desees agregar.

  1. Agrega constantes para los controles de transmisión:
    // 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. Crea una lista de RemoteActions para los controles en tu ventana de PIP.
  3. A continuación, agrega un BroadcastReceiver y anula onReceive() para configurar las acciones de cada botón. Usa un DisposableEffect para registrar el receptor y las acciones remotas. Cuando se deseche el reproductor, cancela el registro del receptor.
    @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. Pasa una lista de tus acciones remotas a 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)

Próximos pasos

En esta guía, aprendiste las prácticas recomendadas para agregar PIP en Compose en versiones anteriores a Android 12 y posteriores a Android 12.

  • Consulta la app de Socialite para ver las prácticas recomendadas de PIP de Compose en acción.
  • Consulta la guía de diseño de PIP para obtener más información.