diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 67a8fea87..873f33b22 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -77,6 +77,7 @@ android { } jniLibs.useLegacyPackaging = rootProject.extra["compression.level"] as Int != 0 } + android.sourceSets["main"].assets.setSrcDirs(listOf("../common/src/commonMain/resources/assets")) val isRelease = gradle.startParameter.taskNames.find { it.toLowerCase().contains("release") } != null val isBundle = gradle.startParameter.taskNames.find { it.toLowerCase().contains("bundle") } != null // if (isRelease) { diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 9fb40c93d..13ca2c309 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -98,6 +98,8 @@ kotlin { implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("org.slf4j:slf4j-simple:2.0.7") implementation("uk.co.caprica:vlcj:4.7.3") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a") } } val desktopTest by getting diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 260182b5a..790345e97 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -43,6 +44,9 @@ import chat.simplex.res.MR import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -52,7 +56,7 @@ actual fun ActiveCallView() { val chatModel = ChatModel BackHandler(onBack = { val call = chatModel.activeCall.value - if (call != null) withApi { chatModel.callManager.endCall(call) } + if (call != null) withBGApi { chatModel.callManager.endCall(call) } }) val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } @@ -112,30 +116,30 @@ actual fun ActiveCallView() { if (call != null) { Log.d(TAG, "has active call $call") when (val r = apiMsg.resp) { - is WCallResponse.Capabilities -> withApi { + is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) chatModel.controller.apiSendCallInvitation(call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } - is WCallResponse.Offer -> withApi { + is WCallResponse.Offer -> withBGApi { chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } - is WCallResponse.Answer -> withApi { + is WCallResponse.Answer -> withBGApi { chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } - is WCallResponse.Ice -> withApi { + is WCallResponse.Ice -> withBGApi { chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - chatModel.activeCall.value = call.copy(callState = CallState.Connected) + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) setCallSound(call.soundSpeaker, audioViaBluetooth) } - withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } } catch (e: Error) { Log.d(TAG,"call status ${r.state.connectionState} not used") } @@ -145,9 +149,12 @@ actual fun ActiveCallView() { setCallSound(call.soundSpeaker, audioViaBluetooth) } } + is WCallResponse.End -> { + withBGApi { chatModel.callManager.endCall(call) } + } is WCallResponse.Ended -> { chatModel.activeCall.value = call.copy(callState = CallState.Ended) - withApi { chatModel.callManager.endCall(call) } + withBGApi { chatModel.callManager.endCall(call) } chatModel.showCallView.value = false } is WCallResponse.Ok -> when (val cmd = apiMsg.command) { @@ -162,7 +169,7 @@ actual fun ActiveCallView() { is WCallCommand.Camera -> { chatModel.activeCall.value = call.copy(localCamera = cmd.camera) if (!call.audioEnabled) { - chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false) + chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) } } is WCallCommand.End -> @@ -187,11 +194,14 @@ actual fun ActiveCallView() { // Lock orientation to portrait in order to have good experience with calls activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT chatModel.activeCallViewIsVisible.value = true + // After the first call, End command gets added to the list which prevents making another calls + chatModel.callCommand.removeAll { it is WCallCommand.End } onDispose { activity.volumeControlStream = prevVolumeControlStream // Unlock orientation activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED chatModel.activeCallViewIsVisible.value = false + chatModel.callCommand.clear() } } } @@ -201,9 +211,9 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot ActiveCallOverlayLayout( call = call, speakerCanBeEnabled = !audioViaBluetooth.value, - dismiss = { withApi { chatModel.callManager.endCall(call) } }, - toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) }, - toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) }, + dismiss = { withBGApi { chatModel.callManager.endCall(call) } }, + toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) }, + toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) }, toggleSound = { var call = chatModel.activeCall.value if (call != null) { @@ -212,7 +222,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot setCallSound(call.soundSpeaker, audioViaBluetooth) } }, - flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) } + flipCamera = { chatModel.callCommand.add(WCallCommand.Camera(call.localCamera.flipped)) } ) } @@ -439,7 +449,7 @@ private fun DisabledBackgroundCallsButton() { //} @Composable -fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) { +fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { val scope = rememberCoroutineScope() val webView = remember { mutableStateOf(null) } val permissionsState = rememberMultiplePermissionsState( @@ -470,13 +480,19 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa webView.value = null } } - LaunchedEffect(callCommand.value, webView.value) { - val cmd = callCommand.value - val wv = webView.value - if (cmd != null && wv != null) { - Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") - processCommand(wv, cmd) - callCommand.value = null + val wv = webView.value + if (wv != null) { + LaunchedEffect(Unit) { + snapshotFlow { callCommand.firstOrNull() } + .distinctUntilChanged() + .filterNotNull() + .collect { + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirst() + Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") + processCommand(wv, cmd) + } + } } } val assetLoader = WebViewAssetLoader.Builder() @@ -502,7 +518,7 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa } } } - this.webViewClient = LocalContentWebViewClient(assetLoader) + this.webViewClient = LocalContentWebViewClient(webView, assetLoader) this.clearHistory() this.clearCache(true) this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface") @@ -512,19 +528,10 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa webViewSettings.javaScriptEnabled = true webViewSettings.mediaPlaybackRequiresUserGesture = false webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE - this.loadUrl("file:android_asset/www/call.html") + this.loadUrl("file:android_asset/www/android/call.html") } } - ) { wv -> - Log.d(TAG, "WebRTCView: webview ready") - // for debugging - // wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) - scope.launch { - delay(2000L) - wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) - webView.value = wv - } - } + ) { /* WebView */ } } } } @@ -539,19 +546,28 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) { // for debugging // onResponse(message) onResponse(json.decodeFromString(message)) - } catch (e: Error) { + } catch (e: Exception) { Log.e(TAG, "failed parsing WebView message: $message") } } } -private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { +private class LocalContentWebViewClient(val webView: MutableState, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { return assetLoader.shouldInterceptRequest(request.url) } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) + webView.value = view + Log.d(TAG, "WebRTCView: webview ready") + // for debugging + // view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) + } } @Preview diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt new file mode 100644 index 000000000..cb74664a4 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -0,0 +1,8 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.runtime.* +import chat.simplex.common.views.helpers.* +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ad033387a..767c678c1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -88,7 +88,7 @@ object ChatModel { val activeCallInvitation = mutableStateOf(null) val activeCall = mutableStateOf(null) val activeCallViewIsVisible = mutableStateOf(false) - val callCommand = mutableStateOf(null) + val callCommand = mutableStateListOf() val showCallView = mutableStateOf(false) val switchingCall = mutableStateOf(false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index da09ea132..58850ce05 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1647,25 +1647,25 @@ object ChatController { val useRelay = appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() Log.d(TAG, ".callOffer iceServers $iceServers") - chatModel.callCommand.value = WCallCommand.Offer( + chatModel.callCommand.add(WCallCommand.Offer( offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey, iceServers = iceServers, relay = useRelay - ) + )) } } is CR.CallAnswer -> { withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived) - chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates) + chatModel.callCommand.add(WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)) } } is CR.CallExtraInfo -> { withCall(r, r.contact) { _ -> - chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates) + chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)) } } is CR.CallEnded -> { @@ -1674,7 +1674,7 @@ object ChatController { chatModel.callManager.reportCallRemoteEnded(invitation = invitation) } withCall(r, r.contact) { _ -> - chatModel.callCommand.value = WCallCommand.End + chatModel.callCommand.add(WCallCommand.End) withApi { chatModel.activeCall.value = null chatModel.showCallView.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index fe4da718a..f601776f9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -3,8 +3,6 @@ package chat.simplex.common.views.call import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.withApi -import chat.simplex.common.views.helpers.withBGApi -import chat.simplex.common.views.usersettings.showInDevelopingAlert import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.minutes @@ -26,10 +24,6 @@ class CallManager(val chatModel: ChatModel) { } fun acceptIncomingCall(invitation: RcvCallInvitation) { - if (appPlatform.isDesktop) { - return showInDevelopingAlert() - } - val call = chatModel.activeCall.value if (call == null) { justAcceptIncomingCall(invitation = invitation) @@ -58,12 +52,12 @@ class CallManager(val chatModel: ChatModel) { val useRelay = controller.appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() Log.d(TAG, "answerIncomingCall iceServers: $iceServers") - callCommand.value = WCallCommand.Start( + callCommand.add(WCallCommand.Start( media = invitation.callType.media, aesKey = invitation.sharedKey, iceServers = iceServers, relay = useRelay - ) + )) callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { activeCallInvitation.value = null @@ -80,7 +74,7 @@ class CallManager(val chatModel: ChatModel) { showCallView.value = false } else { Log.d(TAG, "CallManager.endCall: ending call...") - callCommand.value = WCallCommand.End + callCommand.add(WCallCommand.End) showCallView.value = false controller.apiEndCall(call.contact) activeCall.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 3e14414fc..4be49d4c0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -1,7 +1,5 @@ package chat.simplex.common.views.call -import androidx.compose.runtime.Composable -import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* import chat.simplex.res.MR @@ -23,16 +21,17 @@ data class Call( val videoEnabled: Boolean = localMedia == CallMediaType.Video, val soundSpeaker: Boolean = localMedia == CallMediaType.Video, var localCamera: VideoCamera = VideoCamera.User, - val connectionInfo: ConnectionInfo? = null + val connectionInfo: ConnectionInfo? = null, + var connectedAt: Instant? = null ) { val encrypted: Boolean get() = localEncrypted && sharedKey != null val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false - val encryptionStatus: String @Composable get() = when(callState) { + val encryptionStatus: String get() = when(callState) { CallState.WaitCapabilities -> "" - CallState.InvitationSent -> stringResource(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) - CallState.InvitationAccepted -> stringResource(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) - else -> stringResource(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) + CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) + CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) + else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) } val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected @@ -49,16 +48,16 @@ enum class CallState { Connected, Ended; - val text: String @Composable get() = when(this) { - WaitCapabilities -> stringResource(MR.strings.callstate_starting) - InvitationSent -> stringResource(MR.strings.callstate_waiting_for_answer) - InvitationAccepted -> stringResource(MR.strings.callstate_starting) - OfferSent -> stringResource(MR.strings.callstate_waiting_for_confirmation) - OfferReceived -> stringResource(MR.strings.callstate_received_answer) - AnswerReceived -> stringResource(MR.strings.callstate_received_confirmation) - Negotiated -> stringResource(MR.strings.callstate_connecting) - Connected -> stringResource(MR.strings.callstate_connected) - Ended -> stringResource(MR.strings.callstate_ended) + val text: String get() = when(this) { + WaitCapabilities -> generalGetString(MR.strings.callstate_starting) + InvitationSent -> generalGetString(MR.strings.callstate_waiting_for_answer) + InvitationAccepted -> generalGetString(MR.strings.callstate_starting) + OfferSent -> generalGetString(MR.strings.callstate_waiting_for_confirmation) + OfferReceived -> generalGetString(MR.strings.callstate_received_answer) + AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation) + Negotiated -> generalGetString(MR.strings.callstate_connecting) + Connected -> generalGetString(MR.strings.callstate_connected) + Ended -> generalGetString(MR.strings.callstate_ended) } } @@ -67,13 +66,14 @@ enum class CallState { @Serializable sealed class WCallCommand { - @Serializable @SerialName("capabilities") object Capabilities: WCallCommand() + @Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand() @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand() @Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() + @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @Serializable @SerialName("end") object End: WCallCommand() } @@ -85,6 +85,7 @@ sealed class WCallResponse { @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse() @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() + @Serializable @SerialName("end") object End: WCallResponse() @Serializable @SerialName("ended") object Ended: WCallResponse() @Serializable @SerialName("ok") object Ok: WCallResponse() @Serializable @SerialName("error") data class Error(val message: String): WCallResponse() @@ -106,14 +107,14 @@ sealed class WCallResponse { } @Serializable data class CallCapabilities(val encryption: Boolean) @Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) { - val text: String @Composable get() { + val text: String get() { val local = localCandidate?.candidateType val remote = remoteCandidate?.candidateType return when { local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host -> - stringResource(MR.strings.call_connection_peer_to_peer) + generalGetString(MR.strings.call_connection_peer_to_peer) local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay -> - stringResource(MR.strings.call_connection_via_relay) + generalGetString(MR.strings.call_connection_via_relay) else -> "${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}" } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 4afcdacbd..40f2b32e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -33,7 +33,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.platform.AudioPlayer -import chat.simplex.common.views.usersettings.showInDevelopingAlert import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -274,23 +273,24 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: withApi { chatModel.controller.apiJoinGroup(groupId) } }, startCall = out@ { media -> - if (appPlatform.isDesktop) { - return@out showInDevelopingAlert() - } withBGApi { val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) chatModel.showCallView.value = true - chatModel.callCommand.value = WCallCommand.Capabilities + chatModel.callCommand.add(WCallCommand.Capabilities(media)) } } }, + endCall = { + val call = chatModel.activeCall.value + if (call != null) withApi { chatModel.callManager.endCall(call) } + }, acceptCall = { contact -> hideKeyboard(view) val invitation = chatModel.callInvitations.remove(contact.id) if (invitation == null) { - AlertManager.shared.showAlertMsg("Call already ended!") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended)) } else { chatModel.callManager.acceptIncomingCall(invitation = invitation) } @@ -433,6 +433,7 @@ fun ChatLayout( cancelFile: (Long) -> Unit, joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, + endCall: () -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -491,7 +492,7 @@ fun ChatLayout( } Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, changeNtfsState, onSearchValueChanged) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, @@ -520,6 +521,7 @@ fun ChatInfoToolbar( back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, + endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, @@ -540,6 +542,7 @@ fun ChatInfoToolbar( } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val menuItems = arrayListOf<@Composable () -> Unit>() + val activeCall by remember { chatModel.activeCall } menuItems.add { ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { showMenu.value = false @@ -548,20 +551,52 @@ fun ChatInfoToolbar( } if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) { - barButtons.add { - IconButton({ - showMenu.value = false - startCall(CallMediaType.Audio) - }, - enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { - Icon( - painterResource(MR.images.ic_call_500), - stringResource(MR.strings.icon_descr_more_button), - tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) + if (activeCall == null) { + barButtons.add { + IconButton( + { + showMenu.value = false + startCall(CallMediaType.Audio) + }, + enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active + ) { + Icon( + painterResource(MR.images.ic_call_500), + stringResource(MR.strings.icon_descr_more_button), + tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } + } else if (activeCall?.contact?.id == chat.id) { + barButtons.add { + val call = remember { chatModel.activeCall }.value + val connectedAt = call?.connectedAt + if (connectedAt != null) { + val time = remember { mutableStateOf(durationText(0)) } + LaunchedEffect(Unit) { + while (true) { + time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt()) + delay(250) + } + } + val sp50 = with(LocalDensity.current) { 50.sp.toDp() } + Text(time.value, Modifier.widthIn(min = sp50)) + } + } + barButtons.add { + IconButton({ + showMenu.value = false + endCall() + }) { + Icon( + painterResource(MR.images.ic_call_end_filled), + null, + tint = MaterialTheme.colors.error + ) + } } } - if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { + if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active && activeCall == null) { menuItems.add { ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { showMenu.value = false @@ -1290,6 +1325,7 @@ fun PreviewChatLayout() { cancelFile = {}, joinGroup = {}, startCall = {}, + endCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, @@ -1359,6 +1395,7 @@ fun PreviewGroupChatLayout() { cancelFile = {}, joinGroup = {}, startCall = {}, + endCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e281c5762..e486aca4a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -3,7 +3,6 @@ package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer import SectionDividerSpaced -import SectionItemView import SectionSpacer import SectionTextFooter import SectionView @@ -35,7 +34,7 @@ import chat.simplex.common.views.newchat.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.common.views.chatlist.openChat +import chat.simplex.common.views.chatlist.openLoadedChat import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -87,7 +86,7 @@ fun GroupMemberInfoView( if (memberContact != null) { val memberChat = Chat(ChatInfo.Direct(memberContact), chatItems = arrayListOf()) chatModel.addChat(memberChat) - openChat(memberChat, chatModel) + openLoadedChat(memberChat, chatModel) closeAll() chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index 3e4dce7f4..6f165515e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -68,7 +68,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) { // sharedKey: invitation.sharedKey // ) // m.showCallView = true -// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) +// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true) // } else { // AlertManager.shared.showAlertMsg(title: "Call already ended!") // } @@ -141,7 +141,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) { // sharedKey: invitation.sharedKey // ) // m.showCallView = true -// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) +// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true) // } else { // AlertManager.shared.showAlertMsg(title: "Call already ended!") // } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index b16bf4c9b..2ff33ead5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -4,7 +4,6 @@ import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity @@ -13,10 +12,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.ui.graphics.drawscope.ContentDrawScope -import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -126,14 +121,14 @@ fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) { suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { val chat = chatModel.controller.apiGetChat(ChatType.Direct, contactId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) } } suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) { val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) } } @@ -141,12 +136,12 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") } } -suspend fun openChat(chat: Chat, chatModel: ChatModel) { +fun openLoadedChat(chat: Chat, chatModel: ChatModel) { chatModel.chatItems.clear() chatModel.chatItemStatuses.clear() chatModel.chatItems.addAll(chat.chatItems) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 66ef1cf9f..4df62e12e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString @@ -29,6 +30,9 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.common.views.usersettings.simplexTeamUri import chat.simplex.common.platform.* +import chat.simplex.common.views.call.Call +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* @@ -121,6 +125,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } } if (searchInList.isEmpty()) { + DesktopActiveCallOverlayLayout(newChatSheetState) NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { @@ -311,6 +316,9 @@ private fun ProgressIndicator() { ) } +@Composable +expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) + fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index d8466e9d9..35d5b8b3e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -50,11 +50,12 @@ class AlertManager { fun showAlertDialogButtonsColumn( title: String, text: AnnotatedString? = null, + onDismissRequest: (() -> Unit)? = null, buttons: @Composable () -> Unit, ) { showAlert { AlertDialog( - onDismissRequest = ::hideAlert, + onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = { Text( title, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 3a799eddf..abc894942 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -81,6 +81,35 @@ fun ProfileImage( } } +/** [AccountCircleFilled] has its inner padding which leads to visible border if there is background underneath. + * This is workaround + * */ +@Composable +fun ProfileImageForActiveCall( + size: Dp, + image: String? = null, + color: Color = MaterialTheme.colors.secondaryVariant, +) { + if (image == null) { + Box(Modifier.requiredSize(size).clip(CircleShape)) { + Icon( + AccountCircleFilled, + contentDescription = stringResource(MR.strings.icon_descr_profile_image_placeholder), + tint = color, + modifier = Modifier.requiredSize(size + 14.dp) + ) + } + } else { + val imageBitmap = base64ToBitmap(image) + Image( + imageBitmap, + stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).clip(CircleShape) + ) + } +} + @Preview @Composable diff --git a/apps/multiplatform/android/src/main/assets/www/README.md b/apps/multiplatform/common/src/commonMain/resources/assets/www/README.md similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/README.md rename to apps/multiplatform/common/src/commonMain/resources/assets/www/README.md diff --git a/packages/simplex-chat-webrtc/src/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html similarity index 88% rename from packages/simplex-chat-webrtc/src/call.html rename to apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html index d7b3f6cef..46910bfaf 100644 --- a/packages/simplex-chat-webrtc/src/call.html +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html @@ -3,7 +3,7 @@ - +
- +
diff --git a/apps/multiplatform/android/src/main/assets/www/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/style.css rename to apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css diff --git a/apps/multiplatform/android/src/main/assets/www/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.html similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/call.html rename to apps/multiplatform/common/src/commonMain/resources/assets/www/call.html diff --git a/apps/multiplatform/android/src/main/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js similarity index 92% rename from apps/multiplatform/android/src/main/assets/www/call.js rename to apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index c7cf4a932..fd574d022 100644 --- a/apps/multiplatform/android/src/main/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -23,6 +23,9 @@ var TransformOperation; })(TransformOperation || (TransformOperation = {})); let activeCall; let answerTimeout = 30000; +var useWorker = false; +var localizedState = ""; +var localizedDescription = ""; const processCommand = (function () { const defaultIceServers = [ { urls: ["stun:stun.simplex.im:443"] }, @@ -38,9 +41,9 @@ const processCommand = (function () { iceTransportPolicy: relay ? "relay" : "all", }, iceCandidates: { - delay: 3000, - extrasInterval: 2000, - extrasTimeout: 8000, + delay: 750, + extrasInterval: 1500, + extrasTimeout: 12000, }, }; } @@ -81,6 +84,10 @@ const processCommand = (function () { if (delay) clearTimeout(delay); resolved = true; + console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)); + //const ipv6Elem = candidates.find((item) => item.candidate.includes("raddr ::")) + //candidates = ipv6Elem != undefined ? candidates.filter((elem) => elem == ipv6Elem) : candidates + //console.log("LALAL resolveIceCandidates2", JSON.stringify(candidates)) const iceCandidates = serialize(candidates); candidates = []; resolve(iceCandidates); @@ -88,19 +95,20 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return; + console.log("LALAL sendIceCandidates", JSON.stringify(candidates)); const iceCandidates = serialize(candidates); candidates = []; sendMessageToNative({ resp: { type: "ice", iceCandidates } }); } }); } - async function initializeCall(config, mediaType, aesKey, useWorker) { + async function initializeCall(config, mediaType, aesKey) { const pc = new RTCPeerConnection(config.peerConnectionConfig); const remoteStream = new MediaStream(); const localCamera = VideoCamera.User; const localStream = await getLocalMediaStream(mediaType, localCamera); const iceCandidates = getIceCandidates(pc, config); - const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker }; + const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey }; await setupMediaStreams(call); let connectionTimeout = setTimeout(connectionHandler, answerTimeout); pc.addEventListener("connectionstatechange", connectionStateChange); @@ -178,17 +186,17 @@ const processCommand = (function () { // This request for local media stream is made to prompt for camera/mic permissions on call start if (command.media) await getLocalMediaStream(command.media, VideoCamera.User); - const encryption = supportsInsertableStreams(command.useWorker); + const encryption = supportsInsertableStreams(useWorker); resp = { type: "capabilities", capabilities: { encryption } }; break; case "start": { console.log("starting incoming call - create webrtc session"); if (activeCall) endCall(); - const { media, useWorker, iceServers, relay } = command; + const { media, iceServers, relay } = command; const encryption = supportsInsertableStreams(useWorker); const aesKey = encryption ? command.aesKey : undefined; - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker); + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); @@ -202,7 +210,6 @@ const processCommand = (function () { // iceServers, // relay, // aesKey, - // useWorker, // } resp = { type: "offer", @@ -210,21 +217,23 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: { encryption }, }; + console.log("LALALs", JSON.stringify(resp)); break; } case "offer": if (activeCall) { resp = { type: "error", message: "accept: call already started" }; } - else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + else if (!supportsInsertableStreams(useWorker) && command.aesKey) { resp = { type: "error", message: "accept: encryption is not supported" }; } else { const offer = parse(command.offer); const remoteIceCandidates = parse(command.iceCandidates); - const { media, aesKey, useWorker, iceServers, relay } = command; - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker); + const { media, aesKey, iceServers, relay } = command; + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; + console.log("LALALo", JSON.stringify(remoteIceCandidates)); await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); @@ -236,6 +245,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, }; } + console.log("LALALo", JSON.stringify(resp)); break; case "answer": if (!pc) { @@ -250,6 +260,7 @@ const processCommand = (function () { else { const answer = parse(command.answer); const remoteIceCandidates = parse(command.iceCandidates); + console.log("LALALa", JSON.stringify(remoteIceCandidates)); await pc.setRemoteDescription(new RTCSessionDescription(answer)); addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; @@ -286,6 +297,11 @@ const processCommand = (function () { resp = { type: "ok" }; } break; + case "description": + localizedState = command.state; + localizedDescription = command.description; + resp = { type: "ok" }; + break; case "end": endCall(); resp = { type: "ok" }; @@ -310,12 +326,14 @@ const processCommand = (function () { catch (e) { console.log(e); } + shutdownCameraAndMic(); activeCall = undefined; resetVideoElements(); } function addIceCandidates(conn, iceCandidates) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)); + console.log("LALAL addIceCandidates", JSON.stringify(c)); } } async function setupMediaStreams(call) { @@ -335,7 +353,7 @@ const processCommand = (function () { if (call.aesKey) { if (!call.key) call.key = await callCrypto.decodeAesKey(call.aesKey); - if (call.useWorker && !call.worker) { + if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`; call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }))); call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message })); @@ -479,6 +497,11 @@ const processCommand = (function () { return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) || (!!useWorker && "RTCRtpScriptTransform" in window)); } + function shutdownCameraAndMic() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localStream) { + activeCall.localStream.getTracks().forEach((track) => track.stop()); + } + } function resetVideoElements() { const videos = getVideoElements(); if (!videos) @@ -507,6 +530,15 @@ const processCommand = (function () { } return processCommand; })(); +function toggleMedia(s, media) { + let res = false; + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks(); + for (const t of tracks) { + t.enabled = !t.enabled; + res = t.enabled; + } + return res; +} // Cryptography function - it is loaded both in the main window and in worker context (if the worker is used) function callCryptoFunction() { const initialPlainTextRequired = { diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html new file mode 100644 index 000000000..5e945ffe6 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html @@ -0,0 +1,50 @@ + + + + SimpleX Chat WebRTC call + + + + + + + +
+
+

+

+
+
+ +
+

+ + + + +

+ +
+ + +
+ diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg new file mode 100644 index 000000000..34c409818 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg new file mode 100644 index 000000000..afebf258d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg new file mode 100644 index 000000000..941dc182a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg new file mode 100644 index 000000000..43cfd7cb9 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg new file mode 100644 index 000000000..def80d471 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg new file mode 100644 index 000000000..07557e277 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg new file mode 100644 index 000000000..19999e82a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg new file mode 100644 index 000000000..2857a913f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css new file mode 100644 index 000000000..24c31fa6f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css @@ -0,0 +1,127 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 20%; + max-width: 20%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} + +#manage-call { + position: absolute; + width: fit-content; + top: 90%; + left: 50%; + transform: translate(-50%, 0); + display: grid; + grid-auto-flow: column; + grid-column-gap: 30px; +} + +#manage-call button { + border: none; + cursor: pointer; + appearance: none; + background-color: inherit; +} + +#progress { + position: absolute; + left: 50%; + top: 50%; + margin-left: -52px; + margin-top: -52px; + border-radius: 50%; + border-top: 5px solid white; + border-right: 5px solid white; + border-bottom: 5px solid white; + border-left: 5px solid black; + width: 100px; + height: 100px; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#info-block { + position: absolute; + color: white; + line-height: 10px; + opacity: 0.8; + width: 200px; + font-family: Arial, Helvetica, sans-serif; +} + +#info-block.audio { + text-align: center; + left: 50%; + top: 50%; + margin-left: -100px; + margin-top: 100px; +} + +#info-block.video { + left: 16px; + top: 2px; +} + +#audio-call-icon { + position: absolute; + display: none; + left: 50%; + top: 50%; + margin-left: -50px; + margin-top: -44px; + width: 100px; + height: 100px; +} diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js new file mode 100644 index 000000000..73c33ae91 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -0,0 +1,80 @@ +"use strict"; +// Override defaults to enable worker on Chrome and Safari +useWorker = window.safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1; +// Create WebSocket connection. +const socket = new WebSocket(`ws://${location.host}`); +socket.addEventListener("open", (_event) => { + console.log("Opened socket"); + sendMessageToNative = (msg) => { + console.log("Message to server: ", msg); + socket.send(JSON.stringify(msg)); + }; +}); +socket.addEventListener("message", (event) => { + const parsed = JSON.parse(event.data); + reactOnMessageFromServer(parsed); + processCommand(parsed); + console.log("Message from server: ", event.data); +}); +socket.addEventListener("close", (_event) => { + console.log("Closed socket"); + sendMessageToNative = (_msg) => { + console.log("Tried to send message to native but the socket was closed already"); + }; + window.close(); +}); +function endCallManually() { + sendMessageToNative({ resp: { type: "end" } }); +} +function toggleAudioManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) { + document.getElementById("toggle-audio").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio) + ? '' + : ''; + } +} +function toggleSpeakerManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.remoteStream) { + document.getElementById("toggle-speaker").innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio) + ? '' + : ''; + } +} +function toggleVideoManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) { + document.getElementById("toggle-video").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Video) + ? '' + : ''; + } +} +function reactOnMessageFromServer(msg) { + var _a; + switch ((_a = msg.command) === null || _a === void 0 ? void 0 : _a.type) { + case "capabilities": + document.getElementById("info-block").className = msg.command.media; + break; + case "offer": + case "start": + document.getElementById("toggle-audio").style.display = "inline-block"; + document.getElementById("toggle-speaker").style.display = "inline-block"; + if (msg.command.media == "video") { + document.getElementById("toggle-video").style.display = "inline-block"; + } + document.getElementById("info-block").className = msg.command.media; + break; + case "description": + updateCallInfoView(msg.command.state, msg.command.description); + if ((activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection.connectionState) == "connected") { + document.getElementById("progress").style.display = "none"; + if (document.getElementById("info-block").className == CallMediaType.Audio) { + document.getElementById("audio-call-icon").style.display = "block"; + } + } + break; + } +} +function updateCallInfoView(state, description) { + document.getElementById("state").innerText = state; + document.getElementById("description").innerText = description; +} +//# sourceMappingURL=ui.js.map \ No newline at end of file diff --git a/apps/multiplatform/android/src/main/assets/www/lz-string.min.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/lz-string.min.js rename to apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js diff --git a/packages/simplex-chat-webrtc/src/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/style.css similarity index 100% rename from packages/simplex-chat-webrtc/src/style.css rename to apps/multiplatform/common/src/commonMain/resources/assets/www/style.css diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 4439680c6..25fc9ec8d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -1,16 +1,15 @@ package chat.simplex.common.platform -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.* import chat.simplex.common.model.* -import chat.simplex.common.views.helpers.AlertManager -import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.State import uk.co.caprica.vlcj.player.component.AudioPlayerComponent import java.io.File +import java.util.* import kotlin.math.max actual class RecorderNative: RecorderInterface { @@ -38,7 +37,7 @@ actual object AudioPlayer: AudioPlayerInterface { // Returns real duration of the track private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { - val absoluteFilePath = getAppFilePath(fileSource.filePath) + val absoluteFilePath = if (fileSource.isAbsolutePath) fileSource.filePath else getAppFilePath(fileSource.filePath) if (!File(absoluteFilePath).exists()) { Log.e(TAG, "No such file: ${fileSource.filePath}") return null @@ -208,6 +207,25 @@ val MediaPlayer.duration: Int get() = media().info().duration().toInt() actual object SoundPlayer: SoundPlayerInterface { - override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ } - override fun stop() { /*LALAL*/ } + var playing = false + + override fun start(scope: CoroutineScope, sound: Boolean) { + withBGApi { + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + SoundPlayer::class.java.getResource("/media/ring_once.mp3").openStream()!!.use { it.copyTo(tmpFile.outputStream()) } + playing = true + while (playing) { + if (sound) { + AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true) + } + delay(3500) + } + } + } + + override fun stop() { + playing = false + AudioPlayer.stop() + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index df45a8acb..42def0c75 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -1,8 +1,243 @@ package chat.simplex.common.views.call -import androidx.compose.runtime.Composable +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.nanohttpd.protocols.http.IHTTPSession +import org.nanohttpd.protocols.http.response.Response +import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse +import org.nanohttpd.protocols.http.response.Status +import org.nanohttpd.protocols.websockets.* +import java.io.IOException +import java.net.URI + +private const val SERVER_HOST = "localhost" +private const val SERVER_PORT = 50395 +val connections = ArrayList() @Composable actual fun ActiveCallView() { - // LALAL + val endCall = { + val call = chatModel.activeCall.value + if (call != null) withBGApi { chatModel.callManager.endCall(call) } + } + BackHandler(onBack = endCall) + WebRTCController(chatModel.callCommand) { apiMsg -> + Log.d(TAG, "received from WebRTCController: $apiMsg") + val call = chatModel.activeCall.value + if (call != null) { + Log.d(TAG, "has active call $call") + when (val r = apiMsg.resp) { + is WCallResponse.Capabilities -> withBGApi { + val callType = CallType(call.localMedia, r.capabilities) + chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) + } + is WCallResponse.Offer -> withBGApi { + chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) + } + is WCallResponse.Answer -> withBGApi { + chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + } + is WCallResponse.Ice -> withBGApi { + chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + } + is WCallResponse.Connection -> + try { + val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") + if (callStatus == WebRTCCallStatus.Connected) { + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) + } + withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + } catch (e: Error) { + Log.d(TAG, "call status ${r.state.connectionState} not used") + } + is WCallResponse.Connected -> { + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) + } + is WCallResponse.End -> { + withBGApi { chatModel.callManager.endCall(call) } + } + is WCallResponse.Ended -> { + chatModel.activeCall.value = call.copy(callState = CallState.Ended) + withBGApi { chatModel.callManager.endCall(call) } + chatModel.showCallView.value = false + } + is WCallResponse.Ok -> when (val cmd = apiMsg.command) { + is WCallCommand.Answer -> + chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + is WCallCommand.Media -> { + when (cmd.media) { + CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable) + CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable) + } + } + is WCallCommand.Camera -> { + chatModel.activeCall.value = call.copy(localCamera = cmd.camera) + if (!call.audioEnabled) { + chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) + } + } + is WCallCommand.End -> + chatModel.showCallView.value = false + else -> {} + } + is WCallResponse.Error -> { + Log.e(TAG, "ActiveCallView: command error ${r.message}") + } + } + } + } + + SendStateUpdates() + DisposableEffect(Unit) { + chatModel.activeCallViewIsVisible.value = true + // After the first call, End command gets added to the list which prevents making another calls + chatModel.callCommand.removeAll { it is WCallCommand.End } + onDispose { + chatModel.activeCallViewIsVisible.value = false + chatModel.callCommand.clear() + } + } +} + +@Composable +private fun SendStateUpdates() { + LaunchedEffect(Unit) { + snapshotFlow { chatModel.activeCall.value } + .distinctUntilChanged() + .filterNotNull() + .collect { call -> + val state = call.callState.text + val connInfo = call.connectionInfo + // val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" + val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" + val description = call.encryptionStatus + connInfoText + chatModel.callCommand.add(WCallCommand.Description(state, description)) + } + } +} + +@Composable +fun WebRTCController(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { + val uriHandler = LocalUriHandler.current + val server = remember { + uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/") + startServer(onResponse) + } + fun processCommand(cmd: WCallCommand) { + val apiCall = WVAPICall(command = cmd) + for (connection in connections.toList()) { + try { + connection.send(json.encodeToString(apiCall)) + break + } catch (e: Exception) { + Log.e(TAG, "Failed to send message to browser: ${e.stackTraceToString()}") + } + } + } + DisposableEffect(Unit) { + onDispose { + processCommand(WCallCommand.End) + server.stop() + connections.clear() + } + } + LaunchedEffect(Unit) { + snapshotFlow { callCommand.firstOrNull() } + .distinctUntilChanged() + .filterNotNull() + .collect { + while (connections.isEmpty()) { + delay(100) + } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirst() + Log.d(TAG, "WebRTCController LaunchedEffect executing $cmd") + processCommand(cmd) + } + } + } +} + +fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { + val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { + override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) + + @Suppress("NewApi") + fun resourcesToResponse(path: String): Response { + val uri = Class.forName("chat.simplex.common.AppKt").getResource("/assets/www$path") ?: return resourceNotFound + val response = newFixedLengthResponse( + Status.OK, getMimeTypeForFile(uri.file), + uri.openStream().readAllBytes() + ) + response.setKeepAlive(true) + response.setUseGzip(true) + return response + } + + val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found") + + override fun handle(session: IHTTPSession): Response { + return when { + session.headers["upgrade"] == "websocket" -> super.handle(session) + session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html") + else -> resourcesToResponse(URI.create(session.uri).path) + } + } + } + server.start(60_000_000) + return server +} + +class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) { + override fun onOpen() { + connections.add(this) + } + + override fun onClose(closeCode: CloseCode?, reason: String?, initiatedByRemote: Boolean) { + onResponse(WVAPIMessage(null, WCallResponse.End)) + } + + override fun onMessage(message: WebSocketFrame) { + Log.d(TAG, "MyWebSocket.onMessage") + try { + // for debugging + // onResponse(message.textPayload) + onResponse(json.decodeFromString(message.textPayload)) + } catch (e: Exception) { + Log.e(TAG, "failed parsing browser message: $message") + } + } + + override fun onPong(pong: WebSocketFrame?) = Unit + + override fun onException(exception: IOException) { + Log.e(TAG, "WebSocket exception: ${exception.stackTraceToString()}") + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 0ad69d1c0..2b646f0e4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.unit.dp import chat.simplex.common.views.helpers.* -private object NoIndication : Indication { +object NoIndication : Indication { private object NoIndicationInstance : IndicationInstance { override fun ContentDrawScope.drawIndication() { drawContent() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt new file mode 100644 index 000000000..d2fa97f86 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -0,0 +1,75 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) { + val call = remember { chatModel.activeCall}.value + // if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) { + if (call != null && !newChatSheetState.collectAsState().value.isVisible()) { + val showMenu = remember { mutableStateOf(false) } + val media = call.peerMedia ?: call.localMedia + CompositionLocalProvider( + LocalIndication provides NoIndication + ) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.BottomEnd + ) { + Box( + Modifier + .padding(end = 71.dp, bottom = 92.dp) + .size(67.dp) + .combinedClickable(onClick = { + val chat = chatModel.getChat(call.contact.id) + if (chat != null) { + withApi { + openChat(chat.chatInfo, chatModel) + } + } + }, + onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true }, + contentAlignment = Alignment.Center + ) { + Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) { + ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image) + } + Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) { + if (media == CallMediaType.Video) { + Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White) + } else { + Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White) + } + } + DefaultDropdownMenu(showMenu) { + ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = { + withBGApi { chatModel.callManager.endCall(call) } + showMenu.value = false + }) + } + } + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 b/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 new file mode 100644 index 000000000..958327733 Binary files /dev/null and b/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 differ diff --git a/packages/simplex-chat-webrtc/copy b/packages/simplex-chat-webrtc/copy index 4991cdef4..770547b2c 100755 --- a/packages/simplex-chat-webrtc/copy +++ b/packages/simplex-chat-webrtc/copy @@ -1,14 +1,24 @@ #!/bin/sh # it can be tested in the browser from dist folder -cp ./src/call.html ./dist/call.html -cp ./src/style.css ./dist/style.css +mkdir -p dist/{android,desktop,desktop/images} 2>/dev/null +cp ./src/android/call.html ./dist/android/call.html +cp ./src/android/style.css ./dist/android/style.css +cp ./src/desktop/call.html ./dist/desktop/call.html +cp ./src/desktop/style.css ./dist/desktop/style.css +cp ./src/desktop/images/* ./dist/desktop/images/ cp ./node_modules/lz-string/libs/lz-string.min.js ./dist/lz-string.min.js cp ./src/webcall.html ./dist/webcall.html cp ./src/ui.js ./dist/ui.js -# copy to android app -cp ./src/call.html ../../apps/multiplatform/android/src/main/assets/www/call.html -cp ./src/style.css ../../apps/multiplatform/android/src/main/assets/www/style.css -cp ./dist/call.js ../../apps/multiplatform/android/src/main/assets/www/call.js -cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/multiplatform/android/src/main/assets/www/lz-string.min.js +# copy to android and desktop apps +mkdir -p ../../apps/multiplatform/common/src/commonMain/resources/assets/www/{android,desktop,desktop/images} 2>/dev/null +cp ./src/android/call.html ../../apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html +cp ./src/android/style.css ../../apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css +cp ./src/desktop/call.html ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html +cp ./src/desktop/style.css ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css +cp ./src/desktop/images/* ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ + +cp ./dist/desktop/ui.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +cp ./dist/call.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index d1fc60b5a..f11ea3634 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -40,4 +40,4 @@ "dependencies": { "lz-string": "^1.4.4" } -} \ No newline at end of file +} diff --git a/packages/simplex-chat-webrtc/src/android/call.html b/packages/simplex-chat-webrtc/src/android/call.html new file mode 100644 index 000000000..46910bfaf --- /dev/null +++ b/packages/simplex-chat-webrtc/src/android/call.html @@ -0,0 +1,26 @@ + + + + + + + + + + + +
+ +
+ diff --git a/packages/simplex-chat-webrtc/src/android/style.css b/packages/simplex-chat-webrtc/src/android/style.css new file mode 100644 index 000000000..3d2941c71 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/android/style.css @@ -0,0 +1,41 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 7b0b51ea6..a6f036eb3 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -15,6 +15,7 @@ type WCallCommand = | WCallIceCandidates | WCEnableMedia | WCToggleCamera + | WCDescription | WCEndCall type WCallResponse = @@ -24,14 +25,15 @@ type WCallResponse = | WCallIceCandidates | WRConnection | WRCallConnected + | WRCallEnd | WRCallEnded | WROk | WRError | WCAcceptOffer -type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "end" +type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "end" -type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "ended" | "ok" | "error" +type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error" enum CallMediaType { Audio = "audio", @@ -53,15 +55,13 @@ interface IWCallResponse { interface WCCapabilities extends IWCallCommand { type: "capabilities" - media?: CallMediaType - useWorker?: boolean + media: CallMediaType } interface WCStartCall extends IWCallCommand { type: "start" media: CallMediaType aesKey?: string - useWorker?: boolean iceServers?: RTCIceServer[] relay?: boolean } @@ -76,7 +76,6 @@ interface WCAcceptOffer extends IWCallCommand { iceCandidates: string // JSON strings for RTCIceCandidateInit media: CallMediaType aesKey?: string - useWorker?: boolean iceServers?: RTCIceServer[] relay?: boolean } @@ -110,6 +109,12 @@ interface WCToggleCamera extends IWCallCommand { camera: VideoCamera } +interface WCDescription extends IWCallCommand { + type: "description" + state: string + description: string +} + interface WRCapabilities extends IWCallResponse { type: "capabilities" capabilities: CallCapabilities @@ -134,6 +139,10 @@ interface WRCallConnected extends IWCallResponse { connectionInfo: ConnectionInfo } +interface WRCallEnd extends IWCallResponse { + type: "end" +} + interface WRCallEnded extends IWCallResponse { type: "ended" } @@ -185,13 +194,15 @@ interface Call { localStream: MediaStream remoteStream: MediaStream aesKey?: string - useWorker?: boolean worker?: Worker key?: CryptoKey } let activeCall: Call | undefined let answerTimeout = 30_000 +var useWorker = false +var localizedState = "" +var localizedDescription = "" const processCommand = (function () { type RTCRtpSenderWithEncryption = RTCRtpSender & { @@ -232,9 +243,9 @@ const processCommand = (function () { iceTransportPolicy: relay ? "relay" : "all", }, iceCandidates: { - delay: 3000, - extrasInterval: 2000, - extrasTimeout: 8000, + delay: 750, + extrasInterval: 1500, + extrasTimeout: 12000, }, } } @@ -274,6 +285,7 @@ const processCommand = (function () { function resolveIceCandidates() { if (delay) clearTimeout(delay) resolved = true + console.log("LALAL resolveIceCandidates", JSON.stringify(candidates)) const iceCandidates = serialize(candidates) candidates = [] resolve(iceCandidates) @@ -281,6 +293,7 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return + console.log("LALAL sendIceCandidates", JSON.stringify(candidates)) const iceCandidates = serialize(candidates) candidates = [] sendMessageToNative({resp: {type: "ice", iceCandidates}}) @@ -288,13 +301,13 @@ const processCommand = (function () { }) } - async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string, useWorker?: boolean): Promise { + async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { const pc = new RTCPeerConnection(config.peerConnectionConfig) const remoteStream = new MediaStream() const localCamera = VideoCamera.User const localStream = await getLocalMediaStream(mediaType, localCamera) const iceCandidates = getIceCandidates(pc, config) - const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker} + const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey} await setupMediaStreams(call) let connectionTimeout: number | undefined = setTimeout(connectionHandler, answerTimeout) pc.addEventListener("connectionstatechange", connectionStateChange) @@ -374,16 +387,16 @@ const processCommand = (function () { if (activeCall) endCall() // This request for local media stream is made to prompt for camera/mic permissions on call start if (command.media) await getLocalMediaStream(command.media, VideoCamera.User) - const encryption = supportsInsertableStreams(command.useWorker) + const encryption = supportsInsertableStreams(useWorker) resp = {type: "capabilities", capabilities: {encryption}} break case "start": { console.log("starting incoming call - create webrtc session") if (activeCall) endCall() - const {media, useWorker, iceServers, relay} = command + const {media, iceServers, relay} = command const encryption = supportsInsertableStreams(useWorker) const aesKey = encryption ? command.aesKey : undefined - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker) + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection const offer = await pc.createOffer() await pc.setLocalDescription(offer) @@ -397,7 +410,6 @@ const processCommand = (function () { // iceServers, // relay, // aesKey, - // useWorker, // } resp = { type: "offer", @@ -405,19 +417,21 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: {encryption}, } + console.log("LALALs", JSON.stringify(resp)) break } case "offer": if (activeCall) { resp = {type: "error", message: "accept: call already started"} - } else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + } else if (!supportsInsertableStreams(useWorker) && command.aesKey) { resp = {type: "error", message: "accept: encryption is not supported"} } else { const offer: RTCSessionDescriptionInit = parse(command.offer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - const {media, aesKey, useWorker, iceServers, relay} = command - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker) + const {media, aesKey, iceServers, relay} = command + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection + console.log("LALALo", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(offer)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) @@ -429,6 +443,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, } } + console.log("LALALo", JSON.stringify(resp)) break case "answer": if (!pc) { @@ -440,6 +455,7 @@ const processCommand = (function () { } else { const answer: RTCSessionDescriptionInit = parse(command.answer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) + console.log("LALALa", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(answer)) addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} @@ -472,6 +488,11 @@ const processCommand = (function () { resp = {type: "ok"} } break + case "description": + localizedState = command.state + localizedDescription = command.description + resp = {type: "ok"} + break case "end": endCall() resp = {type: "ok"} @@ -494,6 +515,7 @@ const processCommand = (function () { } catch (e) { console.log(e) } + shutdownCameraAndMic() activeCall = undefined resetVideoElements() } @@ -501,6 +523,7 @@ const processCommand = (function () { function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)) + console.log("LALAL addIceCandidates", JSON.stringify(c)) } } @@ -520,7 +543,7 @@ const processCommand = (function () { async function setupEncryptionWorker(call: Call) { if (call.aesKey) { if (!call.key) call.key = await callCrypto.decodeAesKey(call.aesKey) - if (call.useWorker && !call.worker) { + if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()` call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], {type: "text/javascript"}))) call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => @@ -680,6 +703,12 @@ const processCommand = (function () { remote: HTMLMediaElement } + function shutdownCameraAndMic() { + if (activeCall?.localStream) { + activeCall.localStream.getTracks().forEach((track) => track.stop()) + } + } + function resetVideoElements() { const videos = getVideoElements() if (!videos) return @@ -706,10 +735,19 @@ const processCommand = (function () { const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() for (const t of tracks) t.enabled = enable } - return processCommand })() +function toggleMedia(s: MediaStream, media: CallMediaType): boolean { + let res = false + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() + for (const t of tracks) { + t.enabled = !t.enabled + res = t.enabled + } + return res +} + type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise interface CallCrypto { diff --git a/packages/simplex-chat-webrtc/src/desktop/call.html b/packages/simplex-chat-webrtc/src/desktop/call.html new file mode 100644 index 000000000..5e945ffe6 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/call.html @@ -0,0 +1,50 @@ + + + + SimpleX Chat WebRTC call + + + + + + + +
+
+

+

+
+
+ +
+

+ + + + +

+ +
+ + +
+ diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg new file mode 100644 index 000000000..34c409818 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg new file mode 100644 index 000000000..afebf258d --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg new file mode 100644 index 000000000..941dc182a --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg new file mode 100644 index 000000000..43cfd7cb9 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg new file mode 100644 index 000000000..def80d471 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg new file mode 100644 index 000000000..07557e277 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg new file mode 100644 index 000000000..19999e82a --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg new file mode 100644 index 000000000..2857a913f --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/style.css b/packages/simplex-chat-webrtc/src/desktop/style.css new file mode 100644 index 000000000..24c31fa6f --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/style.css @@ -0,0 +1,127 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 20%; + max-width: 20%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} + +#manage-call { + position: absolute; + width: fit-content; + top: 90%; + left: 50%; + transform: translate(-50%, 0); + display: grid; + grid-auto-flow: column; + grid-column-gap: 30px; +} + +#manage-call button { + border: none; + cursor: pointer; + appearance: none; + background-color: inherit; +} + +#progress { + position: absolute; + left: 50%; + top: 50%; + margin-left: -52px; + margin-top: -52px; + border-radius: 50%; + border-top: 5px solid white; + border-right: 5px solid white; + border-bottom: 5px solid white; + border-left: 5px solid black; + width: 100px; + height: 100px; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#info-block { + position: absolute; + color: white; + line-height: 10px; + opacity: 0.8; + width: 200px; + font-family: Arial, Helvetica, sans-serif; +} + +#info-block.audio { + text-align: center; + left: 50%; + top: 50%; + margin-left: -100px; + margin-top: 100px; +} + +#info-block.video { + left: 16px; + top: 2px; +} + +#audio-call-icon { + position: absolute; + display: none; + left: 50%; + top: 50%; + margin-left: -50px; + margin-top: -44px; + width: 100px; + height: 100px; +} diff --git a/packages/simplex-chat-webrtc/src/desktop/ui.ts b/packages/simplex-chat-webrtc/src/desktop/ui.ts new file mode 100644 index 000000000..ea681b4eb --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/ui.ts @@ -0,0 +1,87 @@ +// Override defaults to enable worker on Chrome and Safari +useWorker = (window as any).safari !== undefined || navigator.userAgent.indexOf("Chrome") != -1 + +// Create WebSocket connection. +const socket = new WebSocket(`ws://${location.host}`) + +socket.addEventListener("open", (_event) => { + console.log("Opened socket") + sendMessageToNative = (msg: WVApiMessage) => { + console.log("Message to server: ", msg) + socket.send(JSON.stringify(msg)) + } +}) + +socket.addEventListener("message", (event) => { + const parsed = JSON.parse(event.data) + reactOnMessageFromServer(parsed) + processCommand(parsed) + console.log("Message from server: ", event.data) +}) + +socket.addEventListener("close", (_event) => { + console.log("Closed socket") + sendMessageToNative = (_msg: WVApiMessage) => { + console.log("Tried to send message to native but the socket was closed already") + } + window.close() +}) + +function endCallManually() { + sendMessageToNative({resp: {type: "end"}}) +} + +function toggleAudioManually() { + if (activeCall?.localMedia) { + document.getElementById("toggle-audio")!!.innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio) + ? '' + : '' + } +} + +function toggleSpeakerManually() { + if (activeCall?.remoteStream) { + document.getElementById("toggle-speaker")!!.innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio) + ? '' + : '' + } +} + +function toggleVideoManually() { + if (activeCall?.localMedia) { + document.getElementById("toggle-video")!!.innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Video) + ? '' + : '' + } +} + +function reactOnMessageFromServer(msg: WVApiMessage) { + switch (msg.command?.type) { + case "capabilities": + document.getElementById("info-block")!!.className = msg.command.media + break + case "offer": + case "start": + document.getElementById("toggle-audio")!!.style.display = "inline-block" + document.getElementById("toggle-speaker")!!.style.display = "inline-block" + if (msg.command.media == "video") { + document.getElementById("toggle-video")!!.style.display = "inline-block" + } + document.getElementById("info-block")!!.className = msg.command.media + break + case "description": + updateCallInfoView(msg.command.state, msg.command.description) + if (activeCall?.connection.connectionState == "connected") { + document.getElementById("progress")!.style.display = "none" + if (document.getElementById("info-block")!!.className == CallMediaType.Audio) { + document.getElementById("audio-call-icon")!.style.display = "block" + } + } + break + } +} + +function updateCallInfoView(state: string, description: string) { + document.getElementById("state")!!.innerText = state + document.getElementById("description")!!.innerText = description +}