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 đặc biệt của chế độ nhiều cửa sổ được dùng chủ yếu khi phát lại video. Tính năng này cho phép người dùng xem video trong một cửa sổ nhỏ được ghim vào góc màn hình trong khi di chuyển giữa các ứng dụng hoặc duyệt xem 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 tính năng Hình trong hình vào ứng dụng, bạn cần đăng ký hoạt động, chuyển hoạt động của bạn sang chế độ Hình trong hình nếu cần và đảm bảo các thành phần trên giao diện người dùng sẽ bị ẩn và video sẽ tiếp tục phát khi hoạt động này đang ở chế độ Hình trong hình.

Hướng dẫn này mô tả cách thêm tính năng Hình trong hình trong Compose vào ứng dụng bằng video trong Compose trong quá trình triển khai. Hãy xem ứng dụng Socialite để biết các phương pháp hay nhất này hoạt động như thế nào.

Thiết lập ứng dụng để bật 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 và đặt thành true để khai báo rằng bạn sẽ sử dụng tính năng PiP trong ứng dụng.
  2. Thêm configChanges rồi đặ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. Bằng cách này, hoạt động của bạn sẽ không chạy lại khi các thay đổi về bố cục xảy 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 trên 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 tính năng Hình trong hình khi thoát ứng dụng trên Android 12

Để thêm tính năng Hình trong hình cho những phiên bản trước Android 12, hãy dùng addOnUserLeaveHintProvider. Theo dõi sau đây để thêm PiP đối với Android 12 trở về trước:

  1. Thêm cổng phiên bản để chỉ truy cập được mã này ở 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 cho thời điểm kích hoạt onUserLeaveHintProvider bằng cách sử dụng lambda. Trong hàm lambda, hãy gọi enterPictureInPictureMode() trên findActivity() rồi truyền vào PictureInPictureParams.Builder().build().
  4. Thêm addOnUserLeaveHintListener bằng findActivity() và truyền vào lambda.
  5. Trong onDispose, hãy thêm removeOnUserLeaveHintListener bằng findActivity() và 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 info", "API does not support PiP")
}

Thêm PiP khi rời khỏi ứng dụng cho phiên bản 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 chuyển đến trình phát video của ứng dụng.

  1. Tạo modifier rồi gọi onGloballyPositioned trên đó. Bố cục toạ độ sẽ được sử dụng trong bước sau.
  2. Tạo một 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 và đặt thành true để nhập PiP (Hình trong hình) khi vuốt. Cách này giúp ảnh động trở nên mượt mà hơn so với khi xem hết enterPictureInPictureMode.
  4. Sử dụng findActivity() để gọi setPictureInPictureParams(). Cuộc gọi cho 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ế độ PiP thông qua một lượt 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 đó đối với hàm PictureInPictureParams.Builder, nên bạn không cần đặt thông số mới trên trình tạo. Tuy nhiên, nếu bạn muốn thay đổi bất kỳ tham số nào trên nút nhấp vào, bạn có thể đặt chúng 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ế độ 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 sang cửa sổ PiP trừ phi bạn chỉ định giao diện người dùng sẽ trông như thế nào cả 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 có đang ở chế độ PiP hay không. Bạn có thể sử dụng OnPictureInPictureModeChangedProvider để đạt được mục tiêu này. Đoạn mã dưới đây cho bạn biết ứng dụng của bạn có đang ở chế độ PiP 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ể sử dụng rememberIsInPipMode() để bật/tắt những thành phần 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ế độ PiP 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 chứ không phả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 theo dõi trạng thái của trình phát video bằng mutableStateOf.

Chuyển đổi trạng thái dựa trên việc video có đang phát hay không

Để bật/tắt 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 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 chơi 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ó được thả hay không

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

fun releasePlayer() {
    shouldEnterPipMode = false
}

Sử dụng trạng thái để xác định xem bạn đã nhập chế độ PiP hay chưa (trước Android 12)

  1. Vì việc thêm PiP trước phiên bản 12 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 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 info", "API does not support PiP")
    }

Sử dụng trạng thái để xác định xem đã nhập chế độ PiP hay chưa (sau Android 12)

Truyền biến trạng thái vào setAutoEnterEnabled để ứng dụng của bạn chỉ nhập 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 để nhập PiP (Hình trong hình) . Trên Android 12 trở lên, tính năng này cũng tạo ra ảnh động mượt mà hơn khi thoát khỏi chế độ PiP. Thêm API này vào trình tạo PiP để cho biết khu vực của hoạt động 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 được cung cấp qua hàm onGloballyPositioned trên đối tượng sửa đổi.
  3. Gọi setSourceRectHint() trên builder rồi truyền sourceRect vào biến.

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)

Sử 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 tỷ lệ khung hình hoặc sử dụng chiều rộng và chiều cao cho kích thước video của trình phát. Nếu bạn bằng cách sử dụng trình phát media3, hãy kiểm tra để đảm bảo rằng trình phát không rỗng và kích thước video không bằng VideoSize.UNKNOWN trước khi đặt tỷ lệ .

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)

Nếu bạn đang sử dụng trình phát tuỳ chỉnh, hãy thiết lập tỷ lệ khung hình theo chiều cao của trình phát và chiều rộng bằng cú pháp dành riêng cho trình phát của bạn. Xin lưu ý rằng nếu trình phát đổi kích thước trong quá trình khởi chạy, nếu giá trị nằm ngoài giới hạn hợp lệ của nội dung thì ứng dụng của bạn sẽ gặp sự cố. Bạn có thể cần phải thêm các bước kiểm tra khi có thể tính toán tỷ lệ khung hình, tương tự như cách tỷ lệ khung hình được thực hiện cho media3 trình phát.

Thêm thao tác từ xa

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

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

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 và sau Android 12.