mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
fix: notification actions, PiP hangup, PiP compact UI, and PiP remote actions
1. Notification: silence channel sound (CallAudioManager handles ringtone), use CallNotificationReceiver for accept/reject actions instead of launching MainActivity 2. Cancel call notification when call is accepted (Connecting state) 3. PiP: hang up call when PiP is dismissed (activity destroyed) 4. PiP: add RemoteAction buttons (hangup, mute toggle) since Compose buttons are not interactive in PiP mode 5. PiP: show compact UI with smaller avatar (48dp) and smaller text when in picture-in-picture mode https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ
This commit is contained in:
@@ -269,6 +269,10 @@
|
||||
android:name=".service.notifications.NotificationReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".ui.call.CallNotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ class CallController(
|
||||
audioManager.stopRingbackTone()
|
||||
audioManager.switchToCallAudioMode()
|
||||
audioManager.acquireProximityWakeLock()
|
||||
NotificationUtils.cancelCallNotification(context)
|
||||
}
|
||||
|
||||
is CallState.Connected -> {
|
||||
|
||||
+30
-11
@@ -523,6 +523,9 @@ object NotificationUtils {
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
|
||||
// Silence the notification sound — CallAudioManager plays the ringtone
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
}
|
||||
|
||||
val notificationManager: NotificationManager =
|
||||
@@ -544,8 +547,11 @@ object NotificationUtils {
|
||||
|
||||
val channel = getOrCreateCallChannel(applicationContext)
|
||||
|
||||
// Tapping the notification opens the CallActivity
|
||||
val contentIntent =
|
||||
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
|
||||
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
|
||||
val contentPendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
@@ -555,17 +561,29 @@ object NotificationUtils {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val fullScreenIntent =
|
||||
Intent(applicationContext, MainActivity::class.java).apply {
|
||||
data = uri.toUri()
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
val acceptIntent =
|
||||
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver::class.java).apply {
|
||||
action = com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver.ACTION_ACCEPT_CALL
|
||||
}
|
||||
|
||||
val fullScreenPendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
val acceptPendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
CALL_NOTIFICATION_ID + 1,
|
||||
fullScreenIntent,
|
||||
acceptIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
val rejectIntent =
|
||||
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver::class.java).apply {
|
||||
action = com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver.ACTION_REJECT_CALL
|
||||
}
|
||||
|
||||
val rejectPendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
CALL_NOTIFICATION_ID + 2,
|
||||
rejectIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
@@ -577,15 +595,16 @@ object NotificationUtils {
|
||||
.setContentText(callerName)
|
||||
.setLargeIcon(callerBitmap)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setFullScreenIntent(contentPendingIntent, true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setAutoCancel(true)
|
||||
.setOngoing(true)
|
||||
.setTimeoutAfter(60_000)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), contentPendingIntent)
|
||||
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), fullScreenPendingIntent)
|
||||
.setSilent(true)
|
||||
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), rejectPendingIntent)
|
||||
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), acceptPendingIntent)
|
||||
|
||||
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
@@ -20,19 +20,51 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.call
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
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 androidx.compose.runtime.mutableStateOf
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.call.CallState
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CallActivity : AppCompatActivity() {
|
||||
val isInPipMode = mutableStateOf(false)
|
||||
|
||||
private val pipActionReceiver =
|
||||
object : BroadcastReceiver() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
when (intent.action) {
|
||||
ACTION_PIP_HANGUP -> {
|
||||
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() }
|
||||
}
|
||||
|
||||
ACTION_PIP_TOGGLE_MUTE -> {
|
||||
ActiveCallHolder.callController?.toggleAudioMute()
|
||||
updatePipParams()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -46,6 +78,8 @@ class CallActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
registerPipReceiver()
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
CallScreen(
|
||||
@@ -53,6 +87,7 @@ class CallActivity : AppCompatActivity() {
|
||||
callController = callController,
|
||||
accountViewModel = accountViewModel,
|
||||
onCallEnded = { finish() },
|
||||
isInPipMode = isInPipMode.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,12 +100,25 @@ class CallActivity : AppCompatActivity() {
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
if (!isInPictureInPictureMode) {
|
||||
// User expanded from PiP — bring the full call UI back
|
||||
isInPipMode.value = isInPictureInPictureMode
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onDestroy() {
|
||||
unregisterPipReceiver()
|
||||
|
||||
// If the activity is being destroyed while still in an active call
|
||||
// (e.g. user swiped PiP away), hang up the call.
|
||||
val state = ActiveCallHolder.callManager?.state?.value
|
||||
if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) {
|
||||
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() }
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun enterPipIfActive() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val callManager = ActiveCallHolder.callManager ?: return
|
||||
val state = callManager.state.value
|
||||
val isActive =
|
||||
@@ -78,21 +126,103 @@ class CallActivity : AppCompatActivity() {
|
||||
state is CallState.Connecting ||
|
||||
state is CallState.Offering
|
||||
|
||||
if (isActive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (isActive) {
|
||||
try {
|
||||
val params =
|
||||
PictureInPictureParams
|
||||
.Builder()
|
||||
.setAspectRatio(Rational(9, 16))
|
||||
.build()
|
||||
enterPictureInPictureMode(params)
|
||||
enterPictureInPictureMode(buildPipParams())
|
||||
} catch (_: Exception) {
|
||||
// PiP not supported or activity not in correct state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePipParams() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
if (!isInPipMode.value) return
|
||||
try {
|
||||
setPictureInPictureParams(buildPipParams())
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPipParams(): PictureInPictureParams {
|
||||
val builder =
|
||||
PictureInPictureParams
|
||||
.Builder()
|
||||
.setAspectRatio(Rational(16, 9))
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
builder.setActions(buildPipActions())
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildPipActions(): List<RemoteAction> {
|
||||
val actions = mutableListOf<RemoteAction>()
|
||||
|
||||
// Mute / Unmute toggle
|
||||
val isMuted = ActiveCallHolder.callController?.isAudioMuted?.value == true
|
||||
val muteIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
PIP_MUTE_REQUEST_CODE,
|
||||
Intent(ACTION_PIP_TOGGLE_MUTE).setPackage(packageName),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val muteAction =
|
||||
RemoteAction(
|
||||
Icon.createWithResource(
|
||||
this,
|
||||
if (isMuted) R.drawable.ic_mic_off else R.drawable.ic_mic_on,
|
||||
),
|
||||
getString(if (isMuted) R.string.call_unmute else R.string.call_mute),
|
||||
getString(if (isMuted) R.string.call_unmute else R.string.call_mute),
|
||||
muteIntent,
|
||||
)
|
||||
actions.add(muteAction)
|
||||
|
||||
// Hangup
|
||||
val hangupIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
PIP_HANGUP_REQUEST_CODE,
|
||||
Intent(ACTION_PIP_HANGUP).setPackage(packageName),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val hangupAction =
|
||||
RemoteAction(
|
||||
Icon.createWithResource(this, R.drawable.ic_call_end),
|
||||
getString(R.string.call_hangup),
|
||||
getString(R.string.call_hangup),
|
||||
hangupIntent,
|
||||
)
|
||||
actions.add(hangupAction)
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
private fun registerPipReceiver() {
|
||||
val filter =
|
||||
IntentFilter().apply {
|
||||
addAction(ACTION_PIP_HANGUP)
|
||||
addAction(ACTION_PIP_TOGGLE_MUTE)
|
||||
}
|
||||
registerReceiver(pipActionReceiver, filter, RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
private fun unregisterPipReceiver() {
|
||||
try {
|
||||
unregisterReceiver(pipActionReceiver)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_PIP_HANGUP = "com.vitorpamplona.amethyst.PIP_HANGUP"
|
||||
private const val ACTION_PIP_TOGGLE_MUTE = "com.vitorpamplona.amethyst.PIP_TOGGLE_MUTE"
|
||||
private const val PIP_HANGUP_REQUEST_CODE = 0x60001
|
||||
private const val PIP_MUTE_REQUEST_CODE = 0x60002
|
||||
|
||||
fun launch(context: Context) {
|
||||
context.startActivity(
|
||||
Intent(context, CallActivity::class.java).apply {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CallNotificationReceiver : BroadcastReceiver() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
when (intent.action) {
|
||||
ACTION_ACCEPT_CALL -> {
|
||||
val callController = ActiveCallHolder.callController
|
||||
val callManager = ActiveCallHolder.callManager
|
||||
val state = callManager?.state?.value
|
||||
if (state is com.vitorpamplona.amethyst.commons.call.CallState.IncomingCall) {
|
||||
callController?.acceptIncomingCall(state.sdpOffer)
|
||||
}
|
||||
NotificationUtils.cancelCallNotification(context)
|
||||
CallActivity.launch(context)
|
||||
}
|
||||
|
||||
ACTION_REJECT_CALL -> {
|
||||
val callManager = ActiveCallHolder.callManager
|
||||
GlobalScope.launch {
|
||||
callManager?.rejectCall()
|
||||
}
|
||||
NotificationUtils.cancelCallNotification(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_ACCEPT_CALL = "com.vitorpamplona.amethyst.ACCEPT_CALL"
|
||||
const val ACTION_REJECT_CALL = "com.vitorpamplona.amethyst.REJECT_CALL"
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ fun CallScreen(
|
||||
callController: CallController?,
|
||||
accountViewModel: AccountViewModel,
|
||||
onCallEnded: () -> Unit,
|
||||
isInPipMode: Boolean = false,
|
||||
) {
|
||||
val callState by callManager.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -121,73 +122,97 @@ fun CallScreen(
|
||||
}
|
||||
|
||||
is CallState.Offering -> {
|
||||
CallInProgressUI(
|
||||
peerPubKey = state.peerPubKey,
|
||||
statusText = stringRes(R.string.call_calling),
|
||||
accountViewModel = accountViewModel,
|
||||
onHangup = { scope.launch { callManager.hangup() } },
|
||||
)
|
||||
if (isInPipMode) {
|
||||
PipCallUI(peerPubKey = state.peerPubKey, statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
CallInProgressUI(
|
||||
peerPubKey = state.peerPubKey,
|
||||
statusText = stringRes(R.string.call_calling),
|
||||
accountViewModel = accountViewModel,
|
||||
onHangup = { scope.launch { callManager.hangup() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CallState.IncomingCall -> {
|
||||
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
|
||||
val acceptWithPermission =
|
||||
rememberCallWithPermission(context, isVideo = isVideoCall) {
|
||||
callController?.acceptIncomingCall(state.sdpOffer)
|
||||
}
|
||||
IncomingCallUI(
|
||||
callerPubKey = state.callerPubKey,
|
||||
callType = state.callType,
|
||||
accountViewModel = accountViewModel,
|
||||
onAccept = acceptWithPermission,
|
||||
onReject = { scope.launch { callManager.rejectCall() } },
|
||||
)
|
||||
if (isInPipMode) {
|
||||
PipCallUI(callerPubKey = state.callerPubKey, statusText = stringRes(R.string.call_incoming), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
|
||||
val acceptWithPermission =
|
||||
rememberCallWithPermission(context, isVideo = isVideoCall) {
|
||||
callController?.acceptIncomingCall(state.sdpOffer)
|
||||
}
|
||||
IncomingCallUI(
|
||||
callerPubKey = state.callerPubKey,
|
||||
callType = state.callType,
|
||||
accountViewModel = accountViewModel,
|
||||
onAccept = acceptWithPermission,
|
||||
onReject = { scope.launch { callManager.rejectCall() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CallState.Connecting -> {
|
||||
CallInProgressUI(
|
||||
peerPubKey = state.peerPubKey,
|
||||
statusText = stringRes(R.string.call_connecting),
|
||||
accountViewModel = accountViewModel,
|
||||
onHangup = { scope.launch { callManager.hangup() } },
|
||||
)
|
||||
if (isInPipMode) {
|
||||
PipCallUI(peerPubKey = state.peerPubKey, statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
CallInProgressUI(
|
||||
peerPubKey = state.peerPubKey,
|
||||
statusText = stringRes(R.string.call_connecting),
|
||||
accountViewModel = accountViewModel,
|
||||
onHangup = { scope.launch { callManager.hangup() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CallState.Connected -> {
|
||||
ConnectedCallUI(
|
||||
state = state,
|
||||
callController = callController,
|
||||
accountViewModel = accountViewModel,
|
||||
onHangup = { scope.launch { callManager.hangup() } },
|
||||
onToggleMute = { callController?.toggleAudioMute() },
|
||||
onToggleVideo = { callController?.toggleVideo() },
|
||||
onCycleAudioRoute = { callController?.cycleAudioRoute() },
|
||||
)
|
||||
if (isInPipMode) {
|
||||
PipConnectedCallUI(state = state, callController = callController, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
ConnectedCallUI(
|
||||
state = state,
|
||||
callController = callController,
|
||||
accountViewModel = accountViewModel,
|
||||
onHangup = { scope.launch { callManager.hangup() } },
|
||||
onToggleMute = { callController?.toggleAudioMute() },
|
||||
onToggleVideo = { callController?.toggleVideo() },
|
||||
onCycleAudioRoute = { callController?.cycleAudioRoute() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CallState.Ended -> {
|
||||
CallInProgressUI(
|
||||
peerPubKey = state.peerPubKey,
|
||||
statusText = stringRes(R.string.call_ended),
|
||||
accountViewModel = accountViewModel,
|
||||
onHangup = { onCallEnded() },
|
||||
)
|
||||
if (!isInPipMode) {
|
||||
CallInProgressUI(
|
||||
peerPubKey = state.peerPubKey,
|
||||
statusText = stringRes(R.string.call_ended),
|
||||
accountViewModel = accountViewModel,
|
||||
onHangup = { onCallEnded() },
|
||||
)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
delay(2000)
|
||||
onCallEnded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage?.let { error ->
|
||||
Snackbar(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||
action = {
|
||||
Text(
|
||||
stringRes(R.string.call_dismiss),
|
||||
modifier =
|
||||
Modifier.padding(8.dp),
|
||||
color = MaterialTheme.colorScheme.inversePrimary,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(error)
|
||||
if (!isInPipMode) {
|
||||
errorMessage?.let { error ->
|
||||
Snackbar(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||
action = {
|
||||
Text(
|
||||
stringRes(R.string.call_dismiss),
|
||||
modifier =
|
||||
Modifier.padding(8.dp),
|
||||
color = MaterialTheme.colorScheme.inversePrimary,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -557,6 +582,120 @@ private fun formatDuration(seconds: Long): String {
|
||||
return "%02d:%02d".format(mins, secs)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PipCallUI(
|
||||
peerPubKey: String = "",
|
||||
callerPubKey: String = "",
|
||||
statusText: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val pubKey = peerPubKey.ifEmpty { callerPubKey }
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user ->
|
||||
if (user != null) {
|
||||
ClickableUserPicture(
|
||||
baseUser = user,
|
||||
size = 48.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = statusText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PipConnectedCallUI(
|
||||
state: CallState.Connected,
|
||||
callController: CallController?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
var elapsed by remember { mutableLongStateOf(0L) }
|
||||
|
||||
LaunchedEffect(state.startedAtEpoch) {
|
||||
while (true) {
|
||||
elapsed = TimeUtils.now() - state.startedAtEpoch
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) }
|
||||
val remoteVideoTrack by (callController?.remoteVideoTrack ?: emptyVideoFlow).collectAsState()
|
||||
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
|
||||
val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
// Remote video full screen in PiP
|
||||
if (isRemoteVideoActive) {
|
||||
remoteVideoTrack?.let { track ->
|
||||
VideoRenderer(
|
||||
videoTrack = track,
|
||||
eglBase = callController?.getEglBase(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
mirror = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRemoteVideoActive) {
|
||||
// Show small avatar + timer in PiP
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user ->
|
||||
if (user != null) {
|
||||
ClickableUserPicture(
|
||||
baseUser = user,
|
||||
size = 48.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = formatDuration(elapsed),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Timer overlay
|
||||
Text(
|
||||
text = formatDuration(elapsed),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
fontSize = 10.sp,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeepScreenOn() {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01V11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z"/>
|
||||
</vector>
|
||||
Reference in New Issue
Block a user