fix: route call events via EventProcessor and add video call button

Fix call connection stuck at "Connecting":
- Inner events from unwrapped GiftWraps are dispatched through
  EventProcessor.consumeEvent(), not LocalCache.newEventBundles.
  The previous approach using newEventBundles observer never saw
  the inner call events because they're created internally during
  GiftWrap processing, not from relay arrivals.
- Restored callManager routing in EventProcessor.consumeEvent()
  and wired it via account.newNotesPreProcessor.callManager in
  AccountViewModel.initCallController()

Video call button:
- Added Videocam icon button in DM chat header (RenderRoomTopBar)
  next to the voice call button
- onVideoCallClick initiates a VIDEO type call with camera capturer

https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
Claude
2026-04-02 01:12:04 +00:00
parent b0f3943e69
commit d93b384f72
4 changed files with 47 additions and 25 deletions
@@ -146,12 +146,6 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -222,6 +216,7 @@ class AccountViewModel(
)
callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) }
callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) }
account.newNotesPreProcessor.callManager = callManager
callController = controller
}
@@ -1415,23 +1410,6 @@ class AccountViewModel(
}
}
}
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
newNotes.forEach { note ->
val event = note.event ?: return@forEach
when (event) {
is CallOfferEvent,
is CallAnswerEvent,
is CallIceCandidateEvent,
is CallHangupEvent,
is CallRejectEvent,
is CallRenegotiateEvent,
-> callManager.onSignalingEvent(event)
}
}
}
}
}
override fun onCleared() {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -36,6 +37,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
@@ -52,6 +59,8 @@ class EventProcessor(
private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache)
private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache)
var callManager: CallManager? = null
suspend fun consume(note: Note) {
note.event?.let { event ->
try {
@@ -68,10 +77,22 @@ class EventProcessor(
publicNote: Note,
) {
when (event) {
is CallOfferEvent,
is CallAnswerEvent,
is CallIceCandidateEvent,
is CallHangupEvent,
is CallRejectEvent,
is CallRenegotiateEvent,
-> callManager?.onSignalingEvent(event)
is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote)
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote)
}
}
@@ -48,12 +48,18 @@ fun ChatroomScreen(
nav: INav,
) {
val context = LocalContext.current
val startCall =
val startVoiceCall =
rememberCallWithPermission(context) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE)
nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey))
}
val startVideoCall =
rememberCallWithPermission(context) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO)
nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey))
}
DisappearingScaffold(
isInvertedLayout = true,
@@ -62,7 +68,8 @@ fun ChatroomScreen(
room = roomId,
accountViewModel = accountViewModel,
nav = nav,
onCallClick = { _ -> startCall() },
onCallClick = { _ -> startVoiceCall() },
onVideoCallClick = { _ -> startVideoCall() },
)
},
accountViewModel = accountViewModel,
@@ -33,6 +33,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -74,6 +75,7 @@ fun RenderRoomTopBar(
accountViewModel: AccountViewModel,
nav: INav,
onCallClick: ((String) -> Unit)? = null,
onVideoCallClick: ((String) -> Unit)? = null,
) {
if (room.users.size == 1) {
TopBarExtensibleWithBackButton(
@@ -90,6 +92,20 @@ fun RenderRoomTopBar(
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel)
if (onVideoCallClick != null) {
IconButton(
onClick = { onVideoCallClick(baseUser.pubkeyHex) },
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Videocam,
contentDescription = "Video call",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
if (onCallClick != null) {
IconButton(
onClick = { onCallClick(baseUser.pubkeyHex) },