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 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()
@@ -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<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
private fun GroupCallPictures(
peerPubKeys: Set<String>,
@@ -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))
}
}
}
+2
View File
@@ -797,6 +797,8 @@
<string name="call_voice">Voice 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_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_accept">Failed to accept call</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() {
val peerPubKeys: Set<HexKey>
val callId: String