Aggiungere Picture in picture (PIP) alla tua app con un video player di Compose

Picture in picture (PIP) è un tipo speciale di modalità multi-finestra utilizzata principalmente per la riproduzione di video. Consente all'utente di guardare un video in una piccola finestra bloccata in un angolo dello schermo mentre naviga tra le app o sfoglia i contenuti sulla schermata principale.

PiP sfrutta le API multi-finestra rese disponibili in Android 7.0 per fornire la finestra overlay video fissata. Per aggiungere PIP all'app, devi registrare la tua attività, passare alla tua attività in modalità PIP, se necessario, e assicurarti che gli elementi UI siano nascosti e che la riproduzione del video continui quando l'attività è in modalità PIP.

Questa guida descrive come aggiungere PIP in Compose alla tua app con un'implementazione di video di Compose. Guarda l'app Socialite per vedere come funzionano queste best practice.

Configurare l'app per PIP

Nel tag attività del file AndroidManifest.xml, procedi nel seguente modo:

  1. Aggiungi supportsPictureInPicture e impostalo su true per dichiarare che userai PIP nell'app.
  2. Aggiungi configChanges e impostalo su orientation|screenLayout|screenSize|smallestScreenSize per specificare che la tua attività gestisce le modifiche alla configurazione del layout. In questo modo, l'attività non viene riavviata quando si verificano modifiche al layout durante le transizioni alla modalità PIP.

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

Nel codice di Compose:

  1. Aggiungi questa estensione su Context. Utilizzerai questa estensione più volte nella guida per accedere all'attività.
    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")
    }

Aggiungere un'app PIP all'uscita per versioni precedenti ad Android 12

Per aggiungere la funzionalità PIP per le versioni precedenti ad Android 12, usa addOnUserLeaveHintProvider. Per aggiungere la funzionalità PIP per le versioni precedenti ad Android 12:

  1. Aggiungi una porta di versione in modo che questo codice sia accessibile solo nelle versioni da O fino alla versione R.
  2. Utilizza un DisposableEffect con Context come chiave.
  3. All'interno di DisposableEffect, definisci il comportamento per l'attivazione di onUserLeaveHintProvider utilizzando una funzione lambda. Nella lambda, chiama enterPictureInPictureMode() su findActivity() e passa PictureInPictureParams.Builder().build().
  4. Aggiungi addOnUserLeaveHintListener usando findActivity() e passa nella lambda.
  5. In onDispose, aggiungi removeOnUserLeaveHintListener usando findActivity() e passa nella 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")
}

Aggiungere un'app PIP all'uscita dopo Android 12

Dopo Android 12, l'elemento PictureInPictureParams.Builder viene aggiunto tramite un modificatore che viene trasmesso al video player dell'app.

  1. Crea un modifier e chiama onGloballyPositioned. Le coordinate di layout verranno utilizzate in un passaggio successivo.
  2. Crea una variabile per PictureInPictureParams.Builder().
  3. Aggiungi un'istruzione if per verificare se l'SDK è S o superiore. In questo caso, aggiungi setAutoEnterEnabled al builder e impostalo su true per attivare la modalità PIP allo scorrimento. In questo modo avrai un'animazione più fluida rispetto a quella offerta da enterPictureInPictureMode.
  4. Usa findActivity() per chiamare setPictureInPictureParams(). Chiama build() sul builder e trasmettilo.

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)

Aggiungere PIP tramite un pulsante

Per attivare la modalità PIP tramite un clic su un pulsante, chiama enterPictureInPictureMode() su findActivity().

I parametri sono già impostati dalle chiamate precedenti a PictureInPictureParams.Builder, quindi non è necessario impostarne di nuovi nel builder. Tuttavia, se vuoi modificare parametri al clic sul pulsante, puoi impostarli qui.

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

Gestire l'UI in modalità PIP

Quando attivi la modalità PIP, l'intera UI dell'app entra nella finestra PIP, a meno che tu non specifichi l'aspetto dell'interfaccia utente dentro e fuori la modalità PIP.

Innanzitutto, devi sapere quando la tua app è in modalità PIP o meno. Puoi utilizzare OnPictureInPictureModeChangedProvider per raggiungere questo obiettivo. Il codice riportato di seguito indica se la tua app è in modalità 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
    }
}

Ora puoi usare rememberIsInPipMode() per attivare/disattivare gli elementi UI da mostrare quando l'app entra in modalità 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()
}

Assicurati che l'app entri in modalità PIP al momento giusto

La tua app non deve attivare la modalità PIP nelle seguenti situazioni:

  • Se il video viene interrotto o messo in pausa.
  • Se ti trovi su una pagina dell'app diversa da quella del video player.

Per controllare quando la tua app deve attivare la modalità PIP, aggiungi una variabile che monitori lo stato del video player utilizzando un elemento mutableStateOf.

Attiva/disattiva lo stato in base alla riproduzione del video

Per attivare/disattivare lo stato in base alla riproduzione del video player, aggiungi un listener sul video player. Attiva/disattiva lo stato della variabile di stato in base al fatto che il player sia in riproduzione o meno:

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

Attiva/disattiva lo stato in base al rilascio del player

Quando il player viene rilasciato, imposta la variabile di stato su false:

fun releasePlayer() {
    shouldEnterPipMode = false
}

Utilizza lo stato per definire se è stata attivata la modalità PIP (prima di Android 12)

  1. Poiché l'aggiunta di PIP pre-12 utilizza un elemento DisposableEffect, devi creare una nuova variabile tramite rememberUpdatedState con newValue impostato come variabile di stato. Ciò garantirà che la versione aggiornata venga utilizzata all'interno di DisposableEffect.
  2. Nella funzione lambda che definisce il comportamento quando viene attivato OnUserLeaveHintListener, aggiungi un'istruzione if con la variabile di stato relativa alla chiamata 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 lo stato per definire se è stata attivata la modalità PIP (dopo Android 12)

Trasmetti la tua variabile di stato in setAutoEnterEnabled in modo che la tua app entri in modalità PIP solo al momento giusto:

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)

Utilizza setSourceRectHint per implementare un'animazione fluida

L'API setSourceRectHint crea un'animazione più fluida per l'accesso alla modalità PIP. In Android 12 e versioni successive, crea anche un'animazione più fluida per l'uscita dalla modalità PIP. Aggiungi questa API al generatore PIP per indicare l'area dell'attività visibile dopo la transizione a PIP.

  1. Aggiungi setSourceRectHint() a builder solo se lo stato definisce che l'app deve passare in modalità PIP. Questo evita di calcolare sourceRect quando l'app non deve inserire PIP.
  2. Per impostare il valore sourceRect, utilizza il valore layoutCoordinates fornito dalla funzione onGloballyPositioned nel modificatore.
  3. Richiama setSourceRectHint() su builder e passa nella variabile 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 per impostare le proporzioni della finestra PIP

Per impostare le proporzioni della finestra Picture in picture, puoi scegliere proporzioni specifiche o utilizzare la larghezza e l'altezza della variabile sourceRect per impostare le proporzioni.

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)

Aggiungi azioni remote

Se vuoi aggiungere controlli (riproduzione, pausa e così via) alla finestra Picture in picture, crea un elemento RemoteAction per ogni controllo che vuoi aggiungere.

  1. Aggiungi costanti per i controlli della trasmissione:
    // 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 un elenco di RemoteActions per i controlli nella finestra PIP.
  3. Successivamente, aggiungi un elemento BroadcastReceiver e sostituisci onReceive() per impostare le azioni di ogni pulsante. Utilizza un elemento DisposableEffect per registrare il ricevitore e le azioni remote. Quando il player è pronto, annulla la registrazione del ricevitore.
    @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. Passa un elenco delle tue azioni da remoto 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)

Passaggi successivi

In questa guida hai appreso le best practice per aggiungere PIP in Compose sia prima che dopo Android 12.