mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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
This commit is contained in:
@@ -205,6 +205,17 @@
|
||||
tools:replace="screenOrientation"
|
||||
tools:ignore="DiscouragedApi" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.call.CallActivity"
|
||||
android:autoRemoveFromRecents="true"
|
||||
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden|keyboard|uiMode"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:launchMode="singleTop"
|
||||
android:exported="false"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/Theme.Amethyst"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name=".service.playback.pip.PipVideoActivity"
|
||||
android:autoRemoveFromRecents="true"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.call
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.call.CallManager
|
||||
import com.vitorpamplona.amethyst.service.call.CallController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* Holds references to the active call state so that [CallActivity] can access
|
||||
* the same [CallManager] and [CallController] owned by [AccountViewModel] in
|
||||
* the main activity. Both activities run in the same process.
|
||||
*/
|
||||
object ActiveCallHolder {
|
||||
var callManager: CallManager? = null
|
||||
private set
|
||||
var callController: CallController? = null
|
||||
private set
|
||||
var accountViewModel: AccountViewModel? = null
|
||||
private set
|
||||
|
||||
fun set(
|
||||
callManager: CallManager,
|
||||
callController: CallController?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
this.callManager = callManager
|
||||
this.callController = callController
|
||||
this.accountViewModel = accountViewModel
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
callManager = null
|
||||
callController = null
|
||||
accountViewModel = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.call
|
||||
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Rational
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.vitorpamplona.amethyst.commons.call.CallState
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
class CallActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val callManager = ActiveCallHolder.callManager
|
||||
val callController = ActiveCallHolder.callController
|
||||
val accountViewModel = ActiveCallHolder.accountViewModel
|
||||
|
||||
if (callManager == null || accountViewModel == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
CallScreen(
|
||||
callManager = callManager,
|
||||
callController = callController,
|
||||
accountViewModel = accountViewModel,
|
||||
onCallEnded = { finish() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
enterPipIfActive()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
if (!isInPictureInPictureMode) {
|
||||
// User expanded from PiP — bring the full call UI back
|
||||
}
|
||||
}
|
||||
|
||||
private fun enterPipIfActive() {
|
||||
val callManager = ActiveCallHolder.callManager ?: return
|
||||
val state = callManager.state.value
|
||||
val isActive =
|
||||
state is CallState.Connected ||
|
||||
state is CallState.Connecting ||
|
||||
state is CallState.Offering
|
||||
|
||||
if (isActive && Build.VERSION.SDK_INT >= 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.ActiveCall> {
|
||||
CallScreen(
|
||||
callManager = accountViewModel.callManager,
|
||||
callController = accountViewModel.callController,
|
||||
accountViewModel = accountViewModel,
|
||||
onCallEnded = { nav.popBack() },
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.PublicChatChannel> {
|
||||
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+6
-5
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user