feat: wire WebRTC call signaling end-to-end
Connects all call infrastructure so calls flow through the system: - EventProcessor routes unwrapped call events (offer/answer/ICE/ hangup/reject) from gift wraps to CallManager - CallController orchestrates WebRTC session lifecycle: creates PeerConnection, generates SDP offers/answers, exchanges ICE candidates via gift-wrapped events, and manages foreground service - AccountViewModel initializes CallManager + CallController and wires answer/ICE callbacks between them - Account.publishCallSignaling() publishes gift-wrapped events - DM chat top bar gets a call button (1-on-1 rooms only) that initiates a voice call and navigates to ActiveCall screen - ActiveCall route registered in AppNavigation with CallScreen https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
@@ -1686,6 +1686,11 @@ class Account(
|
|||||||
|
|
||||||
suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
|
suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
|
||||||
|
|
||||||
|
suspend fun publishCallSignaling(wrap: GiftWrapEvent) {
|
||||||
|
val relayList = computeRelayListToBroadcast(wrap)
|
||||||
|
client.publish(wrap, relayList)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updateStatus(
|
suspend fun updateStatus(
|
||||||
oldStatus: AddressableNote,
|
oldStatus: AddressableNote,
|
||||||
newStatus: String,
|
newStatus: String,
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.call
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import com.vitorpamplona.amethyst.commons.call.CallManager
|
||||||
|
import com.vitorpamplona.amethyst.commons.call.CallState
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType
|
||||||
|
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.webrtc.IceCandidate
|
||||||
|
import org.webrtc.MediaStream
|
||||||
|
import org.webrtc.SessionDescription
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class CallController(
|
||||||
|
private val context: Context,
|
||||||
|
private val callManager: CallManager,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val publishWrap: suspend (GiftWrapEvent) -> Unit,
|
||||||
|
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
|
||||||
|
) {
|
||||||
|
private var webRtcSession: WebRtcCallSession? = null
|
||||||
|
private val callFactory = WebRtcCallFactory()
|
||||||
|
private var currentCallId: String? = null
|
||||||
|
private var currentPeerPubKey: HexKey? = null
|
||||||
|
|
||||||
|
fun initiateCall(
|
||||||
|
peerPubKey: HexKey,
|
||||||
|
callType: CallType,
|
||||||
|
) {
|
||||||
|
val callId = UUID.randomUUID().toString()
|
||||||
|
currentCallId = callId
|
||||||
|
currentPeerPubKey = peerPubKey
|
||||||
|
|
||||||
|
createWebRtcSession()
|
||||||
|
webRtcSession?.addAudioTrack()
|
||||||
|
if (callType == CallType.VIDEO) {
|
||||||
|
webRtcSession?.addVideoTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
webRtcSession?.createOffer { sdp ->
|
||||||
|
scope.launch {
|
||||||
|
callManager.initiateCall(peerPubKey, callType, callId, sdp.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptIncomingCall(sdpOffer: String) {
|
||||||
|
val state = callManager.state.value
|
||||||
|
if (state !is CallState.IncomingCall) return
|
||||||
|
|
||||||
|
currentCallId = state.callId
|
||||||
|
currentPeerPubKey = state.callerPubKey
|
||||||
|
|
||||||
|
createWebRtcSession()
|
||||||
|
webRtcSession?.addAudioTrack()
|
||||||
|
if (state.callType == CallType.VIDEO) {
|
||||||
|
webRtcSession?.addVideoTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
webRtcSession?.setRemoteDescription(
|
||||||
|
SessionDescription(SessionDescription.Type.OFFER, sdpOffer),
|
||||||
|
)
|
||||||
|
|
||||||
|
webRtcSession?.createAnswer { sdp ->
|
||||||
|
scope.launch {
|
||||||
|
callManager.acceptCall(sdp.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCallAnswerReceived(sdpAnswer: String) {
|
||||||
|
webRtcSession?.setRemoteDescription(
|
||||||
|
SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onIceCandidateReceived(event: CallIceCandidateEvent) {
|
||||||
|
val json = event.candidateJson()
|
||||||
|
try {
|
||||||
|
val candidate = parseIceCandidate(json)
|
||||||
|
webRtcSession?.addIceCandidate(candidate)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Ignore malformed ICE candidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hangup() {
|
||||||
|
scope.launch { callManager.hangup() }
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cleanup() {
|
||||||
|
stopForegroundService()
|
||||||
|
webRtcSession?.dispose()
|
||||||
|
webRtcSession = null
|
||||||
|
currentCallId = null
|
||||||
|
currentPeerPubKey = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createWebRtcSession() {
|
||||||
|
val iceServers = IceServerConfig.buildIceServers()
|
||||||
|
|
||||||
|
webRtcSession =
|
||||||
|
WebRtcCallSession(
|
||||||
|
context = context,
|
||||||
|
iceServers = iceServers,
|
||||||
|
onIceCandidate = { candidate -> onLocalIceCandidate(candidate) },
|
||||||
|
onPeerConnected = {
|
||||||
|
callManager.onPeerConnected()
|
||||||
|
startForegroundService()
|
||||||
|
},
|
||||||
|
onRemoteStream = { _: MediaStream -> },
|
||||||
|
onDisconnected = {
|
||||||
|
scope.launch { callManager.hangup() }
|
||||||
|
cleanup()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
webRtcSession?.initialize()
|
||||||
|
webRtcSession?.createPeerConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLocalIceCandidate(candidate: IceCandidate) {
|
||||||
|
val callId = currentCallId ?: return
|
||||||
|
val peerPubKey = currentPeerPubKey ?: return
|
||||||
|
val candidateJson = serializeIceCandidate(candidate)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
val signer = signerProvider()
|
||||||
|
val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer)
|
||||||
|
publishWrap(result.wrap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundService() {
|
||||||
|
val intent =
|
||||||
|
Intent(context, CallForegroundService::class.java).apply {
|
||||||
|
action = CallForegroundService.ACTION_START
|
||||||
|
putExtra(CallForegroundService.EXTRA_PEER_NAME, currentPeerPubKey ?: "")
|
||||||
|
}
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopForegroundService() {
|
||||||
|
val intent =
|
||||||
|
Intent(context, CallForegroundService::class.java).apply {
|
||||||
|
action = CallForegroundService.ACTION_STOP
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun serializeIceCandidate(candidate: IceCandidate): String = """{"candidate":"${candidate.sdp}","sdpMid":"${candidate.sdpMid}","sdpMLineIndex":${candidate.sdpMLineIndex}}"""
|
||||||
|
|
||||||
|
fun parseIceCandidate(json: String): IceCandidate {
|
||||||
|
val candidateRegex = """"candidate"\s*:\s*"([^"]*)"""".toRegex()
|
||||||
|
val sdpMidRegex = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex()
|
||||||
|
val sdpMLineIndexRegex = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex()
|
||||||
|
|
||||||
|
val sdp = candidateRegex.find(json)?.groupValues?.get(1) ?: ""
|
||||||
|
val sdpMid = sdpMidRegex.find(json)?.groupValues?.get(1) ?: "0"
|
||||||
|
val sdpMLineIndex =
|
||||||
|
sdpMLineIndexRegex
|
||||||
|
.find(json)
|
||||||
|
?.groupValues
|
||||||
|
?.get(1)
|
||||||
|
?.toIntOrNull() ?: 0
|
||||||
|
|
||||||
|
return IceCandidate(sdpMid, sdpMLineIndex, sdp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp
|
|||||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||||
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||||
|
import com.vitorpamplona.amethyst.ui.call.CallScreen
|
||||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd
|
import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd
|
||||||
@@ -168,6 +169,11 @@ fun BuildNavigation(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: Nav,
|
nav: Nav,
|
||||||
) {
|
) {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
|
accountViewModel.initCallController(context)
|
||||||
|
}
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = nav.controller,
|
navController = nav.controller,
|
||||||
startDestination = Route.Home,
|
startDestination = Route.Home,
|
||||||
@@ -254,6 +260,14 @@ fun BuildNavigation(
|
|||||||
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
|
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
|
||||||
|
|
||||||
|
composableFromEndArgs<Route.ActiveCall> {
|
||||||
|
CallScreen(
|
||||||
|
callManager = accountViewModel.callManager,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onCallEnded = { nav.popBack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
composableFromEndArgs<Route.PublicChatChannel> {
|
composableFromEndArgs<Route.PublicChatChannel> {
|
||||||
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.AccountInfo
|
|||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.call.CallManager
|
||||||
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
|
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
|
||||||
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync
|
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync
|
||||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||||
@@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
|
|||||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||||
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
|
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
|
||||||
|
import com.vitorpamplona.amethyst.service.call.CallController
|
||||||
import com.vitorpamplona.amethyst.service.cashu.CashuToken
|
import com.vitorpamplona.amethyst.service.cashu.CashuToken
|
||||||
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
|
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
|
||||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||||
@@ -187,6 +189,40 @@ class AccountViewModel(
|
|||||||
val broadcastTracker = BroadcastTracker()
|
val broadcastTracker = BroadcastTracker()
|
||||||
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
||||||
|
|
||||||
|
val callManager =
|
||||||
|
CallManager(
|
||||||
|
signer = account.signer,
|
||||||
|
scope = viewModelScope,
|
||||||
|
isFollowing = { account.isFollowing(it) },
|
||||||
|
publishEvent = { wrap ->
|
||||||
|
viewModelScope.launch {
|
||||||
|
account.publishCallSignaling(wrap)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
var callController: CallController? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun initCallController(context: Context) {
|
||||||
|
if (callController != null) return
|
||||||
|
val controller =
|
||||||
|
CallController(
|
||||||
|
context = context.applicationContext,
|
||||||
|
callManager = callManager,
|
||||||
|
scope = viewModelScope,
|
||||||
|
publishWrap = { wrap -> account.publishCallSignaling(wrap) },
|
||||||
|
signerProvider = { account.signer },
|
||||||
|
)
|
||||||
|
callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) }
|
||||||
|
callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) }
|
||||||
|
callController = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
account.newNotesPreProcessor.callManager = callManager
|
||||||
|
}
|
||||||
|
|
||||||
val eventSync =
|
val eventSync =
|
||||||
EventSync(
|
EventSync(
|
||||||
accountPubKey = account.signer.pubKey,
|
accountPubKey = account.signer.pubKey,
|
||||||
|
|||||||
+20
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
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.commons.model.privateChats.ChatroomList
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
@@ -27,6 +28,12 @@ import com.vitorpamplona.amethyst.model.Note
|
|||||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.IEvent
|
import com.vitorpamplona.quartz.nip01Core.core.IEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent
|
||||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||||
@@ -42,6 +49,7 @@ import kotlinx.coroutines.CancellationException
|
|||||||
class EventProcessor(
|
class EventProcessor(
|
||||||
private val account: Account,
|
private val account: Account,
|
||||||
private val cache: LocalCache,
|
private val cache: LocalCache,
|
||||||
|
var callManager: CallManager? = null,
|
||||||
) {
|
) {
|
||||||
private val chatHandler = ChatHandler(account.chatroomList)
|
private val chatHandler = ChatHandler(account.chatroomList)
|
||||||
private val draftHandler = DraftEventHandler(account, cache)
|
private val draftHandler = DraftEventHandler(account, cache)
|
||||||
@@ -68,10 +76,22 @@ class EventProcessor(
|
|||||||
publicNote: Note,
|
publicNote: Note,
|
||||||
) {
|
) {
|
||||||
when (event) {
|
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 ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote)
|
||||||
|
|
||||||
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
|
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
|
||||||
|
|
||||||
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
|
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
|
||||||
|
|
||||||
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
|
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
|
||||||
|
|
||||||
is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote)
|
is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -46,7 +46,23 @@ fun ChatroomScreen(
|
|||||||
DisappearingScaffold(
|
DisappearingScaffold(
|
||||||
isInvertedLayout = true,
|
isInvertedLayout = true,
|
||||||
topBar = {
|
topBar = {
|
||||||
RenderRoomTopBar(roomId, accountViewModel, nav)
|
RenderRoomTopBar(
|
||||||
|
room = roomId,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
|
onCallClick = { peerPubKey ->
|
||||||
|
accountViewModel.callController?.initiateCall(
|
||||||
|
peerPubKey,
|
||||||
|
com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType.VOICE,
|
||||||
|
)
|
||||||
|
nav.nav(
|
||||||
|
com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall(
|
||||||
|
callId = "",
|
||||||
|
peerPubKey = peerPubKey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+19
@@ -25,15 +25,19 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
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.EditNote
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -69,6 +73,7 @@ fun RenderRoomTopBar(
|
|||||||
room: ChatroomKey,
|
room: ChatroomKey,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
|
onCallClick: ((String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (room.users.size == 1) {
|
if (room.users.size == 1) {
|
||||||
TopBarExtensibleWithBackButton(
|
TopBarExtensibleWithBackButton(
|
||||||
@@ -84,6 +89,20 @@ fun RenderRoomTopBar(
|
|||||||
Spacer(modifier = DoubleHorzSpacer)
|
Spacer(modifier = DoubleHorzSpacer)
|
||||||
|
|
||||||
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel)
|
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel)
|
||||||
|
|
||||||
|
if (onCallClick != null) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onCallClick(baseUser.pubkeyHex) },
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Call,
|
||||||
|
contentDescription = "Voice call",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-2
@@ -51,6 +51,9 @@ class CallManager(
|
|||||||
private val _state = MutableStateFlow<CallState>(CallState.Idle)
|
private val _state = MutableStateFlow<CallState>(CallState.Idle)
|
||||||
val state: StateFlow<CallState> = _state.asStateFlow()
|
val state: StateFlow<CallState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
|
||||||
|
var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null
|
||||||
|
|
||||||
private var timeoutJob: Job? = null
|
private var timeoutJob: Job? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -115,6 +118,7 @@ class CallManager(
|
|||||||
|
|
||||||
_state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType)
|
_state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType)
|
||||||
cancelTimeout()
|
cancelTimeout()
|
||||||
|
onAnswerReceived?.invoke(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onCallRejected(event: CallRejectEvent) {
|
fun onCallRejected(event: CallRejectEvent) {
|
||||||
@@ -127,8 +131,7 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onIceCandidate(event: CallIceCandidateEvent) {
|
fun onIceCandidate(event: CallIceCandidateEvent) {
|
||||||
// ICE candidates are handled by the WebRTC session directly.
|
onIceCandidateReceived?.invoke(event)
|
||||||
// This method exists for the call manager to validate the call-id.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPeerConnected() {
|
fun onPeerConnected() {
|
||||||
|
|||||||
Reference in New Issue
Block a user