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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user