From a65e192f03e80a4359a5cec708a3fcc14327308e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 01:07:14 +0000 Subject: [PATCH] feat: add participant to existing call + comprehensive call previews Add ability to invite new users into an ongoing call: - CallManager.invitePeer() sends a call offer to a new peer using the current callId, adding them to pendingPeerPubKeys - CallController.invitePeer() exposes this to the UI layer - Add PersonAdd button to connected call controls - Add AddParticipantDialog with user search (reuses UserSuggestionState) - New strings: call_add_participant, call_search_users Rewrite CallScreenPreviews to showcase all call states: 1. Offering P2P call (Calling...) 2. Offering group call (3 avatars) 3. Connecting 4. Incoming voice call 5. Incoming video call 6. Incoming group call (4 avatars) 7. Connected P2P voice call with controls 8. Connected muted + video + speaker 9. Connected group call with pending peers 10. Connected with Bluetooth audio 11. Call ended 12. Large group (5+ members, +N badge) 13. PiP calling 14. PiP connected https://claude.ai/code/session_01LR8NmFGdMoDTVcfKjL7HWR --- .../amethyst/service/call/CallController.kt | 11 + .../amethyst/ui/call/CallScreen.kt | 85 +++ .../amethyst/ui/call/CallScreenPreviews.kt | 634 ++++++++++++------ amethyst/src/main/res/values/strings.xml | 2 + .../amethyst/commons/call/CallManager.kt | 30 + 5 files changed, 574 insertions(+), 188 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index c841892db1..1a9a54ddbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -337,6 +337,17 @@ class CallController( fun getEglBase() = webRtcSession?.eglBase + fun invitePeer(peerPubKey: String) { + scope.launch { + val session = webRtcSession ?: return@launch + session.createOffer { sdp -> + scope.launch { + callManager.invitePeer(peerPubKey, sdp.description) + } + } + } + } + fun hangup() { scope.launch { callManager.hangup() 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 daee010852..c601525152 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 @@ -44,22 +44,27 @@ import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material3.AlertDialog import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Snackbar import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -81,6 +86,8 @@ import com.vitorpamplona.amethyst.service.call.CallController import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes @@ -181,6 +188,7 @@ fun CallScreen( onToggleMute = { callController?.toggleAudioMute() }, onToggleVideo = { callController?.toggleVideo() }, onCycleAudioRoute = { callController?.cycleAudioRoute() }, + onInvitePeer = { peerPubKey -> callController?.invitePeer(peerPubKey) }, ) } } @@ -363,6 +371,7 @@ private fun ConnectedCallUI( onToggleMute: () -> Unit, onToggleVideo: () -> Unit, onCycleAudioRoute: () -> Unit, + onInvitePeer: (String) -> Unit = {}, ) { var elapsed by remember { mutableLongStateOf(0L) } @@ -384,6 +393,20 @@ private fun ConnectedCallUI( val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState() + var showAddParticipant by remember { mutableStateOf(false) } + + if (showAddParticipant) { + AddParticipantDialog( + accountViewModel = accountViewModel, + existingPeers = state.allPeerPubKeys, + onInvite = { peerPubKey -> + onInvitePeer(peerPubKey) + showAddParticipant = false + }, + onDismiss = { showAddParticipant = false }, + ) + } + Box( modifier = Modifier @@ -529,6 +552,17 @@ private fun ConnectedCallUI( modifier = Modifier.size(28.dp), ) } + IconButton( + onClick = { showAddParticipant = true }, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = stringRes(R.string.call_add_participant), + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } } Spacer(modifier = Modifier.height(24.dp)) FloatingActionButton( @@ -682,6 +716,57 @@ private fun PipConnectedCallUI( } } +@Composable +private fun AddParticipantDialog( + accountViewModel: AccountViewModel, + existingPeers: Set, + onInvite: (String) -> Unit, + onDismiss: () -> Unit, +) { + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + var searchText by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.call_add_participant)) }, + text = { + Column { + OutlinedTextField( + value = searchText, + onValueChange = { + searchText = it + userSuggestions.processCurrentWord(it) + }, + label = { Text(stringRes(R.string.call_search_users)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + Box(modifier = Modifier.height(300.dp)) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (user.pubkeyHex !in existingPeers) { + onInvite(user.pubkeyHex) + } + }, + accountViewModel = accountViewModel, + ) + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.call_dismiss)) + } + }, + ) +} + @Composable private fun GroupCallPictures( peerPubKeys: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt index 85d832c639..01e228faa8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt @@ -33,11 +33,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BluetoothAudio import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material.icons.filled.VolumeOff @@ -57,8 +59,142 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +// ---- Shared building blocks for previews ---- + @Composable -private fun PreviewCallInProgress(statusText: String) { +private fun PreviewAvatar( + modifier: Modifier = Modifier, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = modifier.size(120.dp), + tint = tint, + ) +} + +@Composable +private fun PreviewGroupAvatars( + count: Int, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + val halfSize = 60.dp + Box(Modifier.size(120.dp)) { + repeat(minOf(count, 4)) { i -> + val alignment = + when (i) { + 0 -> Alignment.BottomStart + 1 -> Alignment.TopStart + 2 -> Alignment.BottomEnd + else -> Alignment.TopEnd + } + if (i < 3 || count <= 4) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(halfSize).align(alignment), + tint = tint, + ) + } else { + Box( + modifier = + Modifier + .size(halfSize) + .align(alignment) + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+${count - 3}", + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + } + } + } + } +} + +@Composable +private fun PreviewCallControls( + isMuted: Boolean = false, + isVideoEnabled: Boolean = false, + audioRoute: String = "earpiece", + showAddParticipant: Boolean = false, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon( + imageVector = if (isMuted) Icons.Default.MicOff else Icons.Default.Mic, + contentDescription = "Mute", + tint = if (isMuted) Color.Red else Color.White, + modifier = Modifier.size(28.dp), + ) + } + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon( + imageVector = if (isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff, + contentDescription = "Camera", + tint = if (!isVideoEnabled) Color.Red else Color.White, + modifier = Modifier.size(28.dp), + ) + } + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon( + imageVector = + when (audioRoute) { + "speaker" -> Icons.Default.VolumeUp + "bluetooth" -> Icons.Default.BluetoothAudio + else -> Icons.Default.VolumeOff + }, + contentDescription = "Audio route", + tint = + when (audioRoute) { + "speaker" -> Color.Cyan + "bluetooth" -> Color(0xFF2196F3) + else -> Color.White + }, + modifier = Modifier.size(28.dp), + ) + } + if (showAddParticipant) { + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon( + Icons.Default.PersonAdd, + contentDescription = "Add participant", + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } +} + +// ---- 1. Offering (Calling...) ---- + +@Composable +private fun PreviewCallInProgress( + name: String, + statusText: String, +) { Box( modifier = Modifier @@ -70,24 +206,11 @@ private fun PreviewCallInProgress(statusText: String) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(120.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + PreviewAvatar() Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Alice", - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) + Text(text = name, fontWeight = FontWeight.Bold, fontSize = 20.sp) Spacer(modifier = Modifier.height(8.dp)) - Text( - text = statusText, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 16.sp, - ) + Text(text = statusText, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) Spacer(modifier = Modifier.height(48.dp)) FloatingActionButton( onClick = {}, @@ -95,12 +218,7 @@ private fun PreviewCallInProgress(statusText: String) { shape = CircleShape, modifier = Modifier.size(64.dp), ) { - Icon( - Icons.Default.CallEnd, - contentDescription = "Hang up", - tint = Color.White, - modifier = Modifier.size(32.dp), - ) + Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) } } } @@ -110,29 +228,15 @@ private fun PreviewCallInProgress(statusText: String) { @Composable fun PreviewCallingScreen() { ThemeComparisonColumn { - PreviewCallInProgress("Calling...") + PreviewCallInProgress("Alice", "Calling\u2026") } } -@Preview(showBackground = true, widthDp = 360, heightDp = 640) -@Composable -fun PreviewConnectingScreen() { - ThemeComparisonColumn { - PreviewCallInProgress("Connecting...") - } -} +// ---- 2. Offering group call ---- @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Composable -fun PreviewCallEndedScreen() { - ThemeComparisonColumn { - PreviewCallInProgress("Call ended") - } -} - -@Preview(showBackground = true, widthDp = 360, heightDp = 640) -@Composable -fun PreviewIncomingCallScreen() { +fun PreviewCallingGroupScreen() { ThemeComparisonColumn { Box( modifier = @@ -145,53 +249,64 @@ fun PreviewIncomingCallScreen() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(120.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + PreviewGroupAvatars(3) Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Bob", - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) + Text("Alice, Bob +1", fontWeight = FontWeight.Bold, fontSize = 20.sp) Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Incoming voice call...", - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 16.sp, - ) + Text("Calling\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) Spacer(modifier = Modifier.height(48.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(48.dp), + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), ) { - FloatingActionButton( - onClick = {}, - containerColor = Color.Red, - shape = CircleShape, - modifier = Modifier.size(64.dp), - ) { - Icon( - Icons.Default.CallEnd, - contentDescription = "Reject", - tint = Color.White, - modifier = Modifier.size(32.dp), - ) + Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } + } + } +} + +// ---- 3. Connecting ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectingScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Alice", "Connecting\u2026") + } +} + +// ---- 4. Incoming voice call ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewIncomingVoiceCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewAvatar() + Spacer(modifier = Modifier.height(16.dp)) + Text("Bob", fontWeight = FontWeight.Bold, fontSize = 20.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text("Incoming voice call\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) + Spacer(modifier = Modifier.height(48.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { + FloatingActionButton(onClick = {}, containerColor = Color.Red, shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.CallEnd, "Reject", tint = Color.White, modifier = Modifier.size(32.dp)) } - FloatingActionButton( - onClick = {}, - containerColor = Color(0xFF4CAF50), - shape = CircleShape, - modifier = Modifier.size(64.dp), - ) { - Icon( - Icons.Default.Call, - contentDescription = "Accept", - tint = Color.White, - modifier = Modifier.size(32.dp), - ) + FloatingActionButton(onClick = {}, containerColor = Color(0xFF4CAF50), shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.Call, "Accept", tint = Color.White, modifier = Modifier.size(32.dp)) } } } @@ -199,6 +314,80 @@ fun PreviewIncomingCallScreen() { } } +// ---- 5. Incoming video call ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewIncomingVideoCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewAvatar() + Spacer(modifier = Modifier.height(16.dp)) + Text("Bob", fontWeight = FontWeight.Bold, fontSize = 20.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text("Incoming video call\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) + Spacer(modifier = Modifier.height(48.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { + FloatingActionButton(onClick = {}, containerColor = Color.Red, shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.CallEnd, "Reject", tint = Color.White, modifier = Modifier.size(32.dp)) + } + FloatingActionButton(onClick = {}, containerColor = Color(0xFF4CAF50), shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.Call, "Accept", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } + } + } + } +} + +// ---- 6. Incoming group call ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewIncomingGroupCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewGroupAvatars(4) + Spacer(modifier = Modifier.height(16.dp)) + Text("Bob, Alice +2", fontWeight = FontWeight.Bold, fontSize = 20.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text("Incoming voice call\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) + Spacer(modifier = Modifier.height(48.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { + FloatingActionButton(onClick = {}, containerColor = Color.Red, shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.CallEnd, "Reject", tint = Color.White, modifier = Modifier.size(32.dp)) + } + FloatingActionButton(onClick = {}, containerColor = Color(0xFF4CAF50), shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.Call, "Accept", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } + } + } + } +} + +// ---- 7. Connected P2P voice call ---- + @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Composable fun PreviewConnectedCallScreen() { @@ -214,91 +403,25 @@ fun PreviewConnectedCallScreen() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(120.dp), - tint = Color.White.copy(alpha = 0.5f), - ) + PreviewAvatar(tint = Color.White.copy(alpha = 0.5f)) Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Alice", - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - color = Color.White, - ) + Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "02:45", - color = Color.White.copy(alpha = 0.7f), - fontSize = 16.sp, - ) + Text("02:45", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) } Column( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 48.dp), + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - IconButton( - onClick = {}, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = "Mute", - tint = Color.White, - modifier = Modifier.size(28.dp), - ) - } - IconButton( - onClick = {}, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Icons.Default.VideocamOff, - contentDescription = "Camera off", - tint = Color.Red, - modifier = Modifier.size(28.dp), - ) - } - IconButton( - onClick = {}, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Icons.Default.VolumeOff, - contentDescription = "Speaker", - tint = Color.White, - modifier = Modifier.size(28.dp), - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - FloatingActionButton( - onClick = {}, - containerColor = Color.Red, - shape = CircleShape, - modifier = Modifier.size(64.dp), - ) { - Icon( - Icons.Default.CallEnd, - contentDescription = "Hang up", - tint = Color.White, - modifier = Modifier.size(32.dp), - ) - } + PreviewCallControls(showAddParticipant = true) } } } } +// ---- 8. Connected P2P muted + video + speaker ---- + @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Composable fun PreviewConnectedCallMuted() { @@ -314,12 +437,7 @@ fun PreviewConnectedCallMuted() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(120.dp), - tint = Color.White.copy(alpha = 0.5f), - ) + PreviewAvatar(tint = Color.White.copy(alpha = 0.5f)) Spacer(modifier = Modifier.height(16.dp)) Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) Spacer(modifier = Modifier.height(8.dp)) @@ -327,36 +445,176 @@ fun PreviewConnectedCallMuted() { } Column( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 48.dp), + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { - Icon(Icons.Default.MicOff, "Unmute", tint = Color.Red, modifier = Modifier.size(28.dp)) - } - IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { - Icon(Icons.Default.Videocam, "Camera on", tint = Color.White, modifier = Modifier.size(28.dp)) - } - IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { - Icon(Icons.Default.VolumeUp, "Earpiece", tint = Color.Cyan, modifier = Modifier.size(28.dp)) - } - } - Spacer(modifier = Modifier.height(24.dp)) - FloatingActionButton( - onClick = {}, - containerColor = Color.Red, - shape = CircleShape, - modifier = Modifier.size(64.dp), - ) { - Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) - } + PreviewCallControls( + isMuted = true, + isVideoEnabled = true, + audioRoute = "speaker", + showAddParticipant = true, + ) } } } } + +// ---- 9. Connected group call with pending peers ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectedGroupCallWithPending() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewGroupAvatars(3, tint = Color.White.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(16.dp)) + Text("Alice, Bob +1", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + Text("01:30", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) + Spacer(modifier = Modifier.height(4.dp)) + Text("Waiting for others to join\u2026", color = Color.White.copy(alpha = 0.5f), fontSize = 13.sp) + } + + Column( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PreviewCallControls(showAddParticipant = true) + } + } + } +} + +// ---- 10. Connected with Bluetooth audio ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectedCallBluetooth() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewAvatar(tint = Color.White.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(16.dp)) + Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + Text("10:03", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) + } + + Column( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PreviewCallControls(audioRoute = "bluetooth", showAddParticipant = true) + } + } + } +} + +// ---- 11. Call ended ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewCallEndedScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Alice", "Call ended") + } +} + +// ---- 12. Group call with 5+ members (shows +N badge) ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewGroupCallLargeGroup() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewGroupAvatars(6, tint = Color.White.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(16.dp)) + Text("Alice, Bob +4", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + Text("03:21", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) + } + + Column( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PreviewCallControls(showAddParticipant = true) + } + } + } +} + +// ---- 13. PiP mode (small) ---- + +@Preview(showBackground = true, widthDp = 200, heightDp = 120) +@Composable +fun PreviewPipCallUI() { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(4.dp)) + Text("Calling\u2026", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +// ---- 14. PiP connected (small) ---- + +@Preview(showBackground = true, widthDp = 200, heightDp = 120) +@Composable +fun PreviewPipConnectedCallUI() { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(48.dp), tint = Color.White.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(4.dp)) + Text("02:45", fontSize = 10.sp, color = Color.White.copy(alpha = 0.7f)) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 49801f5cbe..fe8bc00494 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -797,6 +797,8 @@ Voice call Video call Waiting for others to join\u2026 + Add participant + Search users\u2026 Failed to start call Failed to accept call Failed to create call session diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 496b08d5b3..724cab6a7d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -301,6 +301,36 @@ class CallManager( ) } + /** Invites a new peer into the current call by sending them an offer. */ + suspend fun invitePeer( + peerPubKey: HexKey, + sdpOffer: String, + ) { + val current = _state.value + val callId: String + val callType: CallType + when (current) { + is CallState.Connecting -> { + callId = current.callId + callType = current.callType + _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey) + } + + is CallState.Connected -> { + callId = current.callId + callType = current.callType + _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey) + } + + else -> { + return + } + } + + val result = factory.createCallOffer(sdpOffer, peerPubKey, callId, callType, signer) + publishEvent(result.wrap) + } + suspend fun hangup() { val peerPubKeys: Set val callId: String