Menambahkan picture-in-picture (PiP) ke aplikasi dengan pemutar video Compose

Picture-in-picture (PiP) adalah jenis khusus dari mode multi-aplikasi yang banyak digunakan untuk pemutaran video. Pengguna dapat menonton video di jendela kecil yang disematkan ke pojok layar saat bernavigasi antar-aplikasi atau menjelajahi konten di layar utama.

PiP memanfaatkan API multi-aplikasi yang tersedia di Android 7.0 untuk menyediakan jendela overlay video yang disematkan. Untuk menambahkan PiP ke aplikasi, Anda harus mendaftarkan aktivitas, alihkan aktivitas ke mode PiP sesuai kebutuhan, dan pastikan elemen UI disembunyikan dan pemutaran video dilanjutkan saat aktivitas dalam mode PiP.

Panduan ini menjelaskan cara menambahkan PiP di Compose ke aplikasi dengan video Compose terlepas dari implementasi layanan. Lihat aplikasi Socialite untuk melihat cara kerja praktik terbaik ini.

Menyiapkan aplikasi untuk PiP

Di tag aktivitas file AndroidManifest.xml Anda, lakukan hal berikut:

  1. Tambahkan supportsPictureInPicture dan tetapkan ke true untuk menyatakan bahwa Anda akan menggunakan PiP di aplikasi.
  2. Tambahkan configChanges dan tetapkan ke orientation|screenLayout|screenSize|smallestScreenSize untuk menentukan bahwa aktivitas Anda menangani perubahan konfigurasi tata letak. Dengan cara ini, aktivitas Anda tidak diluncurkan kembali saat perubahan tata letak terjadi selama transisi mode PiP.

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

Dalam kode Compose, lakukan hal berikut:

  1. Tambahkan ekstensi ini di Context. Anda akan menggunakan ekstensi ini beberapa kali di sepanjang panduan untuk mengakses aktivitas ini.
    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")
    }

Menambahkan PiP di aplikasi pergi untuk versi Android sebelum 12

Untuk menambahkan PiP untuk pra-Android 12, gunakan addOnUserLeaveHintProvider. Ikuti langkah-langkah berikut untuk menambahkan PiP untuk versi Android sebelum 12:

  1. Tambahkan gate versi sehingga kode ini hanya diakses dalam versi O hingga R.
  2. Gunakan DisposableEffect dengan Context sebagai kuncinya.
  3. Di dalam DisposableEffect, tentukan perilaku saat onUserLeaveHintProvider dipicu menggunakan lambda. Di lambda, panggil enterPictureInPictureMode() di findActivity() dan teruskan PictureInPictureParams.Builder().build().
  4. Tambahkan addOnUserLeaveHintListener menggunakan findActivity() dan teruskan lambda.
  5. Di onDispose, tambahkan removeOnUserLeaveHintListener menggunakan findActivity() dan teruskan 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")
}

Menambahkan PiP di aplikasi keluar untuk perangkat setelah Android 12

Setelah Android 12, PictureInPictureParams.Builder ditambahkan melalui yang diteruskan ke pemutar video aplikasi.

  1. Buat modifier dan panggil onGloballyPositioned di dalamnya. Tata letak koordinat akan digunakan di langkah selanjutnya.
  2. Buat variabel untuk PictureInPictureParams.Builder().
  3. Tambahkan pernyataan if untuk memeriksa apakah SDK adalah S atau yang lebih baru. Jika ya, tambahkan setAutoEnterEnabled ke builder dan tetapkan ke true untuk masuk ke PiP saat digeser. Ini memberikan animasi yang lebih halus daripada melalui enterPictureInPictureMode
  4. Gunakan findActivity() untuk memanggil setPictureInPictureParams(). Panggil build() pada builder dan teruskan.

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)

Menambahkan PiP melalui tombol

Untuk masuk ke mode PiP dengan mengklik tombol, panggil enterPictureInPictureMode() di findActivity().

Parameter sudah ditetapkan oleh panggilan sebelumnya ke PictureInPictureParams.Builder, sehingga Anda tidak perlu menetapkan parameter baru pada builder. Namun, jika ingin mengubah parameter apa pun saat tombol diklik, Anda dapat menetapkannya di sini.

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

Menangani UI dalam mode PiP

Saat memasuki mode PiP, seluruh UI aplikasi akan memasuki jendela PiP, kecuali jika Anda menentukan tampilan UI Anda di dalam dan di luar mode PiP.

Pertama, Anda perlu mengetahui kapan aplikasi Anda dalam mode PiP atau tidak. Anda dapat menggunakan OnPictureInPictureModeChangedProvider untuk mencapainya. Kode di bawah memberi tahu Anda apakah aplikasi dalam mode 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
    }
}

Sekarang, Anda dapat menggunakan rememberIsInPipMode() untuk menampilkan/menyembunyikan elemen UI yang akan ditampilkan saat aplikasi memasuki mode 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()
}

Pastikan aplikasi Anda memasuki mode PiP pada waktu yang tepat

Aplikasi tidak boleh memasuki mode PiP dalam situasi berikut:

  • Jika video dihentikan atau dijeda.
  • Jika Anda berada di halaman aplikasi yang berbeda dengan pemutar video.

Untuk mengontrol kapan aplikasi memasuki mode PiP, tambahkan variabel yang melacak status pemutar video menggunakan mutableStateOf.

Beralih status berdasarkan apakah video sedang diputar atau tidak

Untuk mengalihkan status berdasarkan apakah pemutar video sedang diputar, tambahkan pemroses di pemutar video. Alihkan status variabel status Anda berdasarkan apakah pemutar sedang diputar atau tidak:

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

Beralih status berdasarkan apakah pemutar dirilis

Saat pemutar dirilis, tetapkan variabel status Anda ke false:

fun releasePlayer() {
    shouldEnterPipMode = false
}

Gunakan status untuk menentukan apakah mode PiP dimasukkan (pra-Android 12)

  1. Karena penambahan PiP untuk pra-12 menggunakan DisposableEffect, Anda harus membuat variabel baru oleh rememberUpdatedState dengan newValue ditetapkan sebagai variabel status. Hal ini akan memastikan bahwa versi terbaru digunakan dalam DisposableEffect.
  2. Di lambda yang menentukan perilaku saat OnUserLeaveHintListener dipicu, tambahkan pernyataan if dengan variabel status di sekitar panggilan untuk 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")
    }

Menggunakan status untuk menentukan apakah mode PiP dimasukkan (pasca-Android 12)

Teruskan variabel status Anda ke setAutoEnterEnabled sehingga aplikasi Anda hanya masuk Mode PiP pada saat yang tepat:

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)

Menggunakan setSourceRectHint untuk menerapkan animasi yang mulus

setSourceRectHint API membuat animasi yang lebih halus untuk memasuki PiP mode. Di Android 12+, tindakan ini juga membuat animasi yang lebih halus untuk keluar dari mode PiP. Tambahkan API ini ke builder PiP untuk menunjukkan area aktivitas yang terlihat setelah transisi ke PiP.

  1. Hanya tambahkan setSourceRectHint() ke builder jika status menentukan bahwa aplikasi akan memasuki mode PiP. Hal ini akan menghindari penghitungan sourceRect saat aplikasi tidak perlu masuk ke dalam PiP.
  2. Untuk menetapkan nilai sourceRect, gunakan layoutCoordinates yang diberikan dari fungsi onGloballyPositioned pada pengubah.
  3. Panggil setSourceRectHint() pada builder dan teruskan sourceRect variabel.

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)

Gunakan setAspectRatio untuk menyetel rasio aspek jendela PiP

Untuk menyetel rasio aspek jendela PiP, Anda dapat memilih rasio aspek atau gunakan lebar dan tinggi ukuran video pemutar. Jika Anda menggunakan pemutar media3, periksa apakah pemutar bukan {i>null <i}dan bahwa antarmuka ukuran video tidak sama dengan VideoSize.UNKNOWN sebelum menetapkan aspek rasio.

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)

Jika Anda menggunakan pemutar kustom, tetapkan rasio aspek pada tinggi pemutar dan lebar menggunakan sintaks yang spesifik untuk pemutar Anda. Perhatikan bahwa jika pemain diubah ukurannya selama inisialisasi, jika berada di luar batas yang valid dari rasio aspeknya, aplikasi Anda akan error. Anda mungkin perlu menambahkan pemeriksaan saat rasio aspek dapat dihitung, mirip dengan yang dilakukan pada web.

Menambahkan tindakan jarak jauh

Jika Anda ingin menambahkan kontrol (putar, jeda, dll.) ke jendela PiP, buat RemoteAction untuk setiap kontrol yang ingin ditambahkan.

  1. Tambahkan konstanta untuk kontrol siaran Anda:
    // 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. Buat daftar RemoteActions untuk kontrol di jendela PiP.
  3. Selanjutnya, tambahkan BroadcastReceiver dan ganti onReceive() untuk menyetel tindakan setiap tombol. Gunakan DisposableEffect untuk mendaftarkan penerima dan tindakan jarak jauh. Saat pemutar dibuang, batalkan pendaftaran penerima.
    @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. Teruskan daftar tindakan jarak jauh Anda ke 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)

Langkah berikutnya

Dalam panduan ini, Anda telah mempelajari praktik terbaik untuk menambahkan PiP di Compose sebelum Android 12 dan setelah Android 12.

  • Lihat aplikasi Sosialit untuk melihat praktik terbaik Cara kerja PiP Compose.
  • Lihat panduan desain PiP untuk mengetahui informasi selengkapnya.