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
This commit is contained in:
Claude
2026-04-03 01:07:14 +00:00
parent 0c7aa67dd8
commit a65e192f03
5 changed files with 574 additions and 188 deletions
@@ -337,6 +337,17 @@ class CallController(
fun getEglBase() = webRtcSession?.eglBase 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() { fun hangup() {
scope.launch { scope.launch {
callManager.hangup() callManager.hangup()
@@ -44,22 +44,27 @@ import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff 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.Videocam
import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Snackbar import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -181,6 +188,7 @@ fun CallScreen(
onToggleMute = { callController?.toggleAudioMute() }, onToggleMute = { callController?.toggleAudioMute() },
onToggleVideo = { callController?.toggleVideo() }, onToggleVideo = { callController?.toggleVideo() },
onCycleAudioRoute = { callController?.cycleAudioRoute() }, onCycleAudioRoute = { callController?.cycleAudioRoute() },
onInvitePeer = { peerPubKey -> callController?.invitePeer(peerPubKey) },
) )
} }
} }
@@ -363,6 +371,7 @@ private fun ConnectedCallUI(
onToggleMute: () -> Unit, onToggleMute: () -> Unit,
onToggleVideo: () -> Unit, onToggleVideo: () -> Unit,
onCycleAudioRoute: () -> Unit, onCycleAudioRoute: () -> Unit,
onInvitePeer: (String) -> Unit = {},
) { ) {
var elapsed by remember { mutableLongStateOf(0L) } var elapsed by remember { mutableLongStateOf(0L) }
@@ -384,6 +393,20 @@ private fun ConnectedCallUI(
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).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( Box(
modifier = modifier =
Modifier Modifier
@@ -529,6 +552,17 @@ private fun ConnectedCallUI(
modifier = Modifier.size(28.dp), 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)) Spacer(modifier = Modifier.height(24.dp))
FloatingActionButton( FloatingActionButton(
@@ -682,6 +716,57 @@ private fun PipConnectedCallUI(
} }
} }
@Composable
private fun AddParticipantDialog(
accountViewModel: AccountViewModel,
existingPeers: Set<String>,
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 @Composable
private fun GroupCallPictures( private fun GroupCallPictures(
peerPubKeys: Set<String>, peerPubKeys: Set<String>,
@@ -33,11 +33,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.Call
import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Person 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.Videocam
import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeOff
@@ -57,8 +59,142 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
// ---- Shared building blocks for previews ----
@Composable @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( Box(
modifier = modifier =
Modifier Modifier
@@ -70,24 +206,11 @@ private fun PreviewCallInProgress(statusText: String) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon( PreviewAvatar()
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(text = name, fontWeight = FontWeight.Bold, fontSize = 20.sp)
text = "Alice",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(text = statusText, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp)
text = statusText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
FloatingActionButton( FloatingActionButton(
onClick = {}, onClick = {},
@@ -95,12 +218,7 @@ private fun PreviewCallInProgress(statusText: String) {
shape = CircleShape, shape = CircleShape,
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
) { ) {
Icon( Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp))
Icons.Default.CallEnd,
contentDescription = "Hang up",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
} }
} }
} }
@@ -110,29 +228,15 @@ private fun PreviewCallInProgress(statusText: String) {
@Composable @Composable
fun PreviewCallingScreen() { fun PreviewCallingScreen() {
ThemeComparisonColumn { ThemeComparisonColumn {
PreviewCallInProgress("Calling...") PreviewCallInProgress("Alice", "Calling\u2026")
} }
} }
@Preview(showBackground = true, widthDp = 360, heightDp = 640) // ---- 2. Offering group call ----
@Composable
fun PreviewConnectingScreen() {
ThemeComparisonColumn {
PreviewCallInProgress("Connecting...")
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640) @Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable @Composable
fun PreviewCallEndedScreen() { fun PreviewCallingGroupScreen() {
ThemeComparisonColumn {
PreviewCallInProgress("Call ended")
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun PreviewIncomingCallScreen() {
ThemeComparisonColumn { ThemeComparisonColumn {
Box( Box(
modifier = modifier =
@@ -145,53 +249,64 @@ fun PreviewIncomingCallScreen() {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon( PreviewGroupAvatars(3)
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text("Alice, Bob +1", fontWeight = FontWeight.Bold, fontSize = 20.sp)
text = "Bob",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text("Calling\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp)
text = "Incoming voice call...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
Row( FloatingActionButton(
horizontalArrangement = Arrangement.spacedBy(48.dp), onClick = {},
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) { ) {
FloatingActionButton( Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp))
onClick = {}, }
containerColor = Color.Red, }
shape = CircleShape, }
modifier = Modifier.size(64.dp), }
) { }
Icon(
Icons.Default.CallEnd, // ---- 3. Connecting ----
contentDescription = "Reject",
tint = Color.White, @Preview(showBackground = true, widthDp = 360, heightDp = 640)
modifier = Modifier.size(32.dp), @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( FloatingActionButton(onClick = {}, containerColor = Color(0xFF4CAF50), shape = CircleShape, modifier = Modifier.size(64.dp)) {
onClick = {}, Icon(Icons.Default.Call, "Accept", tint = Color.White, modifier = Modifier.size(32.dp))
containerColor = Color(0xFF4CAF50),
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.Call,
contentDescription = "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) @Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable @Composable
fun PreviewConnectedCallScreen() { fun PreviewConnectedCallScreen() {
@@ -214,91 +403,25 @@ fun PreviewConnectedCallScreen() {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon( PreviewAvatar(tint = Color.White.copy(alpha = 0.5f))
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(16.dp)) 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)) 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( Column(
modifier = modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp),
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Row( PreviewCallControls(showAddParticipant = true)
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),
)
}
} }
} }
} }
} }
// ---- 8. Connected P2P muted + video + speaker ----
@Preview(showBackground = true, widthDp = 360, heightDp = 640) @Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable @Composable
fun PreviewConnectedCallMuted() { fun PreviewConnectedCallMuted() {
@@ -314,12 +437,7 @@ fun PreviewConnectedCallMuted() {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon( PreviewAvatar(tint = Color.White.copy(alpha = 0.5f))
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
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)) Spacer(modifier = Modifier.height(8.dp))
@@ -327,36 +445,176 @@ fun PreviewConnectedCallMuted() {
} }
Column( Column(
modifier = modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp),
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Row( PreviewCallControls(
modifier = Modifier.fillMaxWidth(), isMuted = true,
horizontalArrangement = Arrangement.SpaceEvenly, isVideoEnabled = true,
) { audioRoute = "speaker",
IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { showAddParticipant = true,
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))
}
} }
} }
} }
} }
// ---- 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))
}
}
}
+2
View File
@@ -797,6 +797,8 @@
<string name="call_voice">Voice call</string> <string name="call_voice">Voice call</string>
<string name="call_video">Video call</string> <string name="call_video">Video call</string>
<string name="call_waiting_for_others">Waiting for others to join\u2026</string> <string name="call_waiting_for_others">Waiting for others to join\u2026</string>
<string name="call_add_participant">Add participant</string>
<string name="call_search_users">Search users\u2026</string>
<string name="call_failed_start">Failed to start call</string> <string name="call_failed_start">Failed to start call</string>
<string name="call_failed_accept">Failed to accept call</string> <string name="call_failed_accept">Failed to accept call</string>
<string name="call_failed_session">Failed to create call session</string> <string name="call_failed_session">Failed to create call session</string>
@@ -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() { suspend fun hangup() {
val peerPubKeys: Set<HexKey> val peerPubKeys: Set<HexKey>
val callId: String val callId: String