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