diff --git a/Android/APIExample-Compose/app/src/main/AndroidManifest.xml b/Android/APIExample-Compose/app/src/main/AndroidManifest.xml index 1a465bdef..40e6edf28 100644 --- a/Android/APIExample-Compose/app/src/main/AndroidManifest.xml +++ b/Android/APIExample-Compose/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.APIExampleCompose" + android:supportsPictureInPicture="true" android:configChanges="screenSize|screenLayout|orientation|smallestScreenSize"> @@ -36,12 +37,14 @@ + - + \ No newline at end of file diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/NavGraph.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/NavGraph.kt index efb9315c9..bb60306a8 100644 --- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/NavGraph.kt +++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/NavGraph.kt @@ -1,5 +1,6 @@ package io.agora.api.example.compose +import android.util.Log import androidx.compose.runtime.Composable import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -9,6 +10,7 @@ import androidx.navigation.navArgument import io.agora.api.example.compose.model.Component import io.agora.api.example.compose.model.Components import io.agora.api.example.compose.model.Example +import io.agora.api.example.compose.samples.cleanupPictureInPictureState import io.agora.api.example.compose.ui.example.Example import io.agora.api.example.compose.ui.home.Home import io.agora.api.example.compose.ui.settings.Settings @@ -48,7 +50,15 @@ fun NavGraph() { val example = component.examples[exampleIndex] Example( example = example, - onBackClick = { navController.popBackStack() }, + onBackClick = { + Log.d("PiPDebug", "NavGraph: onBackClick called for example: ${example.name}") + // Special handling for PictureInPicture example + if (example.name == R.string.example_pictureinpicture) { + Log.d("PiPDebug", "NavGraph: Cleaning up PictureInPicture state") + cleanupPictureInPictureState() + } + navController.popBackStack() + }, ) } } diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/model/Examples.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/model/Examples.kt index 47abfdc76..50b4f63f5 100644 --- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/model/Examples.kt +++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/model/Examples.kt @@ -20,7 +20,7 @@ import io.agora.api.example.compose.samples.MediaPlayer import io.agora.api.example.compose.samples.MediaRecorder import io.agora.api.example.compose.samples.OriginAudioData import io.agora.api.example.compose.samples.OriginVideoData -import io.agora.api.example.compose.samples.PictureInPictureEntrance +import io.agora.api.example.compose.samples.PictureInPicture import io.agora.api.example.compose.samples.PlayAudioFiles import io.agora.api.example.compose.samples.PreCallTest import io.agora.api.example.compose.samples.RTMPStreaming @@ -54,7 +54,7 @@ val AdvanceExampleList = listOf( Example(R.string.example_originvideodata) { OriginVideoData() }, Example(R.string.example_customvideosource) { CustomVideoSource() }, Example(R.string.example_customvideorender) { CustomVideoRender() }, - Example(R.string.example_pictureinpicture) { PictureInPictureEntrance(it) }, + Example(R.string.example_pictureinpicture) { PictureInPicture() }, Example(R.string.example_joinmultichannel) { JoinMultiChannel() }, Example(R.string.example_channelencryption) { ChannelEncryption() }, Example(R.string.example_playaudiofiles) { PlayAudioFiles() }, diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PictureInPicture.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PictureInPicture.kt index 7af8f1aa3..f675c0528 100644 --- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PictureInPicture.kt +++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PictureInPicture.kt @@ -2,34 +2,34 @@ package io.agora.api.example.compose.samples import android.app.AppOpsManager import android.app.PictureInPictureParams +import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.graphics.RectF import android.os.Build -import android.os.Bundle import android.os.Process +import android.util.Log import android.util.Rational import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toAndroidRectF @@ -38,20 +38,18 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer import io.agora.api.example.compose.BuildConfig import io.agora.api.example.compose.R import io.agora.api.example.compose.data.SettingPreferences -import io.agora.api.example.compose.ui.common.APIExampleScaffold import io.agora.api.example.compose.ui.common.ChannelNameInput import io.agora.api.example.compose.ui.common.TwoVideoView import io.agora.api.example.compose.ui.common.TwoVideoViewType import io.agora.api.example.compose.ui.common.VideoStatsInfo -import io.agora.api.example.compose.ui.theme.APIExampleComposeTheme import io.agora.api.example.compose.utils.TokenUtils import io.agora.rtc2.ChannelMediaOptions import io.agora.rtc2.Constants @@ -61,25 +59,102 @@ import io.agora.rtc2.RtcEngineConfig import io.agora.rtc2.video.VideoCanvas import io.agora.rtc2.video.VideoEncoderConfiguration +// Global state storage that persists across component recreation +private val globalLocalUid = mutableIntStateOf(0) +private val globalRemoteUid = mutableIntStateOf(0) +private val globalChannelName = mutableStateOf("") +private val globalIsJoined = mutableStateOf(false) +private val isInPipTransition = mutableStateOf(false) +private val isPageLeaving = mutableStateOf(false) // Flag to track if user is truly leaving the page +private var globalCleanupFunction: (() -> Unit)? = null // Global cleanup function +// Helper function to find Activity from Context +private 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") +} + +// Correct PiP state management following Android official guidelines @Composable -fun PictureInPictureEntrance(back: () -> Unit) { - val context = LocalContext.current - val intent = Intent(context, PictureInPictureActivity::class.java) - context.startActivity(intent) - back() +private 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 { info -> + pipMode = info.isInPictureInPictureMode + } + activity.addOnPictureInPictureModeChangedListener(observer) + onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } + } + return pipMode + } else { + return false + } +} + +// Public function to clean up global state when user leaves the page +fun cleanupPictureInPictureState() { + Log.d("PiPDebug", "cleanupPictureInPictureState called") + globalCleanupFunction?.invoke() } @Composable -private fun PictureInPicture() { +fun PictureInPicture() { val context = LocalContext.current as ComponentActivity - var isPipOn by rememberSaveable { mutableStateOf(false) } + // Use the correct PiP state management + val isPipOn = rememberIsInPipMode() + + Log.d("PiPDebug", "PictureInPicture: Current isPipOn = $isPipOn") + + // Function to mark that user is leaving the page + fun markPageLeaving() { + isPageLeaving.value = true + Log.d("PiPDebug", "Marked page as leaving - global state will be cleared on next dispose") + } + + // Register cleanup function globally + LaunchedEffect(Unit) { + globalCleanupFunction = { markPageLeaving() } + } + + // Add LaunchedEffect to handle PiP mode changes + LaunchedEffect(isPipOn) { + Log.d("PiPDebug", "PiP mode changed to: $isPipOn") + // Mark that we're in a PiP transition + isInPipTransition.value = true + // Note: We can't access localUid and rtcEngine here as they're defined later + // The video setup will be handled in the render callbacks + } + + // Add DisposableEffect to track lifecycle + DisposableEffect(Unit) { + onDispose { + // Only clear global state when user is truly leaving the page (not during PiP transitions) + if (isPageLeaving.value) { + Log.d("PiPDebug", "DisposableEffect: User is leaving page, clearing global state") + globalLocalUid.intValue = 0 + globalRemoteUid.intValue = 0 + globalChannelName.value = "" + globalIsJoined.value = false + isPageLeaving.value = false // Reset flag + } else { + Log.d("PiPDebug", "DisposableEffect: Component recreation (PiP transition), preserving global state") + } + } + } val lifecycleOwner = LocalLifecycleOwner.current val keyboard = LocalSoftwareKeyboardController.current - var isJoined by rememberSaveable { mutableStateOf(false) } - var channelName by rememberSaveable { mutableStateOf("") } - var localUid by rememberSaveable { mutableIntStateOf(0) } - var remoteUid by rememberSaveable { mutableIntStateOf(0) } + // Use global state directly to avoid duplication + var isJoined by globalIsJoined + var channelName by globalChannelName + var localUid by globalLocalUid + var remoteUid by globalRemoteUid + var localStats by remember { mutableStateOf(VideoStatsInfo()) } var remoteStats by remember { mutableStateOf(VideoStatsInfo()) } val videoViewBound = remember { RectF() } @@ -176,13 +251,7 @@ private fun PictureInPicture() { } } LaunchedEffect(lifecycleOwner) { - context.addOnPictureInPictureModeChangedListener { info -> - isPipOn = info.isInPictureInPictureMode - if (lifecycleOwner.lifecycle.currentState < Lifecycle.State.STARTED) { - context.finish() - } - } - lifecycleOwner.lifecycle.addObserver(object: DefaultLifecycleObserver { + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { rtcEngine.stopPreview() rtcEngine.leaveChannel() @@ -216,19 +285,31 @@ private fun PictureInPicture() { TwoVideoView( modifier = Modifier .height(350.dp) - .onGloballyPositioned { + .onGloballyPositioned { layoutCoordinates -> videoViewBound.set( - it + layoutCoordinates .boundsInWindow() .toAndroidRectF() ) + val boundsInWindow = layoutCoordinates.boundsInWindow() + Log.d("PiPDebug", "VideoView distance from top: ${boundsInWindow.top}px") }, type = TwoVideoViewType.Row, localUid = localUid, remoteUid = remoteUid, localStats = localStats, remoteStats = remoteStats, - localRender = { view, id, _ -> + localRender = { view, id, isFirstSetup -> + Log.d("PiPDebug", "localRender: view=$view, id=$id, isFirstSetup=$isFirstSetup, isJoined=$isJoined, isPipOn=$isPipOn") + // Clear previous view first + rtcEngine.setupLocalVideo( + VideoCanvas( + null, + Constants.RENDER_MODE_HIDDEN, + id + ) + ) + // Then set up new view rtcEngine.setupLocalVideo( VideoCanvas( view, @@ -237,8 +318,19 @@ private fun PictureInPicture() { ) ) rtcEngine.startPreview() + Log.d("PiPDebug", "localRender: started preview") }, - remoteRender = { view, id, _ -> + remoteRender = { view, id, isFirstSetup -> + Log.d("PiPDebug", "remoteRender: view=$view, id=$id, isFirstSetup=$isFirstSetup, remoteUid=$remoteUid, isPipOn=$isPipOn") + // Clear previous view first + rtcEngine.setupRemoteVideo( + VideoCanvas( + null, + Constants.RENDER_MODE_HIDDEN, + id + ) + ) + // Then set up new view rtcEngine.setupRemoteVideo( VideoCanvas( view, @@ -246,95 +338,90 @@ private fun PictureInPicture() { id ) ) + Log.d("PiPDebug", "remoteRender: setup completed") }) } if (isPipOn) { - videoView() + Log.d("PiPDebug", "PictureInPicture: Rendering PiP mode - localUid: $localUid, remoteUid: $remoteUid, " + + "isJoined: $isJoined") + // In PiP mode, render only the video content without any scaffold or app bar + // Use fillMaxSize to ensure video takes full available space in PiP window + Box(modifier = Modifier.fillMaxSize()) { + videoView() + } } else { - APIExampleComposeTheme { - APIExampleScaffold( - topBarTitle = stringResource(id = R.string.example_pictureinpicture), - showSettingIcon = false, - showBackNavigationIcon = true, - onBackClick = { context.finish() }, - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .consumeWindowInsets(WindowInsets.safeDrawing) - .padding(paddingValues) - ) { - videoView() - Spacer(modifier = Modifier.weight(1f)) + Log.d("PiPDebug", "PictureInPicture: Rendering normal mode - full UI") + // Normal mode with full UI - let Example component handle the scaffold + Column(modifier = Modifier.fillMaxWidth()) { + videoView() + Spacer(modifier = Modifier.weight(1f)) - Button( - modifier = Modifier.padding(16.dp, 8.dp), - enabled = isJoined, - onClick = { - if (Build.VERSION.SDK_INT >= 26) { - val appOpsManager: AppOpsManager = - context.getSystemService(AppOpsManager::class.java) - if (appOpsManager.checkOpNoThrow( - AppOpsManager.OPSTR_PICTURE_IN_PICTURE, - Process.myUid(), - context.packageName - ) == AppOpsManager.MODE_ALLOWED - ) { - context.enterPictureInPictureMode( - PictureInPictureParams.Builder() - .setAspectRatio( - Rational( - videoViewBound.width().toInt(), - videoViewBound.height().toInt() - ) - ) - .build() + Button( + modifier = Modifier.padding(16.dp, 8.dp), + enabled = isJoined, + onClick = { + if (Build.VERSION.SDK_INT >= 26) { + val appOpsManager: AppOpsManager = + context.getSystemService(AppOpsManager::class.java) + if (appOpsManager.checkOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + Process.myUid(), + context.packageName + ) == AppOpsManager.MODE_ALLOWED + ) { + context.enterPictureInPictureMode( + PictureInPictureParams.Builder() + .setAspectRatio( + Rational( + videoViewBound.width().toInt(), + videoViewBound.height().toInt() + ) ) - val homeIntent = Intent(Intent.ACTION_MAIN) - homeIntent.addCategory(Intent.CATEGORY_HOME) - context.startActivity(homeIntent) - isPipOn = true - } - } - } - ) { - Text(text = "Enter Picture-in-Picture Mode") - } - - ChannelNameInput( - channelName = channelName, - isJoined = isJoined, - onJoinClick = { - channelName = it - keyboard?.hide() - permissionLauncher.launch( - arrayOf( - android.Manifest.permission.RECORD_AUDIO, - android.Manifest.permission.CAMERA - ) + .setActions(emptyList()) // Hide system actions (back button, etc.) + .build() ) - }, - onLeaveClick = { - rtcEngine.stopPreview() - rtcEngine.leaveChannel() + val homeIntent = Intent(Intent.ACTION_MAIN) + homeIntent.addCategory(Intent.CATEGORY_HOME) + context.startActivity(homeIntent) + // isPipOn is now managed by rememberIsInPipMode(), no need to manually set + } else { + Toast.makeText( + context, + "Picture-in-Picture permission is not granted", + Toast.LENGTH_SHORT + ).show() } - ) + } else { + Toast.makeText( + context, + "Picture-in-Picture requires Android 8.0 (API 26) or higher", + Toast.LENGTH_SHORT + ).show() + } } + ) { + Text(text = "Enter Picture-in-Picture Mode") } - } - } - -} - -class PictureInPictureActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - PictureInPicture() + ChannelNameInput( + channelName = channelName, + isJoined = isJoined, + onJoinClick = { + channelName = it + keyboard?.hide() + permissionLauncher.launch( + arrayOf( + android.Manifest.permission.RECORD_AUDIO, + android.Manifest.permission.CAMERA + ) + ) + }, + onLeaveClick = { + rtcEngine.stopPreview() + rtcEngine.leaveChannel() + } + ) } } - } \ No newline at end of file diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/ui/example/Example.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/ui/example/Example.kt index 8e8e1e5e5..97b0760bf 100644 --- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/ui/example/Example.kt +++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/ui/example/Example.kt @@ -1,5 +1,9 @@ package io.agora.api.example.compose.ui.example +import android.content.Context +import android.content.ContextWrapper +import android.os.Build +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets @@ -7,30 +11,74 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer import io.agora.api.example.compose.model.Example import io.agora.api.example.compose.ui.common.APIExampleScaffold +private 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") +} + +@Composable +private 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 { info -> + pipMode = info.isInPictureInPictureMode + } + activity.addOnPictureInPictureModeChangedListener(observer) + onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } + } + return pipMode + } else { + return false + } +} + + @Composable fun Example( example: Example, onBackClick: () -> Unit, ) { - APIExampleScaffold( - topBarTitle = stringResource(id = example.name), - showBackNavigationIcon = true, - onBackClick = onBackClick, - ) { paddingValues -> + val isInPictureInPictureMode = rememberIsInPipMode() + if (isInPictureInPictureMode) { Box( - modifier = Modifier - .fillMaxSize() - .consumeWindowInsets(WindowInsets.safeDrawing) - .padding(paddingValues), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { example.content(onBackClick) } + } else { + APIExampleScaffold( + topBarTitle = stringResource(id = example.name), + showBackNavigationIcon = true, + onBackClick = onBackClick, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(WindowInsets.safeDrawing) + .padding(paddingValues) + ) { + example.content(onBackClick) + } + } } } \ No newline at end of file