Thêm tính năng hình trong hình (PiP) vào ứng dụng bằng trình phát video trong Compose

Hình trong hình (PiP) là một loại chế độ nhiều cửa sổ đặc biệt, chủ yếu dùng để phát video. API này cho phép người dùng xem video trong một cửa sổ nhỏ được ghim vào một góc của màn hình trong khi di chuyển giữa các ứng dụng hoặc duyệt qua nội dung trên màn hình chính.

PiP tận dụng các API nhiều cửa sổ có trong Android 7.0 để cung cấp cửa sổ lớp phủ của video đã ghim. Để thêm PiP vào ứng dụng, bạn cần đăng ký hoạt động, chuyển hoạt động sang chế độ PiP nếu cần, đồng thời đảm bảo ẩn các thành phần giao diện người dùng và tiếp tục phát video khi hoạt động đó ở chế độ PiP.

Hướng dẫn này mô tả cách thêm PiP trong Compose vào ứng dụng bằng cách triển khai video trong Compose. Xem ứng dụng Socialite để xem các phương pháp hay nhất này trong thực tế.

Thiết lập ứng dụng để dùng tính năng PiP (Hình trong hình)

Trong thẻ hoạt động của tệp AndroidManifest.xml, hãy làm như sau:

  1. Thêm supportsPictureInPicture rồi đặt thành true để khai báo rằng bạn sẽ sử dụng PiP trong ứng dụng.
  2. Thêm configChanges và đặt thành orientation|screenLayout|screenSize|smallestScreenSize để chỉ định rằng hoạt động của bạn xử lý các thay đổi về cấu hình bố cục. Theo đó, hoạt động của bạn sẽ không chạy lại khi các thay đổi về bố cục diễn ra trong quá trình chuyển đổi chế độ PiP.

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

Trong mã Compose, hãy làm như sau:

  1. Thêm tiện ích này vào Context. Bạn sẽ sử dụng tiện ích này nhiều lần trong suốt hướng dẫn để truy cập vào hoạt động.
    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")
    }

Thêm PiP khi rời khỏi ứng dụng cho phiên bản trước Android 12

Để thêm PiP cho phiên bản trước Android 12, hãy dùng addOnUserLeaveHintProvider. Làm theo các bước sau để thêm PiP cho phiên bản trước Android 12:

  1. Thêm cổng phiên bản để chỉ truy cập mã này trong các phiên bản O cho đến R.
  2. Sử dụng DisposableEffect với Context làm khoá.
  3. Bên trong DisposableEffect, hãy xác định hành vi khi onUserLeaveHintProvider được kích hoạt bằng lambda. Trong hàm lambda, hãy gọi enterPictureInPictureMode() trên findActivity() rồi truyền PictureInPictureParams.Builder().build().
  4. Thêm addOnUserLeaveHintListener bằng findActivity() rồi truyền vào hàm lambda.
  5. Trong onDispose, hãy thêm removeOnUserLeaveHintListener bằng findActivity() rồi truyền vào hàm 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")
}

Thêm PiP khi rời khỏi ứng dụng cho Android 12 trở lên

Sau Android 12, PictureInPictureParams.Builder được thêm thông qua một đối tượng sửa đổi được truyền đến trình phát video của ứng dụng.

  1. Tạo modifier rồi gọi onGloballyPositioned trên đó. Toạ độ bố cục sẽ được sử dụng trong bước sau.
  2. Tạo biến cho PictureInPictureParams.Builder().
  3. Thêm câu lệnh if để kiểm tra xem SDK có phải là S trở lên hay không. Nếu có, hãy thêm setAutoEnterEnabled vào trình tạo rồi đặt thành true để chuyển sang chế độ PiP khi vuốt. Thao tác này sẽ tạo ảnh động mượt mà hơn so với khi truyền qua enterPictureInPictureMode.
  4. Sử dụng findActivity() để gọi setPictureInPictureParams(). Gọi build() trên builder rồi truyền vào.

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)

Thêm PiP thông qua một nút

Để chuyển sang chế độ Hình trong hình bằng cách nhấp vào nút, hãy gọi enterPictureInPictureMode() trên findActivity().

Các tham số đã được đặt bởi các lệnh gọi trước đó đến PictureInPictureParams.Builder, vì vậy, bạn không cần phải đặt tham số mới trên trình tạo. Tuy nhiên, nếu muốn thay đổi bất kỳ tham số nào khi nhấp vào nút, bạn có thể đặt các tham số đó tại đây.

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

Xử lý giao diện người dùng ở chế độ PiP (Hình trong hình)

Khi bạn chuyển sang chế độ PiP, toàn bộ giao diện người dùng của ứng dụng sẽ chuyển vào cửa sổ PiP, trừ phi bạn chỉ định giao diện người dùng sẽ hiển thị trong và ngoài chế độ PiP.

Trước tiên, bạn cần biết khi nào ứng dụng của mình đang ở chế độ Hình trong hình. Bạn có thể sử dụng OnPictureInPictureModeChangedProvider để thực hiện việc này. Mã dưới đây cho bạn biết ứng dụng có đang ở chế độ Hình trong hình hay không.

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

Giờ đây, bạn có thể dùng rememberIsInPipMode() để bật/tắt các phần tử trên giao diện người dùng sẽ hiển thị khi ứng dụng chuyển sang chế độ 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()
}

Đảm bảo ứng dụng của bạn chuyển sang chế độ Hình trong hình vào đúng thời điểm

Ứng dụng của bạn không nên chuyển sang chế độ PiP trong các trường hợp sau:

  • Trường hợp video bị dừng hoặc tạm dừng.
  • Nếu bạn đang ở một trang khác của ứng dụng với trình phát video.

Để kiểm soát thời điểm ứng dụng chuyển sang chế độ PiP, hãy thêm một biến giúp theo dõi trạng thái của trình phát video bằng mutableStateOf.

Bật/tắt trạng thái dựa trên việc video có đang phát hay không

Để chuyển đổi trạng thái dựa trên việc trình phát video có đang phát hay không, hãy thêm một trình nghe vào trình phát video. Bật/tắt trạng thái của biến trạng thái dựa trên việc người chơi có đang phát hay không:

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

Bật/tắt trạng thái dựa trên việc người chơi có nhả tay hay không

Khi trình phát được giải phóng, hãy đặt biến trạng thái thành false:

fun releasePlayer() {
    shouldEnterPipMode = false
}

Dùng trạng thái để xác định xem bạn có đang dùng chế độ PiP hay không (trước Android 12)

  1. Vì việc thêm PiP pre-12 sẽ sử dụng DisposableEffect, nên bạn cần tạo một biến mới bằng rememberUpdatedState, trong đó newValue được đặt làm biến trạng thái. Điều này sẽ đảm bảo rằng phiên bản cập nhật sẽ được sử dụng trong DisposableEffect.
  2. Trong hàm lambda xác định hành vi khi OnUserLeaveHintListener được kích hoạt, hãy thêm câu lệnh if có biến trạng thái xung quanh lệnh gọi đến 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")
    }

Dùng trạng thái để xác định xem bạn có đang dùng chế độ PiP hay không (sau Android 12)

Truyền biến trạng thái vào setAutoEnterEnabled để ứng dụng của bạn chỉ chuyển sang chế độ PiP vào đúng thời điểm:

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)

Sử dụng setSourceRectHint để triển khai ảnh động mượt mà

API setSourceRectHint tạo ảnh động mượt mà hơn để chuyển sang chế độ PiP. Trên Android 12 trở lên, tính năng này cũng tạo ảnh động mượt mà hơn để thoát chế độ PiP. Thêm API này vào trình tạo PiP để cho biết khu vực hoạt động có thể hiển thị sau khi chuyển đổi sang PiP.

  1. Chỉ thêm setSourceRectHint() vào builder nếu trạng thái xác định rằng ứng dụng phải chuyển sang chế độ PiP. Điều này giúp tránh tính toán sourceRect khi ứng dụng không cần nhập PiP.
  2. Để đặt giá trị sourceRect, hãy sử dụng layoutCoordinates nhận được từ hàm onGloballyPositioned trên đối tượng sửa đổi.
  3. Gọi setSourceRectHint() trên builder rồi truyền vào biến 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)

Dùng setAspectRatio để đặt tỷ lệ khung hình của cửa sổ PiP

Để đặt tỷ lệ khung hình của cửa sổ PiP, bạn có thể chọn một tỷ lệ khung hình cụ thể hoặc sử dụng chiều rộng và chiều cao của biến sourceRect để đặt tỷ lệ khung hình.

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)

Thêm hành động từ xa

Nếu bạn muốn thêm các chế độ điều khiển (phát, tạm dừng, v.v.) vào cửa sổ Hình trong hình, hãy tạo một RemoteAction cho từng chế độ điều khiển mà bạn muốn thêm.

  1. Thêm hằng số cho các chế độ điều khiển thông báo truyền phát:
    // 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. Tạo danh sách RemoteActions cho các chế độ điều khiển trong cửa sổ PiP.
  3. Tiếp theo, hãy thêm BroadcastReceiver và ghi đè onReceive() để đặt thao tác cho từng nút. Sử dụng DisposableEffect để đăng ký receiver và hành động từ xa. Khi trình phát bị huỷ, hãy huỷ đăng ký trình thu.
    @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. Chuyển danh sách các thao tác từ xa vào 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)

Các bước tiếp theo

Trong hướng dẫn này, bạn đã tìm hiểu các phương pháp hay nhất để thêm PiP trong Compose cả trước Android 12 và sau Android 12.