Compose 동영상 플레이어로 앱에 PIP 모드 추가

PIP는 주로 동영상 재생에 사용되는 특수한 유형의 멀티 윈도우 모드입니다. 사용자가 기본 화면에서 앱 간에 이동하거나 콘텐츠를 탐색할 때 화면 모서리에 고정된 작은 창에서 동영상을 볼 수 있습니다.

PIP는 Android 7.0에서 사용할 수 있는 멀티 윈도우 API를 활용하여 고정 동영상 오버레이 창을 제공합니다. PIP를 앱에 추가하려면 활동을 등록하고 필요에 따라 활동을 PIP 모드로 전환하며 활동이 PIP 모드일 때 UI 요소가 숨겨지고 동영상 재생이 계속되는지 확인해야 합니다.

이 가이드에서는 Compose 동영상 구현을 통해 Compose의 PIP를 앱에 추가하는 방법을 설명합니다. 이러한 권장사항을 실제로 확인하려면 Socialite 앱을 참고하세요.

PIP 모드용 앱 설정

AndroidManifest.xml 파일의 활동 태그에서 다음을 실행합니다.

  1. supportsPictureInPicture를 추가하고 true로 설정하여 앱에서 PIP를 사용한다고 선언합니다.
  2. configChanges를 추가하고 orientation|screenLayout|screenSize|smallestScreenSize로 설정하여 활동이 레이아웃 구성 변경을 처리하도록 지정합니다. 이렇게 하면 PIP 모드 전환 중에 레이아웃이 변경될 때 활동이 다시 시작되지 않습니다.

      <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 이전용 앱 종료 시 PIP 추가

Android 12 이전 버전의 PIP를 추가하려면 addOnUserLeaveHintProvider를 사용합니다. Android 12 이전용 PIP를 추가하려면 다음 단계를 따르세요.

  1. R까지 버전 O에서만 이 코드에 액세스할 수 있도록 버전 게이트를 추가합니다.
  2. Context와 함께 DisposableEffect를 키로 사용합니다.
  3. DisposableEffect 내에서 람다를 사용하여 onUserLeaveHintProvider이 트리거되는 경우에 관한 동작을 정의합니다. 람다에서 findActivity()enterPictureInPictureMode()를 호출하고 PictureInPictureParams.Builder().build()를 전달합니다.
  4. findActivity()를 사용하여 addOnUserLeaveHintListener를 추가하고 람다를 전달합니다.
  5. onDispose에서 findActivity()를 사용하여 removeOnUserLeaveHintListener를 추가하고 람다를 전달합니다.

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 이후를 위해 앱 종료 시 PIP 추가

Android 12 이후 PictureInPictureParams.Builder는 앱의 동영상 플레이어에 전달되는 수정자를 통해 추가됩니다.

  1. modifier를 만들고 onGloballyPositioned를 호출합니다. 레이아웃 좌표는 이후 단계에서 사용됩니다.
  2. PictureInPictureParams.Builder()의 변수를 만듭니다.
  3. if 문을 추가하여 SDK가 S 이상인지 확인합니다. 이 경우 setAutoEnterEnabled를 빌더에 추가하고 true로 설정하여 스와이프할 때 PIP 모드로 전환합니다. 이렇게 하면 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)

버튼을 통해 PIP 추가

버튼 클릭을 통해 PIP 모드로 전환하려면 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!")
}

PIP 모드에서 UI 처리

PIP 모드로 전환되면 UI가 PIP 모드에 들어가고 나오는 방식을 지정하지 않는 한 앱의 전체 UI가 PIP 모드로 전환됩니다.

먼저 앱이 PIP 모드에 있는지 알아야 합니다. OnPictureInPictureModeChangedProvider를 사용하면 됩니다. 아래 코드는 앱이 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
    }
}

이제 rememberIsInPipMode()를 사용하여 앱이 PIP 모드로 전환될 때 표시할 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()
}

앱이 적절한 시점에 PIP 모드로 전환되는지 확인

다음과 같은 상황에서는 앱이 PIP 모드로 전환되면 안 됩니다.

  • 동영상 정지 또는 일시중지 여부
  • 동영상 플레이어와 앱의 다른 페이지에 있는 경우

앱이 PIP 모드로 전환되는 시점을 제어하려면 mutableStateOf를 사용하여 동영상 플레이어의 상태를 추적하는 변수를 추가합니다.

동영상 재생 여부에 따라 상태 전환

동영상 플레이어의 재생 여부에 따라 상태를 전환하려면 동영상 플레이어에 리스너를 추가합니다. 플레이어의 재생 여부에 따라 상태 변수의 상태를 전환합니다.

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

플레이어가 해제되었는지에 따라 상태 전환

플레이어가 해제되면 상태 변수를 false로 설정합니다.

fun releasePlayer() {
    shouldEnterPipMode = false
}

상태를 사용하여 PIP 모드 전환 여부 정의 (Android 12 이전)

  1. PIP 이전 12를 추가하면 DisposableEffect가 사용되므로 newValue를 상태 변수로 설정하여 rememberUpdatedState로 새 변수를 만들어야 합니다. 이렇게 하면 업데이트된 버전이 DisposableEffect 내에서 사용됩니다.
  2. OnUserLeaveHintListener가 트리거될 때 동작을 정의하는 람다에서 enterPictureInPictureMode() 호출 주위에 상태 변수가 있는 if 문을 추가합니다.

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

상태를 사용하여 PIP 모드 전환 여부 정의 (Android 12 이후)

앱이 적절한 시점에만 PIP 모드로 전환되도록 상태 변수를 setAutoEnterEnabled에 전달합니다.

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는 PIP 모드 전환을 위한 더 매끄러운 애니메이션을 만듭니다. Android 12 이상에서는 PIP 모드를 종료하기 위한 더 매끄러운 애니메이션도 생성합니다. 이 API를 PIP 빌더에 추가하여 PIP로 전환된 후 표시되는 활동 영역을 나타냅니다.

  1. 상태에 따라 앱이 PIP 모드로 전환되어야 한다고 정의하는 경우에만 setSourceRectHint()builder에 추가하세요. 이렇게 하면 앱에서 PIP를 입력할 필요가 없을 때 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를 사용하여 PIP 창의 가로세로 비율 설정

PIP 창의 가로세로 비율을 설정하려면 특정 가로세로 비율을 선택하거나 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)

원격 작업 추가

PIP 창에 컨트롤 (재생, 일시중지 등)을 추가하려면 추가하려는 각 컨트롤의 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. PIP 창의 컨트롤에 사용할 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에 PIP를 추가하는 권장사항을 알아봤습니다.

  • Compose PIP의 실제 권장사항은 소셜 앱을 참고하세요.
  • 자세한 내용은 PiP 설계 안내를 참고하세요.