diff --git a/compose/snippets/build.gradle b/compose/snippets/build.gradle index f96c5aa5..5447db95 100644 --- a/compose/snippets/build.gradle +++ b/compose/snippets/build.gradle @@ -55,12 +55,13 @@ dependencies { implementation "androidx.compose.ui:ui-tooling-preview" implementation "androidx.compose.ui:ui-viewbinding" implementation "androidx.fragment:fragment:1.5.4" - implementation 'androidx.activity:activity-compose:1.6.1' + implementation 'androidx.activity:activity-compose:1.9.0-alpha01' implementation 'androidx.compose.material3:material3:1.0.1' implementation 'androidx.compose.runtime:runtime-livedata' - implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1' + implementation 'androidx.media3:media3-exoplayer:1.2.1' implementation 'androidx.navigation:navigation-compose:2.5.3' testImplementation 'junit:junit:4.13.2' androidTestImplementation(composeBom) diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/pictureInPicture/PictureInPictureSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/pictureInPicture/PictureInPictureSnippets.kt new file mode 100644 index 00000000..5b043794 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/pictureInPicture/PictureInPictureSnippets.kt @@ -0,0 +1,368 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.example.compose.snippets.pictureInPicture + +import android.app.PendingIntent +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon +import android.os.Build +import android.util.Rational +import androidx.activity.ComponentActivity +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toAndroidRectF +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.content.ContextCompat +import androidx.core.graphics.toRect +import androidx.core.util.Consumer +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer + +var shouldEnterPipMode by mutableStateOf(false) + +// [START region_tag_13] +// Constants for broadcast receiver +const val ACTION_BROADCAST_CONTROL = "broadcast_control" + +// Intent extra 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 +const val REQUEST_PLAY = 5 +const val REQUEST_PAUSE = 6 +// [END region_tag_13] + +@Composable +fun PipListenerPreAPI12(shouldEnterPipMode: Boolean) { + // [START region_tag_1] + // [START region_tag_10] + 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 = { + if (currentShouldEnterPipMode) { + context.findActivity() + .enterPictureInPictureMode(PictureInPictureParams.Builder().build()) + } + } + // [END region_tag_10] + context.findActivity().addOnUserLeaveHintListener( + onUserLeaveBehavior + ) + onDispose { + context.findActivity().removeOnUserLeaveHintListener( + onUserLeaveBehavior + ) + } + } + } + // [END region_tag_1] +} + +@Composable +fun VideoPlayer( + modifier: Modifier = Modifier +) { + // [END region_tag_2] + val context = LocalContext.current + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + 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(true) + } + context.findActivity().setPictureInPictureParams(builder.build()) + } + } + // [END region_tag_2] +} + +// [START region_tag_3] +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") +} +// [END region_tag_3] + +@Composable +fun VideoPlayerScreen( +) { + // [START region_tag_3] + val context = LocalContext.current + Button(onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.findActivity().enterPictureInPictureMode( + // the parameters have been set by previous calls + PictureInPictureParams.Builder().build() + ) + } + }) { + Text(text = "Enter PiP mode!") + } + // [END region_tag_4] +} + +// [START region_tag_5] +@Composable +fun isInPipMode(): 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 { info -> + pipMode = info.isInPictureInPictureMode + } + activity.addOnPictureInPictureModeChangedListener( + observer + ) + onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } + } + + + return pipMode + } else { + return false + } +} +// [END region_tag_5] + +@Composable +fun VideoPlayerScreen( + modifier: Modifier = Modifier, +) { + // [START region_tag_6] + val inPipMode = isInPipMode() + + Column(modifier = modifier) { + // This text will only show up when the app is in PiP mode + if (!inPipMode) { + Text( + text = "Picture in Picture", + ) + } + VideoPlayer() + } + // [END region_tag_6] +} + +fun initializePlayer(context: Context) { + val player = ExoPlayer.Builder(context.applicationContext) + .build().apply {} + + // [START region_tag_7] + player.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + shouldEnterPipMode = isPlaying + } + }) + // [END region_tag_7] +} + +// [START region_tag_8] +fun releasePlayer() { + shouldEnterPipMode = false +} +// [END region_tag_8] + +@Composable +fun VideoPlayer( + shouldEnterPipMode: Boolean, + modifier: Modifier = Modifier, +) { + // [START region_tag_9] + val context = LocalContext.current + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + 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()) + } + } + // [END region_tag_9] +} + + +@Composable +fun VideoPlayer1( + shouldEnterPipMode: Boolean, + modifier: Modifier = Modifier, +) { + // [START region_tag_11] + val context = LocalContext.current + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + 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()) + } + } + // [END region_tag_11] +} + +@Composable +fun VideoPlayer2( + shouldEnterPipMode: Boolean, + modifier: Modifier = Modifier, +) { + // [START region_tag_12] + val context = LocalContext.current + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + 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()) + } + } + // [END region_tag_12] +} + +// [START region_tag_14] +@RequiresApi(Build.VERSION_CODES.O) +private fun buildRemoteAction( + @DrawableRes iconResId: Int, + @StringRes titleResId: Int, + requestCode: Int, + controlType: Int, + context: Context +): RemoteAction { + return RemoteAction( + Icon.createWithResource(context, iconResId), + context.getString(titleResId), + context.getString(titleResId), + PendingIntent.getBroadcast( + context, + requestCode, + Intent(ACTION_BROADCAST_CONTROL) + .putExtra(EXTRA_CONTROL_TYPE, controlType), + PendingIntent.FLAG_IMMUTABLE + ) + ) +} +// [END region_tag_14] + +// [START region_tag_15] +@RequiresApi(Build.VERSION_CODES.O) +@Composable +fun BroadcastReceiver(player: Player?) { + if (isInPipMode() && player != null) { + val context = LocalContext.current + + DisposableEffect(key1 = 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) + } + } + } +} +// [END region_tag_15] + +@RequiresApi(Build.VERSION_CODES.O) +fun listOfRemoteActions(isPlaying: Boolean, context: Context): List { + return listOf() +} + +// [START region_tag_16] +@Composable +fun VideoPlayer4( + shouldEnterPipMode: Boolean, + modifier: Modifier = Modifier, + +) { + val context = LocalContext.current + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val pipModifier = modifier.onGloballyPositioned { layoutCoordinates -> + val builder = PictureInPictureParams.Builder() + builder.setActions( + listOfRemoteActions(shouldEnterPipMode, context) + ) + context.findActivity().setPictureInPictureParams(builder.build()) + } + } +} +// [END region_tag_16] \ No newline at end of file