Adicionar picture-in-picture (PiP) ao seu app com um player de vídeo do Compose

O picture-in-picture (PiP) é um tipo especial de modo de várias janelas usado principalmente para reprodução de vídeos. Ele permite que o usuário assista a um vídeo em uma pequena janela fixada em um canto da tela enquanto navega entre apps ou pelo conteúdo na tela principal.

O modo picture-in-picture aproveita as APIs de várias janelas disponíveis no Android 7.0 para fornecer a janela fixa de sobreposição de vídeo. Para adicionar o picture-in-picture ao seu app, é necessário registrar a atividade, alternar para o modo picture-in-picture conforme necessário e garantir que os elementos da interface estejam ocultos e a reprodução do vídeo continue quando a atividade estiver no modo picture-in-picture.

Este guia descreve como adicionar o picture-in-picture no Compose ao seu app com uma implementação de vídeo do Compose. Acesse o app Socialite para conferir essas práticas recomendadas em ação.

Configurar seu app para picture-in-picture

Na tag de atividade do arquivo AndroidManifest.xml, faça o seguinte:

  1. Adicione supportsPictureInPicture e defina-o como true para declarar que você usará o picture-in-picture no seu app.
  2. Adicione configChanges e defina-o como orientation|screenLayout|screenSize|smallestScreenSize para especificar que sua atividade processa as mudanças de configuração de layout. Dessa forma, a atividade não será reiniciada quando ocorrerem mudanças de layout durante as transições do modo picture-in-picture.

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

No código do Compose, faça o seguinte:

  1. Adicione essa extensão no Context. Você usará essa extensão várias vezes em todo o guia para acessar a atividade.
    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")
    }

Adicionar o app PiP ao sair para versões anteriores ao Android 12

Para adicionar o picture-in-picture às versões anteriores ao Android 12, use addOnUserLeaveHintProvider. Siga estas etapas para adicionar o picture-in-picture a versões anteriores ao Android 12:

  1. Adicione uma porta de versão para que esse código seja acessado somente na versão O até R.
  2. Use um DisposableEffect com Context como chave.
  3. Dentro do DisposableEffect, defina o comportamento para quando a onUserLeaveHintProvider for acionada usando uma lambda. Na lambda, chame enterPictureInPictureMode() em findActivity() e transmita PictureInPictureParams.Builder().build().
  4. Adicione addOnUserLeaveHintListener usando findActivity() e transmita a lambda.
  5. Em onDispose, adicione removeOnUserLeaveHintListener usando findActivity() e transmita a 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")
}

Adicionar o app PiP ao sair após o Android 12

Após o Android 12, o PictureInPictureParams.Builder é adicionado por um modificador transmitido ao player de vídeo do app.

  1. Crie uma modifier e chame onGloballyPositioned nela. As coordenadas do layout serão usadas em uma etapa posterior.
  2. Crie uma variável para o PictureInPictureParams.Builder().
  3. Adicione uma instrução if para verificar se o SDK é S ou mais recente. Se esse for o caso, adicione setAutoEnterEnabled ao builder e defina-o como true para entrar no modo de picture-in-picture ao deslizar. Isso proporciona uma animação mais suave do que passar por enterPictureInPictureMode.
  4. Use findActivity() para chamar setPictureInPictureParams(). Chame build() no builder e transmita-o.

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)

Adicionar o picture-in-picture usando um botão

Para entrar no modo picture-in-picture clicando em um botão, chame enterPictureInPictureMode() em findActivity().

Os parâmetros já são definidos por chamadas anteriores para o PictureInPictureParams.Builder. Portanto, não é necessário definir novos parâmetros no builder. No entanto, se você quiser mudar algum parâmetro ao clicar no botão, defina-os aqui.

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

Gerenciar a interface no modo picture-in-picture

Quando você entra no modo picture-in-picture, toda a interface do app entra na janela picture-in-picture, a menos que você especifique como será a aparência da interface dentro e fora desse modo.

Primeiro, você precisa saber quando o app está ou não no modo picture-in-picture. Você pode usar OnPictureInPictureModeChangedProvider para fazer isso. O código abaixo informa se o app está no modo picture-in-picture.

@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
    }
}

Agora, você pode usar rememberIsInPipMode() para alternar quais elementos da interface serão mostrados quando o app entrar no modo picture-in-picture:

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

Verificar se o app entra no modo picture-in-picture nos momentos certos

Seu app não deve entrar no modo picture-in-picture nas seguintes situações:

  • Se o vídeo foi interrompido ou pausado.
  • Se você estiver em uma página do app diferente da que está no player de vídeo.

Para controlar quando o app entra no modo picture-in-picture, adicione uma variável que rastreie o estado do player de vídeo usando um mutableStateOf.

Alternar o estado com base na reprodução do vídeo

Para alternar o estado com base na reprodução do player de vídeo, adicione um listener. Alterne o estado da sua variável de estado com base na reprodução ou não do player:

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

Alterna o estado com base na liberação do player

Quando o player for liberado, defina a variável de estado como false:

fun releasePlayer() {
    shouldEnterPipMode = false
}

Usar o estado para definir se o modo picture-in-picture está no modo picture-in-picture (antes do Android 12)

  1. Como a adição do picture-in-picture pré-12 usa uma DisposableEffect, é necessário criar uma nova variável por rememberUpdatedState com newValue definido como sua variável de estado. Isso garante que a versão atualizada seja usada no DisposableEffect.
  2. Na lambda que define o comportamento quando a OnUserLeaveHintListener é acionada, adicione uma instrução if com a variável de estado em torno da chamada para 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")
    }

Usar o estado para definir se o modo picture-in-picture foi ativado (após o Android 12)

Transmita a variável de estado para setAutoEnterEnabled para que o app entre no modo picture-in-picture apenas no momento certo:

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)

Usar setSourceRectHint para implementar uma animação suave.

A API setSourceRectHint cria uma animação mais suave para entrar no modo de picture-in-picture. No Android 12 e versões mais recentes, ela também cria uma animação mais suave para sair do modo picture-in-picture. Adicione essa API ao builder do PIP para indicar a área da atividade que vai ficar visível após a transição para o picture-in-picture.

  1. Adicione setSourceRectHint() ao builder somente se o estado definir que o app precisa entrar no modo picture-in-picture. Isso evitará calcular sourceRect quando o app não precisar inserir o picture-in-picture.
  2. Para definir o valor de sourceRect, use o layoutCoordinates fornecido pela função onGloballyPositioned no modificador.
  3. Chame setSourceRectHint() em builder e transmita a variável 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)

Usar setAspectRatio para definir a proporção da janela picture-in-picture

Para definir a proporção da janela picture-in-picture, escolha uma proporção específica ou use a largura e a altura da variável sourceRect para definir a proporção.

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)

Adicionar ações remotas

Se você quiser adicionar controles (reproduzir, pausar etc.) à janela do picture-in-picture, crie um RemoteAction para cada controle.

  1. Adicione constantes para seus controles de transmissão:
    // 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. Crie uma lista de RemoteActions para os controles na janela picture-in-picture.
  3. Em seguida, adicione um BroadcastReceiver e modifique onReceive() para definir as ações de cada botão. Use um DisposableEffect para registrar o receptor e as ações remotas. Quando o jogador for descartado, cancele a inscrição do 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. Transmita uma lista de suas ações remotas para o 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óximas etapas

Neste guia, você aprendeu as práticas recomendadas para adicionar o picture-in-picture no Compose antes e depois do Android 12.