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

O picture-in-picture (PiP) é um tipo especial do modo de várias janelas usado principalmente para a reprodução de vídeos. Ele permite que o usuário assista 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, registre o alterne sua atividade para o modo picture-in-picture conforme necessário e verifique se os elementos da interface ficam ocultos e a reprodução do vídeo continua quando a atividade está no modo picture-in-picture.

Este guia descreve como adicionar o PiP no Compose ao seu app com uma implementação de vídeo do Compose. Acesse o app Socialite para conferir as melhores práticas de IA 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ê vai fazer o seguinte: usando o picture-in-picture no seu app.
  2. Adicionar configChanges e defini-lo como orientation|screenLayout|screenSize|smallestScreenSize para especificar sua atividade lida com mudanças de configuração de layout. Dessa forma, sua atividade não é reiniciado quando ocorrem alterações 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. Adicionar essa extensão ao Context. Você usará essa extensão várias vezes ao longo do 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 picture-in-picture ao sair do app para versões anteriores ao Android 12

Para adicionar o PiP em versões anteriores ao Android 12, use addOnUserLeaveHintProvider. Siga estas etapas para adicionar o PiP para versões anteriores ao Android 12:

  1. Adicione uma porta de versão para que esse código seja acessado apenas nas versões O até R.
  2. Use um DisposableEffect com Context como a chave.
  3. No DisposableEffect, defina o comportamento quando o onUserLeaveHintProvider é acionado 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 transmitir 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 info", "API does not support PiP")
}

Adicionar picture-in-picture ao sair do app para o pós-Android 12

Após o Android 12, a PictureInPictureParams.Builder é adicionada usando uma transmitido ao player de vídeo do app.

  1. Crie uma modifier e chame onGloballyPositioned nela. O 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. Em caso afirmativo, adicione setAutoEnterEnabled como o builder e o defina como true para entrar no picture-in-picture. ao deslizar. Isso proporciona uma animação mais suave enterPictureInPictureMode
  4. Use findActivity() para chamar setPictureInPictureParams(). Ligar para build() em o builder e transmiti-lo.

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 picture-in-picture com um botão

Para entrar no modo PiP com um clique no botão, chame enterPictureInPictureMode() em findActivity().

Os parâmetros já foram 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 os parâmetros 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!")
}

Processar a interface no modo picture-in-picture

Quando você entra no modo picture-in-picture, toda a interface do app entra nessa janela, a menos que você especificar como a interface vai ficar dentro e fora do modo picture-in-picture.

Primeiro, você precisa saber quando seu app está no modo picture-in-picture ou não. Você pode usar OnPictureInPictureModeChangedProvider para fazer isso. O código abaixo informa se o aplicativo 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()
}

Conferir 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 for interrompido ou pausado.
  • Se você estiver em uma página diferente do app do player de vídeo.

Para controlar quando seu 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 no estado de reprodução do player, adicione um listener ao player. Alterna o estado da variável de estado com base em se o player está sendo reproduzido ou não:

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

Alternar o estado com base no lançamento do jogador

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 é usado (anteriores ao Android 12)

  1. Como a adição do PiP antes do Android 12 usa um DisposableEffect, é necessário criar uma nova variável com 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 o OnUserLeaveHintListener for acionado, adicione uma instrução if com a variável de estado ao redor 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 info", "API does not support PiP")
    }

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

Transmita a variável de estado ao setAutoEnterEnabled para que o app só entre Modo picture-in-picture 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)

Use setSourceRectHint para implementar uma animação suave

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

  1. Só adicione setSourceRectHint() à builder se o estado definir que o deve entrar no modo picture-in-picture. Isso evita calcular sourceRect quando o app não precisará entrar no modo picture-in-picture.
  2. Para definir o valor sourceRect, use os layoutCoordinates fornecidos pela função onGloballyPositioned no modificador.
  3. Chame setSourceRectHint() no builder e transmita o sourceRect variável.

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 PiP, escolha uma proporção específica ou use a largura e a altura do tamanho do vídeo do player. Se você for usando um player de mídia3, verifique se o player não é nulo e se a o tamanho do vídeo não é igual a VideoSize.UNKNOWN antes de definir o aspecto proporção.

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)

Se você estiver usando um player personalizado, defina a proporção na altura do player. e largura usando a sintaxe específica do seu player. Se o player for redimensionado durante a inicialização, se ele estiver fora dos limites válidos de qual pode ser a proporção, o app vai falhar. Talvez seja necessário adicionar verificações quando a proporção pode ser calculada, semelhante ao que é feito para um player media3.

Adicionar ações remotas

Para adicionar controles (reproduzir, pausar etc.) à janela picture-in-picture, crie um RemoteAction para cada controle que você quer adicionar.

  1. Adicione constantes para os 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 substitua onReceive() para definir o 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 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 das 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 && 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)

Próximas etapas

Neste guia, você conheceu as práticas recomendadas para adicionar picture-in-picture no Compose versões anteriores ao Android 12 e posteriores ao Android 12.