使用 Compose 影片播放器將子母畫面 (PiP) 新增至應用程式

子母畫面 (PiP) 是一種特殊的多視窗模式,主要用於影片播放。可讓使用者透過固定在螢幕角落的小視窗觀看影片,同時在應用程式內瀏覽或瀏覽主畫面內容。

子母畫面會利用 Android 7.0 提供的多視窗 API 提供固定的影片重疊視窗。如要在應用程式中加入子母畫面功能,您必須註冊活動、視需要將活動切換至子母畫面模式,並確認在活動處於子母畫面模式時,UI 元素為隱藏狀態,且影片能繼續播放。

本指南說明如何使用 Compose 影片實作項目,在 Compose 中新增子母畫面。如要瞭解這些最佳做法的實際運作情形,請參閱 Socialite 應用程式。

設定應用程式以使用子母畫面模式

AndroidManifest.xml 檔案的活動標記中,執行以下操作:

  1. 新增 supportsPictureInPicture 並將其設為 true,宣告您將在應用程式中使用子母畫面。
  2. 新增 configChanges 並將其設為 orientation|screenLayout|screenSize|smallestScreenSize,以指定活動處理版面配置設定變更。這樣一來,如果在子母畫面模式轉換期間發生版面配置變更,活動就不會重新啟動。

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

在 Compose 程式碼中執行下列操作:

  1. 前往 Context 新增這個擴充功能。在本指南中,您將多次使用這個擴充功能存取活動。
    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")
    }

針對 Android 12 以下版本,在離開應用程式中新增子母畫面

如要新增 Android 12 以下版本的子母畫面,請使用 addOnUserLeaveHintProvider。請按照下列步驟新增適用於 Android 12 以下版本的子母畫面:

  1. 新增版本閘道,讓這個程式碼只能在 O 到 R 版本中存取。
  2. 請使用 DisposableEffect 搭配 Context 做為索引鍵。
  3. DisposableEffect 中,定義使用 lambda 觸發 onUserLeaveHintProvider 時的行為。在 lambda 中,對 findActivity() 呼叫 enterPictureInPictureMode(),並傳入 PictureInPictureParams.Builder().build()
  4. 使用 findActivity() 新增 addOnUserLeaveHintListener,並傳入 lambda。
  5. onDispose 中使用 findActivity() 新增 removeOnUserLeaveHintListener,並傳入 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")
}

在 Android 12 以後的離開應用程式中新增子母畫面模式

在 Android 12 後,請透過傳遞至應用程式影片播放器的修飾符來新增 PictureInPictureParams.Builder

  1. 建立 modifier 並對其呼叫 onGloballyPositioned。您將在後續步驟中使用版面配置座標。
  2. PictureInPictureParams.Builder() 建立變數。
  3. 新增 if 陳述式,檢查 SDK 是否為 S 以上。如果是,請將 setAutoEnterEnabled 新增至建構工具並將其設為 true,以便在滑動時進入子母畫面模式。相較於處理 enterPictureInPictureMode,動畫效果更流暢。
  4. 使用 findActivity() 呼叫 setPictureInPictureParams()。對 builder 呼叫 build() 並傳入。

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)

透過按鈕新增子母畫面

如要透過點選按鈕進入子母畫面模式,請在 findActivity() 上呼叫 enterPictureInPictureMode()

這些參數已由先前的 PictureInPictureParams.Builder 呼叫設定,因此您不必在建構工具上設定新參數。不過,如果您想變更按鈕點選的任何參數,也可在這裡進行設定。

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

在子母畫面模式下處理 UI

進入子母畫面模式後,應用程式的整個 UI 就會進入子母畫面視窗,除非您指定 UI 在子母畫面模式下的外觀。

首先,您必須瞭解應用程式何時處於子母畫面模式。您可以使用 OnPictureInPictureModeChangedProvider 達成此目的。您可以透過以下程式碼瞭解應用程式是否處於子母畫面模式。

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

現在,您可以使用 rememberIsInPipMode() 切換應用程式進入子母畫面模式時要顯示的 UI 元素:

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

確認應用程式適時進入子母畫面模式

在下列情況中,您的應用程式不應進入子母畫面模式:

  • 影片停止或暫停時。
  • 如果所在的應用程式頁面與影片播放器不同。

如要控制應用程式進入子母畫面模式的時間,請新增變數,使用 mutableStateOf 追蹤影片播放器的狀態。

根據是否播放影片來切換狀態

如要根據影片播放器是否正在播放狀態切換狀態,請在影片播放器中新增事件監聽器。根據玩家是否正在播放,切換狀態變數的狀態:

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

根據玩家是否釋出來切換狀態

玩家釋放後,請將狀態變數設為 false

fun releasePlayer() {
    shouldEnterPipMode = false
}

使用狀態來定義使用者是否已進入子母畫面模式 (Android 12 以下版本)

  1. 由於新增子母畫面 12 前的版本會使用 DisposableEffect,因此您必須透過 rememberUpdatedState 建立新變數,並將 newValue 設為狀態變數。這可確保 DisposableEffect 使用更新版本。
  2. 在定義 OnUserLeaveHintListener 觸發時行為的 lambda 中,新增 if 陳述式,其中包含呼叫 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")
    }

使用狀態定義是否進入子母畫面模式 (Android 12 後)

將狀態變數傳遞至 setAutoEnterEnabled,讓應用程式只在適當時機進入 PiP 模式:

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)

使用 setSourceRectHint 實作流暢的動畫

setSourceRectHint API 可讓進入子母畫面模式時提供更流暢的動畫。在 Android 12 以上版本中,還可以為退出子母畫面模式建立更流暢的動畫。將這個 API 新增至子母畫面建構工具,表示在轉換至子母畫面後顯示的活動區域。

  1. 只有在狀態定義應用程式應進入子母畫面模式時,才能將 setSourceRectHint() 新增至 builder。這可避免在應用程式不需要進入子母畫面的情況下計算 sourceRect
  2. 如要設定 sourceRect 值,請使用該修飾符的 onGloballyPositioned 函式所提供的 layoutCoordinates
  3. builder 上呼叫 setSourceRectHint(),並傳入 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)

使用 setAspectRatio 設定子母畫面視窗的顯示比例

如要設定子母畫面視窗的顯示比例,您可以選擇特定顯示比例,或使用 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)
        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)

新增遠端動作

如果想為子母畫面視窗新增控制項 (播放、暫停等),請為每個要新增的控制項建立 RemoteAction

  1. 為廣播控制項新增常數:
    // 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. 為子母畫面視窗的控制項建立 RemoteActions 清單。
  3. 接著,新增 BroadcastReceiver 並覆寫 onReceive(),以設定每個按鈕的動作。使用 DisposableEffect 註冊接收器和遠端動作。當玩家棄置時,請取消註冊接收器。
    @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. 將遠端動作清單傳入 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)

後續步驟

在這個指南中,您學到了在 Android 12 以下版本和 Android 12 之前的 Compose 中新增子母畫面的最佳做法。

  • 如要瞭解 Compose PiP 的實際應用最佳做法,請參閱 Socialite 應用程式。
  • 詳情請參閱 PiP 設計指南