From ede57065822c899fd0e796f54cb36e58db92d6a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 19:32:45 +0000 Subject: [PATCH] feat: move CallScreen to its own activity for independent PiP Separate the call UI into a dedicated CallActivity so it can enter Picture-in-Picture mode independently of the main activity, allowing users to continue browsing the app during an active call. - Add CallActivity with PiP support via onUserLeaveHint - Add ActiveCallHolder singleton to share call state between activities - Launch CallActivity from call buttons and incoming call observer - Remove in-app nav route for ActiveCall (now a separate activity) - Remove EnterPipOnLeave composable (activity handles PiP directly) https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ --- amethyst/src/main/AndroidManifest.xml | 11 ++ .../amethyst/ui/call/ActiveCallHolder.kt | 55 +++++++++ .../amethyst/ui/call/CallActivity.kt | 104 ++++++++++++++++++ .../amethyst/ui/call/CallScreen.kt | 34 ------ .../amethyst/ui/navigation/AppNavigation.kt | 24 ++-- .../chats/privateDM/ChatroomScreen.kt | 11 +- 6 files changed, 183 insertions(+), 56 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ActiveCallHolder.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index f5e39a3883..7bf548fead 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -205,6 +205,17 @@ tools:replace="screenOrientation" tools:ignore="DiscouragedApi" /> + + = Build.VERSION_CODES.O) { + try { + val params = + PictureInPictureParams + .Builder() + .setAspectRatio(Rational(9, 16)) + .build() + enterPictureInPictureMode(params) + } catch (_: Exception) { + // PiP not supported or activity not in correct state + } + } + } + + companion object { + fun launch(context: Context) { + context.startActivity( + Intent(context, CallActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 01398aa04d..f7d4c9338b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -106,7 +106,6 @@ fun CallScreen( } KeepScreenOn() - EnterPipOnLeave(callState) Box(modifier = Modifier.fillMaxSize()) { when (val state = callState) { @@ -569,36 +568,3 @@ private fun KeepScreenOn() { } } } - -@Composable -private fun EnterPipOnLeave(callState: CallState) { - val context = LocalContext.current - val activity = context as? android.app.Activity ?: return - val isActiveCall = - callState is CallState.Connected || - callState is CallState.Connecting || - callState is CallState.Offering - - if (isActiveCall && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = - object : androidx.lifecycle.DefaultLifecycleObserver { - override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { - try { - val params = - android.app.PictureInPictureParams - .Builder() - .setAspectRatio(android.util.Rational(9, 16)) - .build() - activity.enterPictureInPictureMode(params) - } catch (_: Exception) { - // PiP not supported or activity not in correct state - } - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index aafcb4a216..832f628087 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -46,11 +46,11 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress -import com.vitorpamplona.amethyst.ui.call.CallScreen +import com.vitorpamplona.amethyst.ui.call.ActiveCallHolder +import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd -import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -167,20 +167,19 @@ fun AppNavigation( DisplayCrashMessages(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) - ObserveIncomingCalls(accountViewModel, nav) + ObserveIncomingCalls(accountViewModel) } @Composable -private fun ObserveIncomingCalls( - accountViewModel: AccountViewModel, - nav: INav, -) { +private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) { + val context = LocalContext.current val callState by accountViewModel.callManager.state.collectAsState() LaunchedEffect(callState) { val state = callState if (state is CallState.IncomingCall) { - nav.nav(Route.ActiveCall(state.callId, state.callerPubKey)) + ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) + CallActivity.launch(context) } } } @@ -281,15 +280,6 @@ fun BuildNavigation( composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } - composableFromEndArgs { - CallScreen( - callManager = accountViewModel.callManager, - callController = accountViewModel.callController, - accountViewModel = accountViewModel, - onCallEnded = { nav.popBack() }, - ) - } - composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 221519c7f7..a833ae21ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -27,10 +27,11 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.ui.call.ActiveCallHolder +import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -51,16 +52,16 @@ fun ChatroomScreen( val startVoiceCall = rememberCallWithPermission(context) { val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission + ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) - val callId = accountViewModel.callManager.currentCallId() ?: "" - nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey)) + CallActivity.launch(context) } val startVideoCall = rememberCallWithPermission(context, isVideo = true) { val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission + ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO) - val callId = accountViewModel.callManager.currentCallId() ?: "" - nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey)) + CallActivity.launch(context) } DisappearingScaffold(