Android Picture-in-Picture (PIP) Mode allows multitasking by allowing users to continue watching video (or any other content) while interacting with other apps. Introduced in Android 8.0 (API level 26), PIP mode minimizes video playback into a small, movable window that stays visible on the screen as users navigate their device. This feature is particularly useful for apps that play video, such as media players or video conferencing apps, providing a seamless viewing experience even when switching tasks.
In this article, we’ll explore the basics of PIP and how to implement it for a video playing in ExoPlayer.
The complete code of this project can be found here. Let me know your queries in the comments section below.
Cheers!
Happy Coding 🤗
In this article, we’ll explore the basics of PIP and how to implement it for a video playing in ExoPlayer.
1. The Basics
Before getting into full example, let's cover the basics of PiP. All the pointers discussed below are necessary to have smoother PiP experience.1.1 Declare picture-in-picture Support
By default, the picture-in-picture is not enabled for any app. To enabled PiP in your app, the following changes needs to be done in AndroidManifest.xml- To add PiP support to an activity, add android:supportsPictureInPicture to true.
- Add android:configChanges property to avoid activity restarts when layout configuration changes.
- If necessary, add android:launchMode="singleTask" as well to avoid launching the multiple instances of same activity.
<activity
android:name=".PlayerActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:launchMode="singleTask"
android:supportsPictureInPicture="true" />
1.2 Enter into picture-in-picture
To enter into PiP mode, simply call enterPictureInPictureMode() in your activity by providing necesarry params.
val params = PictureInPictureParams.Builder()
...
.build()
setPictureInPictureParams(params)
// switch to PiP mode
enterPictureInPictureMode(params)
1.3 Smoother PiP transition using Picture In Picture Params
You can use PictureInPictureParams to configure the Picture-in-Picture mode. It allows you to set parameters like aspect ratio, custom actions (such as play/pause controls), and PiP window bounds.- sourceRectHint - Defines the activity area that needs to visible in PiP window. For example, in a video player activity, only the video player should be shown in PiP window.
- setAutoEnterEnabled - Starting from Android 12, setting setAutoEnterEnabled(true) automatically switches the activity to PiP mode when user moves away from the app by using gester navigation, for example pressing home moves the activity into PiP.
- onUserLeaveHint - If you are targeting your app below Android 12, you can call enterPictureInPictureModes() in onUserLeaveHint() method to switch to PiP mode. This is not needed if you are using setAutoEnterEnabled(true).
- setAspectRatio - This ratio defines the desired width and height of PiP window.
- setActions - Using this method you can add custom action buttons to PiP window. For example, you can add play and pause buttons if you are playing a video.
- OnBackPressedCallback - If you want to switch to PiP mode when device back is pressed, you can utilize OnBackPressedCallback.
// preparing visible rect of video player
val visibleRect = Rect()
binding.playerView.getGlobalVisibleRect(visibleRect)
val builder = PictureInPictureParams.Builder()
.setAspectRatio(Rational(visibleRect.width(), visibleRect.height()))
.setSourceRectHint(visibleRect)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// enable auto enter on Android 12
builder.setAutoEnterEnabled(true)
}
val params = builder.build()
// set pip params before entering into PiP
setPictureInPictureParams(params)
enterPictureInPictureMode(params)
2. Playing ExoPlayer Video in PiP
Now let's see how to play the ExoPlayer video in Picture In Picture mode.ExoPlayer is a powerful media player for Android that provides efficient, customizable playback of audio and video content. If you’re new to ExoPlayer, you can refer to this article to learn more.{alertInfo}
- Create a new project in Android Studio from File => New Project and select Empty Views Activity.
- Add ExoPlayer dependencies to app's build.gradle
build.gradle
dependencies { .... // exoplayer implementation("androidx.media3:media3-exoplayer:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") implementation("androidx.media3:media3-exoplayer-dash:1.4.1") }
- Create a new activity class PlayerActivity and add the explayer to it's layout file.
activity_player.xml
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/black" android:orientation="vertical" tools:context=".MainActivity"> <androidx.media3.ui.PlayerView android:id="@+id/player_view" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:show_buffering="when_playing" app:use_controller="false" /> </androidx.constraintlayout.widget.ConstraintLayout>
- Open AndroidManifest.xml and add INTERNET permission to play the media from a remote URL. Add the necessary properties supportsPictureInPicture, configChanges, launchMode required for PiP mode.
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.ExoPlayerPip" tools:targetApi="31"> <activity android:name=".MainActivity" android:theme="@style/Theme.ExoPlayerPip" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity android:name=".PlayerActivity" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:exported="false" android:launchMode="singleTask" android:supportsPictureInPicture="true" /> </application> </manifest>
- Open PlayerActivity and do the following changes. In summary
- In initializePlayer(), the ExoPlayer is initialized and the video is played when player is ready
- The updatePictureInPictureParams() method takes care of PiP configuration like aspect ratio, view source rect area and adding custom actions like play/pause buttons to PiP window. Here if the video is portrait video, the aspect ratio is set as 9:16, otherwise the ratio will be 16:9
- Calling enterPipMode() enters the activity into picture-in-picture mode.
- In onUserLeaveHint(), the activity is entered into PiP mode when app runs on below Android 12.
- A broadcast receiver is used to receive the events when PiP window custom actions are tapped. Based on playback state, the video is paused or resumed and the custom action icons are toggled by calling updatePictureInPictureParams() at runtime.
- The back press is handled using OnBackPressedCallback and the activity is entered into PiP if the video is playing. If the video is not playing, the usual back navigation happens.
PlayerActivity.ktpackage info.androidhive.exoplayerpip import android.app.AppOpsManager import android.app.PendingIntent import android.app.PictureInPictureParams import android.app.RemoteAction import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Rect import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle import android.util.Rational import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.annotation.DrawableRes import androidx.annotation.OptIn import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import info.androidhive.exoplayerpip.databinding.ActivityPlayerBinding class PlayerActivity : AppCompatActivity(), Player.Listener { private val binding by lazy(LazyThreadSafetyMode.NONE) { ActivityPlayerBinding.inflate(layoutInflater) } private var player: Player? = null private var mediaUrl: String? = null private var isPortrait: Boolean = false // whether OS supports PiP or not private val isPipSupported by lazy { packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) } companion object { private const val ACTION_PLAYER_CONTROLS = "action_player_controls" private const val EXTRA_CONTROL_TYPE = "control_type" private const val CONTROL_PLAY_PAUSE = 1 private const val REQUEST_PLAY_OR_PAUSE = 2 } private var backPressCallback: OnBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { // enter PiP mode when video is playing if (!isInPictureInPictureMode && binding.playerView.player?.isPlaying == true) { enterPipMode() } } } // broadcast receiver to receiver actions when pip window action buttons are tapped private val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent == null || intent.action != ACTION_PLAYER_CONTROLS) { return } when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) { // Toggle b/w play and pause CONTROL_PLAY_PAUSE -> { if (binding.playerView.player?.isPlaying == true) { binding.playerView.player?.pause() } else { binding.playerView.player?.play() } // update the pip window actions after player is paused or resumed updatePictureInPictureParams() } } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) mediaUrl = intent.extras?.getString("url") isPortrait = intent.extras?.getBoolean("is_portrait") ?: false if (mediaUrl.isNullOrBlank()) { Toast.makeText(this, "Media url is null!", Toast.LENGTH_SHORT).show() finish() } // register the broadcast receiver registerReceiver(broadcastReceiver, IntentFilter(ACTION_PLAYER_CONTROLS)) } @OptIn(UnstableApi::class) private fun initializePlayer() { // release the player if the activity is already running in PIP mode if (player != null) { releasePlayer() } player = ExoPlayer.Builder(this).build().also { exoPlayer -> binding.playerView.player = exoPlayer val mediaBuilder = MediaItem.Builder().setUri(mediaUrl) val mediaItem = mediaBuilder.build() exoPlayer.setMediaItems(listOf(mediaItem)) exoPlayer.addListener(this) exoPlayer.playWhenReady = true exoPlayer.prepare() } } private fun enterPipMode() { enterPictureInPictureMode(updatePictureInPictureParams()) } // enter into PIP when home is pressed override fun onUserLeaveHint() { super.onUserLeaveHint() enterPipMode() } // Updating picture in picture param private fun updatePictureInPictureParams(): PictureInPictureParams { val visibleRect = Rect() binding.playerView.getGlobalVisibleRect(visibleRect) // Aspect ratio based on video size landscape or portrait val rational = if (isPortrait) { Rational(9, 16) } else { Rational(16, 9) } val builder = PictureInPictureParams.Builder() .setAspectRatio(rational) .setActions( listOf( // Keeping play or pause action based on player state if (binding.playerView.player?.isPlaying == false) { // video is not playing. Keep play action createRemoteAction( R.drawable.ic_play, R.string.play, REQUEST_PLAY_OR_PAUSE, CONTROL_PLAY_PAUSE ) } else { // video is playing. Keep pause action createRemoteAction( R.drawable.ic_pause, R.string.pause, REQUEST_PLAY_OR_PAUSE, CONTROL_PLAY_PAUSE ) } ) ) .setSourceRectHint(visibleRect) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // enable auto enter on Android 12 builder.setAutoEnterEnabled(true) } val params = builder.build() setPictureInPictureParams(params) return params } override fun onPictureInPictureModeChanged( isInPictureInPictureMode: Boolean, newConfig: Configuration ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) // Hide player controls when in PIP mode binding.playerView.useController = !isInPictureInPictureMode } override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) // enable back press handler when pip is supported, pip permission is given and video is playing backPressCallback.isEnabled = isPlaying && isPipSupported && isPipPermissionEnabled() } private fun isPipPermissionEnabled(): Boolean { val appOps = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager? val enabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { appOps?.unsafeCheckOpNoThrow( AppOpsManager.OPSTR_PICTURE_IN_PICTURE, android.os.Process.myUid(), packageName ) == AppOpsManager.MODE_ALLOWED } else { appOps?.checkOpNoThrow( AppOpsManager.OPSTR_PICTURE_IN_PICTURE, android.os.Process.myUid(), packageName ) == AppOpsManager.MODE_ALLOWED } return enabled } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // receiving new media url when activity is running in PIP mode mediaUrl = intent.extras?.getString("url") initializePlayer() } public override fun onStart() { super.onStart() backPressCallback.remove() onBackPressedDispatcher.addCallback(this, backPressCallback) initializePlayer() } override fun onPause() { super.onPause() if (!isInPictureInPictureMode) { binding.playerView.onPause() } } override fun onResume() { super.onResume() binding.playerView.useController = true } override fun onStop() { super.onStop() backPressCallback.remove() unregisterReceiver(broadcastReceiver) releasePlayer() // remove the activity from recent tasks as PIP activity won't be // removed automatically if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { finishAndRemoveTask() } } private fun releasePlayer() { player?.release() player = null } // Method to create action button for pip window private fun createRemoteAction( @DrawableRes iconResId: Int, @StringRes titleResId: Int, requestCode: Int, controlType: Int ): RemoteAction { return RemoteAction( Icon.createWithResource(this, iconResId), getString(titleResId), getString(titleResId), PendingIntent.getBroadcast( this, requestCode, Intent(ACTION_PLAYER_CONTROLS).putExtra(EXTRA_CONTROL_TYPE, controlType), PendingIntent.FLAG_IMMUTABLE ) ) } }
- Finally test the PiP by launching the PlayerActivity by passing the video url.
MainActivity.kt
class MainActivity : AppCompatActivity() { private val binding by lazy(LazyThreadSafetyMode.NONE) { ActivityMainBinding.inflate(layoutInflater) } private var mediaUrlLandscape: String = "https://firebasestorage.googleapis.com/v0/b/project-8525323942962534560.appspot.com/o/samples%2FBig%20Buck%20Bunny%2060fps%204K%20-%20Official%20Blender%20Foundation%20Short%20Film.mp4?alt=media&token=351ab76e-6e1f-43eb-b868-0a060277a338" private var mediaUrlPortrait: String = "https://firebasestorage.googleapis.com/v0/b/project-8525323942962534560.appspot.com/o/samples%2F15365448-hd_1080_1920_30fps.mp4?alt=media&token=4e2bc0e5-42f9-412c-9681-df20aa00599d" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) binding.content.btnVideoLandscape.setOnClickListener { playVideo(mediaUrlLandscape) } binding.content.btnVideoPortrait.setOnClickListener { playVideo(mediaUrlPortrait) } } private fun playVideo(url: String) { startActivity(Intent(this, PlayerActivity::class.java).apply { putExtra("url", url) }) } }
The complete code of this project can be found here. Let me know your queries in the comments section below.
Cheers!
Happy Coding 🤗
Le fir Aagaye
ReplyDelete