Add picture-in-picture (PiP) to your app with a Compose video player

Picture-in-picture (PiP) is a special type of multi-window mode mostly used for video playback. It lets the user watch a video in a small window pinned to a corner of the screen while navigating between apps or browsing content on the main screen.

PiP leverages the multi-window APIs made available in Android 7.0 to provide the pinned video overlay window. To add PiP to your app, you need to register your activity, switch your activity to PiP mode as needed, and make sure UI elements are hidden and video playback continues when the activity is in PiP mode.

This guide describes how to add PiP in Compose to your app with a Compose video implementation. See the Socialite app to see these best practices in action.

Set up your app for PiP

In the activity tag of your AndroidManifest.xml file, do the following:

  1. Add supportsPictureInPicture and set it to true to declare you'll be using PiP in your app.
  2. Add configChanges and set it to orientation|screenLayout|screenSize|smallestScreenSize to specify that your activity handles layout configuration changes. This way, your activity doesn't relaunch when layout changes occur during PiP mode transitions.

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

In your Compose code, do the following:

  1. Add this extension on Context. You'll use this extension multiple times throughout the guide to access the 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")
    }

Add PiP on leave app for pre-Android 12

To add PiP for pre-Android 12, use addOnUserLeaveHintProvider. Follow these steps to add PiP for pre-Android 12:

  1. Add a version gate so that this code is only accessed in versions O until R.
  2. Use a DisposableEffect with Context as the key.
  3. Inside the DisposableEffect, define the behavior for when the onUserLeaveHintProvider is triggered using a lambda. In the lambda, call enterPictureInPictureMode() on findActivity() and pass in PictureInPictureParams.Builder().build().
  4. Add addOnUserLeaveHintListener using findActivity() and pass in the lambda.
  5. In onDispose, add removeOnUserLeaveHintListener using findActivity() and pass in the 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")
}

Add PiP on leave app for post-Android 12

Post-Android 12, the PictureInPictureParams.Builder is added through a modifier that is passed to the app's video player.

  1. Create a modifier and call onGloballyPositioned on it. The layout coordinates will be used in a later step.
  2. Create a variable for the PictureInPictureParams.Builder().
  3. Add an if statement to check if the SDK is S or above. If so, add setAutoEnterEnabled to the builder and set it to true to enter PiP mode upon swipe. This provides a smoother animation than going through enterPictureInPictureMode.
  4. Use findActivity() to call setPictureInPictureParams(). Call build() on the builder and pass it in.

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)

Add PiP through a button

To enter PiP mode through a button click, call enterPictureInPictureMode() on findActivity().

The parameters are already set by previous calls to the PictureInPictureParams.Builder, so you do not need to set new parameters on the builder. However, if you do want to change any parameters on button click, you can set them here.

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

Handle your UI in PiP mode

When you enter PiP mode, your app's entire UI enters the PiP window unless you specify how your UI should look in and out of PiP mode.

First, you need to know when your app is in PiP mode or not. You can use OnPictureInPictureModeChangedProvider to achieve this. The code below tells you if your app is in PiP mode.

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

Now, you can use rememberIsInPipMode() to toggle which UI elements to show when the app enters PiP mode:

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

Make sure that your app enters PiP mode at the right times

Your app should not enter PiP mode in the following situations:

  • If the video is stopped or paused.
  • If you are on a different page of the app than the video player.

To control when your app enters PiP mode, add a variable that tracks the state of the video player using a mutableStateOf.

Toggle state based on if video is playing

To toggle the state based on if the video player is playing, add a listener on the video player. Toggle the state of your state variable based on if the player is playing or not:

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

Toggle state based on if player is released

When the player is released, set your state variable to false:

fun releasePlayer() {
    shouldEnterPipMode = false
}

Use state to define if PiP mode is entered (pre-Android 12)

  1. Since adding PiP pre-12 uses a DisposableEffect, you need to create a new variable by rememberUpdatedState with newValue set as your state variable. This will ensure that the updated version is used within the DisposableEffect.
  2. In the lambda that defines the behavior when the OnUserLeaveHintListener is triggered, add an if statement with the state variable around the call to 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")
    }

Use state to define if PiP mode is entered (post-Android 12)

Pass your state variable into setAutoEnterEnabled so that your app only enters PiP mode at the right time:

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 to implement a smooth animation

The setSourceRectHint API creates a smoother animation for entering PiP mode. In Android 12+, it also creates a smoother animation for exiting PiP mode. Add this API to the PiP builder to indicate the area of the activity that is visible following the transition into PiP.

  1. Only add setSourceRectHint() to the builder if the state defines that the app should enter PiP mode. This avoids calculating sourceRect when the app does not need to enter PiP.
  2. To set the sourceRect value, use the layoutCoordinates that are given from the onGloballyPositioned function on the modifier.
  3. Call setSourceRectHint() on the builder and pass in the sourceRect variable.

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)

Use setAspectRatio to set PiP window's aspect ratio

To set the aspect ratio of the PiP window, you can either choose a specific aspect ratio or use the width and height of the player's video size. If you are using a media3 player, check that the player is not null and that the player's video size is not equal to VideoSize.UNKNOWN before setting the aspect ratio.

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)

If you are using a custom player, set the aspect ratio on the player's height and width using the syntax specific to your player. Be aware that if your player resizes during initialization, if it falls outside of the valid bounds of what the aspect ratio can be, your app will crash. You may need to add checks around when the aspect ratio can be calculated, similar to how it is done for a media3 player.

Add remote actions

If you want to add controls (play, pause, etc.) to your PiP window, create a RemoteAction for each control you want to add.

  1. Add constants for your broadcast controls:
    // 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. Create a list of RemoteActions for the controls in your PiP window.
  3. Next, add a BroadcastReceiver and override onReceive() to set the actions of each button. Use a DisposableEffect to register the receiver and the remote actions. When the player is disposed, unregister the receiver.
    @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. Pass in a list of your remote actions to the 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)

Next steps

In this guide you learned the best practices of adding PiP in Compose both pre-Android 12 and post-Android 12.