diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt index efbce5687..d5168878b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.media.AudioAttributes +import android.media.AudioManager import android.media.Ringtone import android.media.RingtoneManager +import android.media.ToneGenerator import android.os.Build import android.os.PowerManager import android.os.VibrationEffect @@ -36,6 +38,9 @@ class CallAudioManager( private var ringtone: Ringtone? = null private var vibrator: Vibrator? = null private var proximityWakeLock: PowerManager.WakeLock? = null + private var ringbackTone: ToneGenerator? = null + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private var previousAudioMode: Int = AudioManager.MODE_NORMAL fun startRinging() { startRingtone() @@ -47,6 +52,34 @@ class CallAudioManager( stopVibration() } + fun startRingbackTone() { + try { + ringbackTone = + ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80).also { + it.startTone(ToneGenerator.TONE_SUP_RINGTONE) + } + } catch (_: Exception) { + // ToneGenerator may not be available + } + } + + fun stopRingbackTone() { + ringbackTone?.stopTone() + ringbackTone?.release() + ringbackTone = null + } + + fun switchToCallAudioMode() { + previousAudioMode = audioManager.mode + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + audioManager.isSpeakerphoneOn = false + } + + fun restoreAudioMode() { + audioManager.mode = previousAudioMode + audioManager.isSpeakerphoneOn = false + } + fun acquireProximityWakeLock() { if (proximityWakeLock != null) return val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager @@ -67,6 +100,8 @@ class CallAudioManager( fun release() { stopRinging() + stopRingbackTone() + restoreAudioMode() releaseProximityWakeLock() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index bf1293b42..728f6d1ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -23,6 +23,10 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.content.Intent import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.notifications.NotificationUtils @@ -31,14 +35,21 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.webrtc.IceCandidate import org.webrtc.MediaStream import org.webrtc.SessionDescription +import org.webrtc.VideoTrack import java.util.UUID import java.util.concurrent.CopyOnWriteArrayList +private const val TAG = "CallController" + class CallController( private val context: Context, private val callManager: CallManager, @@ -54,6 +65,17 @@ class CallController( private val pendingIceCandidates = CopyOnWriteArrayList() val audioManager = CallAudioManager(context) + private val _remoteVideoTrack = MutableStateFlow(null) + val remoteVideoTrack: StateFlow = _remoteVideoTrack.asStateFlow() + + private val _localVideoTrack = MutableStateFlow(null) + val localVideoTrack: StateFlow = _localVideoTrack.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private var networkCallback: ConnectivityManager.NetworkCallback? = null + init { scope.launch { callManager.state.collect { state -> @@ -62,9 +84,16 @@ class CallController( audioManager.startRinging() } + is CallState.Offering -> { + audioManager.startRingbackTone() + } + is CallState.Connecting -> { audioManager.stopRinging() + audioManager.stopRingbackTone() + audioManager.switchToCallAudioMode() audioManager.acquireProximityWakeLock() + registerNetworkCallback() } is CallState.Connected -> { @@ -85,49 +114,65 @@ class CallController( peerPubKey: HexKey, callType: CallType, ) { - val callId = UUID.randomUUID().toString() - currentCallId = callId - currentPeerPubKey = peerPubKey - remoteDescriptionSet = false - pendingIceCandidates.clear() + try { + val callId = UUID.randomUUID().toString() + currentCallId = callId + currentPeerPubKey = peerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() + _errorMessage.value = null - createWebRtcSession() - webRtcSession?.addAudioTrack() - if (callType == CallType.VIDEO) { - webRtcSession?.addVideoTrack() - } - - webRtcSession?.createOffer { sdp -> - scope.launch { - callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + _localVideoTrack.value = webRtcSession?.getLocalVideoTrack() } + + webRtcSession?.createOffer { sdp -> + scope.launch { + callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initiate call", e) + _errorMessage.value = "Failed to start call: ${e.message}" + cleanup() } } fun acceptIncomingCall(sdpOffer: String) { - val state = callManager.state.value - if (state !is CallState.IncomingCall) return + try { + val state = callManager.state.value + if (state !is CallState.IncomingCall) return - currentCallId = state.callId - currentPeerPubKey = state.callerPubKey - remoteDescriptionSet = false - pendingIceCandidates.clear() + currentCallId = state.callId + currentPeerPubKey = state.callerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() + _errorMessage.value = null - createWebRtcSession() - webRtcSession?.addAudioTrack() - if (state.callType == CallType.VIDEO) { - webRtcSession?.addVideoTrack() - } - - webRtcSession?.setRemoteDescription( - SessionDescription(SessionDescription.Type.OFFER, sdpOffer), - ) - flushPendingIceCandidates() - - webRtcSession?.createAnswer { sdp -> - scope.launch { - callManager.acceptCall(sdp.description) + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (state.callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + _localVideoTrack.value = webRtcSession?.getLocalVideoTrack() } + + webRtcSession?.setRemoteDescription( + SessionDescription(SessionDescription.Type.OFFER, sdpOffer), + ) + flushPendingIceCandidates() + + webRtcSession?.createAnswer { sdp -> + scope.launch { + callManager.acceptCall(sdp.description) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to accept call", e) + _errorMessage.value = "Failed to accept call: ${e.message}" + cleanup() } } @@ -169,19 +214,28 @@ class CallController( } fun setSpeakerOn(on: Boolean) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - audioManager.isSpeakerphoneOn = on + val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + am.isSpeakerphoneOn = on } + fun getEglBase() = webRtcSession?.eglBase + fun hangup() { scope.launch { callManager.hangup() } cleanup() } + fun clearError() { + _errorMessage.value = null + } + fun cleanup() { audioManager.release() + unregisterNetworkCallback() stopForegroundService() NotificationUtils.cancelCallNotification(context) + _remoteVideoTrack.value = null + _localVideoTrack.value = null webRtcSession?.dispose() webRtcSession = null currentCallId = null @@ -202,15 +256,58 @@ class CallController( callManager.onPeerConnected() startForegroundService() }, - onRemoteStream = { _: MediaStream -> }, + onRemoteStream = { stream: MediaStream -> + stream.videoTracks?.firstOrNull()?.let { + _remoteVideoTrack.value = it + } + }, onDisconnected = { scope.launch { callManager.hangup() } }, + onError = { error -> + _errorMessage.value = error + }, ) webRtcSession?.initialize() webRtcSession?.createPeerConnection() } + private fun registerNetworkCallback() { + try { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val request = + NetworkRequest + .Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + val callback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + Log.d(TAG) { "Network available, ICE restart may be needed" } + } + + override fun onLost(network: Network) { + Log.d(TAG) { "Network lost during call" } + } + } + connectivityManager.registerNetworkCallback(request, callback) + networkCallback = callback + } catch (e: Exception) { + Log.e(TAG, "Failed to register network callback", e) + } + } + + private fun unregisterNetworkCallback() { + try { + networkCallback?.let { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + connectivityManager.unregisterNetworkCallback(it) + } + } catch (_: Exception) { + } + networkCallback = null + } + private fun onLocalIceCandidate(candidate: IceCandidate) { val callId = currentCallId ?: return val peerPubKey = currentPeerPubKey ?: return @@ -233,11 +330,14 @@ class CallController( } private fun stopForegroundService() { - val intent = - Intent(context, CallForegroundService::class.java).apply { - action = CallForegroundService.ACTION_STOP - } - context.startService(intent) + try { + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_STOP + } + context.startService(intent) + } catch (_: Exception) { + } } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index b877c044a..7fdc043fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -51,6 +51,7 @@ class WebRtcCallSession( private val onPeerConnected: () -> Unit, private val onRemoteStream: (MediaStream) -> Unit, private val onDisconnected: () -> Unit, + private val onError: (String) -> Unit = {}, ) { private var peerConnectionFactory: PeerConnectionFactory? = null private var peerConnection: PeerConnection? = null @@ -104,9 +105,12 @@ class WebRtcCallSession( onPeerConnected() } - PeerConnection.IceConnectionState.DISCONNECTED, - PeerConnection.IceConnectionState.FAILED, - -> { + PeerConnection.IceConnectionState.FAILED -> { + onError("Connection failed - check network") + onDisconnected() + } + + PeerConnection.IceConnectionState.DISCONNECTED -> { onDisconnected() } @@ -278,12 +282,14 @@ class WebRtcCallSession( override fun onCreateFailure(error: String?) { Log.e(TAG, "SDP operation failed: $error") + error?.let { onError("SDP error: $it") } } override fun onSetSuccess() {} override fun onSetFailure(error: String?) { Log.e(TAG, "SDP set failed: $error") + error?.let { onError("SDP error: $it") } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index ee6325639..08d00f9f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -584,6 +584,8 @@ object NotificationUtils { .setOngoing(true) .setTimeoutAfter(60_000) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .addAction(R.drawable.amethyst, "Reject", contentPendingIntent) + .addAction(R.drawable.amethyst, "Accept", fullScreenPendingIntent) notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 4f7ef31bd..1d4e5c432 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -31,15 +31,23 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +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.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.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.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -57,6 +65,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.call.CallController @@ -67,6 +76,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.webrtc.RendererCommon +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoTrack @Composable fun CallScreen( @@ -78,6 +90,7 @@ fun CallScreen( val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() val context = LocalContext.current + val errorMessage by (callController?.errorMessage ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) { scope.launch { callManager.hangup() } @@ -85,70 +98,89 @@ fun CallScreen( KeepScreenOn() - when (val state = callState) { - is CallState.Idle -> { - LaunchedEffect(Unit) { onCallEnded() } + Box(modifier = Modifier.fillMaxSize()) { + when (val state = callState) { + is CallState.Idle -> { + LaunchedEffect(Unit) { onCallEnded() } + } + + is CallState.Offering -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Calling...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.IncomingCall -> { + val acceptWithPermission = + rememberCallWithPermission(context) { + callController?.acceptIncomingCall(state.sdpOffer) + } + IncomingCallUI( + callerPubKey = state.callerPubKey, + callType = state.callType, + accountViewModel = accountViewModel, + onAccept = acceptWithPermission, + onReject = { scope.launch { callManager.rejectCall() } }, + ) + } + + is CallState.Connecting -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Connecting...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.Connected -> { + ConnectedCallUI( + state = state, + callController = callController, + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + onToggleMute = { + callManager.toggleAudioMute() + callController?.setAudioMuted(!state.isAudioMuted) + }, + onToggleVideo = { + callManager.toggleVideo() + callController?.setVideoEnabled(!state.isVideoEnabled) + }, + onToggleSpeaker = { + callManager.toggleSpeaker() + callController?.setSpeakerOn(!state.isSpeakerOn) + }, + ) + } + + is CallState.Ended -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Call ended", + accountViewModel = accountViewModel, + onHangup = { onCallEnded() }, + ) + } } - is CallState.Offering -> { - CallInProgressUI( - peerPubKey = state.peerPubKey, - statusText = "Calling...", - accountViewModel = accountViewModel, - onHangup = { scope.launch { callManager.hangup() } }, - ) - } - - is CallState.IncomingCall -> { - val acceptWithPermission = - rememberCallWithPermission(context) { - callController?.acceptIncomingCall(state.sdpOffer) - } - IncomingCallUI( - callerPubKey = state.callerPubKey, - callType = state.callType, - accountViewModel = accountViewModel, - onAccept = acceptWithPermission, - onReject = { scope.launch { callManager.rejectCall() } }, - ) - } - - is CallState.Connecting -> { - CallInProgressUI( - peerPubKey = state.peerPubKey, - statusText = "Connecting...", - accountViewModel = accountViewModel, - onHangup = { scope.launch { callManager.hangup() } }, - ) - } - - is CallState.Connected -> { - ConnectedCallUI( - state = state, - accountViewModel = accountViewModel, - onHangup = { scope.launch { callManager.hangup() } }, - onToggleMute = { - callManager.toggleAudioMute() - callController?.setAudioMuted(!state.isAudioMuted) + errorMessage?.let { error -> + Snackbar( + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + action = { + Text( + "Dismiss", + modifier = + Modifier.padding(8.dp), + color = MaterialTheme.colorScheme.inversePrimary, + ) }, - onToggleVideo = { - callManager.toggleVideo() - callController?.setVideoEnabled(!state.isVideoEnabled) - }, - onToggleSpeaker = { - callManager.toggleSpeaker() - callController?.setSpeakerOn(!state.isSpeakerOn) - }, - ) - } - - is CallState.Ended -> { - CallInProgressUI( - peerPubKey = state.peerPubKey, - statusText = "Call ended", - accountViewModel = accountViewModel, - onHangup = { onCallEnded() }, - ) + ) { + Text(error) + } } } } @@ -288,6 +320,7 @@ private fun IncomingCallUI( @Composable private fun ConnectedCallUI( state: CallState.Connected, + callController: CallController?, accountViewModel: AccountViewModel, onHangup: () -> Unit, onToggleMute: () -> Unit, @@ -303,51 +336,126 @@ private fun ConnectedCallUI( } } + val remoteVideoTrack by (callController?.remoteVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() + val localVideoTrack by (callController?.localVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() + Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center, + .background(Color.Black), ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> - if (user != null) { - ClickableUserPicture( - baseUser = user, - size = 120.dp, - accountViewModel = accountViewModel, - ) - Spacer(modifier = Modifier.height(16.dp)) - UsernameDisplay( - baseUser = user, - accountViewModel = accountViewModel, - fontWeight = FontWeight.Bold, - ) + // Remote video (full screen background) + remoteVideoTrack?.let { track -> + VideoRenderer( + videoTrack = track, + eglBase = callController?.getEglBase(), + modifier = Modifier.fillMaxSize(), + mirror = false, + ) + } + + // Local video (small pip in corner) + localVideoTrack?.let { track -> + VideoRenderer( + videoTrack = track, + eglBase = callController?.getEglBase(), + modifier = + Modifier + .size(120.dp, 160.dp) + .align(Alignment.TopEnd) + .padding(16.dp), + mirror = true, + ) + } + + // If no video, show avatar + if (remoteVideoTrack == null) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + textColor = Color.White, + ) + } } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatDuration(elapsed), + color = Color.White.copy(alpha = 0.7f), + fontSize = 16.sp, + ) } - Spacer(modifier = Modifier.height(8.dp)) + } else { + // Timer overlay Text( text = formatDuration(elapsed), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 16.sp, + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = 48.dp), ) - Spacer(modifier = Modifier.height(48.dp)) + } + + // Controls at bottom + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, ) { - IconButton(onClick = onToggleMute) { - Text(if (state.isAudioMuted) "Unmute" else "Mute") + IconButton( + onClick = onToggleMute, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = if (state.isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic, + contentDescription = if (state.isAudioMuted) "Unmute" else "Mute", + tint = if (state.isAudioMuted) Color.Red else Color.White, + modifier = Modifier.size(28.dp), + ) } - IconButton(onClick = onToggleVideo) { - Text(if (state.isVideoEnabled) "Cam Off" else "Cam On") + IconButton( + onClick = onToggleVideo, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = if (state.isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff, + contentDescription = if (state.isVideoEnabled) "Camera off" else "Camera on", + tint = if (!state.isVideoEnabled) Color.Red else Color.White, + modifier = Modifier.size(28.dp), + ) } - IconButton(onClick = onToggleSpeaker) { - Text(if (state.isSpeakerOn) "Earpiece" else "Speaker") + IconButton( + onClick = onToggleSpeaker, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, + contentDescription = if (state.isSpeakerOn) "Earpiece" else "Speaker", + tint = if (state.isSpeakerOn) Color.Cyan else Color.White, + modifier = Modifier.size(28.dp), + ) } } Spacer(modifier = Modifier.height(24.dp)) @@ -368,6 +476,30 @@ private fun ConnectedCallUI( } } +@Composable +private fun VideoRenderer( + videoTrack: VideoTrack, + eglBase: org.webrtc.EglBase?, + modifier: Modifier = Modifier, + mirror: Boolean = false, +) { + AndroidView( + modifier = modifier, + factory = { ctx -> + SurfaceViewRenderer(ctx).apply { + setMirror(mirror) + setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL) + eglBase?.eglBaseContext?.let { init(it, null) } + videoTrack.addSink(this) + } + }, + onRelease = { renderer -> + videoTrack.removeSink(renderer) + renderer.release() + }, + ) +} + private fun formatDuration(seconds: Long): String { val mins = seconds / 60 val secs = seconds % 60