// // ActiveCallView.swift // SimpleX (iOS) // // Created by Evgeny on 05/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // import SwiftUI import WebKit import SimpleXChat struct ActiveCallView: View { @EnvironmentObject var m: ChatModel @ObservedObject var call: Call @State private var rtcWebView: WKWebView? = nil @State private var webViewMsg: WVAPIMessage? = nil var body: some View { ZStack(alignment: .bottom) { WebRTCView(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg) .onAppear() { sendCommandToWebView() } .onChange(of: m.callCommand) { _ in sendCommandToWebView() } .onChange(of: rtcWebView) { _ in sendCommandToWebView() } .onChange(of: webViewMsg) { _ in processWebViewMessage() } .background(.black) if let call = m.activeCall, let webView = rtcWebView { ActiveCallOverlay(call: call, webView: webView) } } .preferredColorScheme(.dark) } private func sendCommandToWebView() { if m.activeCall != nil, let wv = rtcWebView, let cmd = m.callCommand { m.callCommand = nil sendCallCommand(wv, cmd) } } private func processWebViewMessage() { if let msg = webViewMsg, let call = m.activeCall, let webView = rtcWebView { logger.debug("ActiveCallView: response \(msg.resp.respType)") switch msg.resp { case let .capabilities(capabilities): let callType = CallType(media: call.localMedia, capabilities: capabilities) Task { do { try await apiSendCallInvitation(call.contact, callType) } catch { logger.error("apiSendCallInvitation \(responseError(error))") } DispatchQueue.main.async { call.callState = .invitationSent call.localCapabilities = capabilities } } case let .offer(offer, iceCandidates, capabilities): Task { do { try await apiSendCallOffer(call.contact, offer, iceCandidates, media: call.localMedia, capabilities: capabilities) } catch { logger.error("apiSendCallOffer \(responseError(error))") } DispatchQueue.main.async { call.callState = .offerSent call.localCapabilities = capabilities } } case let .answer(answer, iceCandidates): Task { do { try await apiSendCallAnswer(call.contact, answer, iceCandidates) } catch { logger.error("apiSendCallAnswer \(responseError(error))") } DispatchQueue.main.async { call.callState = .negotiated } } case let .ice(iceCandidates): Task { do { try await apiSendCallExtraInfo(call.contact, iceCandidates) } catch { logger.error("apiSendCallExtraInfo \(responseError(error))") } } case let .connection(state): if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), case .connected = callStatus { // if case .outgoing = call.direction { // CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) // } call.callState = .connected // CallKit doesn't work well with WKWebView // This is a hack to enable microphone in WKWebView after CallKit takes over it if CallController.useCallKit { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { m.callCommand = .camera(camera: call.localCamera) } } } Task { do { try await apiCallStatus(call.contact, state.connectionState) } catch { logger.error("apiCallStatus \(responseError(error))") } } case let .connected(connectionInfo): call.callState = .connected call.connectionInfo = connectionInfo case .ended: closeCallView(webView) call.callState = .ended if let uuid = call.callkitUUID { CallController.shared.endCall(callUUID: uuid) } case .ok: switch msg.command { case .answer: call.callState = .negotiated case let .camera(camera): call.localCamera = camera Task { // This disables microphone if it was disabled before flipping the camera await webView.setMicrophoneCaptureState(call.audioEnabled ? .active : .muted) // This compensates for the bug on some devices when remote video does not appear // await webView.setCameraCaptureState(.muted) // await webView.setCameraCaptureState(call.videoEnabled ? .active : .muted) } case .end: closeCallView(webView) m.activeCall = nil default: () } case let .error(message): logger.debug("ActiveCallView: command error: \(message)") case let .invalid(type): logger.debug("ActiveCallView: invalid response: \(type)") } } } private func closeCallView(_ webView: WKWebView) { m.showCallView = false Task { await webView.setMicrophoneCaptureState(.muted) await webView.setCameraCaptureState(.muted) } } } struct ActiveCallOverlay: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var call: Call var webView: WKWebView var body: some View { VStack { switch call.localMedia { case .video: callInfoView(call, .leading) .foregroundColor(.white) .opacity(0.8) .padding() Spacer() HStack { toggleAudioButton() Spacer() Color.clear.frame(width: 40, height: 40) Spacer() endCallButton() Spacer() if call.videoEnabled { flipCameraButton() } else { Color.clear.frame(width: 40, height: 40) } Spacer() toggleVideoButton() } .padding(.horizontal, 20) .padding(.bottom, 16) .frame(maxWidth: .infinity, alignment: .center) case .audio: VStack { ProfileImage(imageStr: call.contact.profile.image) .scaledToFit() .frame(width: 192, height: 192) callInfoView(call, .center) } .foregroundColor(.white) .opacity(0.8) .padding() .frame(maxHeight: .infinity) Spacer() ZStack(alignment: .bottom) { toggleAudioButton() .frame(maxWidth: .infinity, alignment: .leading) endCallButton() } .padding(.bottom, 60) .padding(.horizontal, 48) } } .frame(maxWidth: .infinity) } private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View { VStack { Text(call.contact.chatViewName) .lineLimit(1) .font(.title) .frame(maxWidth: .infinity, alignment: alignment) Group { Text(call.callState.text) HStack { Text(call.encryptionStatus) if let connInfo = call.connectionInfo { // Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))") Text("(") + Text(connInfo.text) + Text(")") } } } .font(.subheadline) .frame(maxWidth: .infinity, alignment: alignment) } } private func endCallButton() -> some View { let cc = CallController.shared return callButton("phone.down.fill", size: 60) { if let uuid = call.callkitUUID { cc.endCall(callUUID: uuid) } else { cc.endCall(call: call) {} } } .foregroundColor(.red) } private func toggleAudioButton() -> some View { controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") { Task { await webView.setMicrophoneCaptureState(call.audioEnabled ? .muted : .active) DispatchQueue.main.async { call.audioEnabled = !call.audioEnabled } } } } private func toggleVideoButton() -> some View { controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") { Task { await webView.setCameraCaptureState(call.videoEnabled ? .muted : .active) DispatchQueue.main.async { call.videoEnabled = !call.videoEnabled } } } } @ViewBuilder private func flipCameraButton() -> some View { let cmd = WCallCommand.camera(camera: call.localCamera == .user ? .environment : .user) controlButton(call, "arrow.triangle.2.circlepath") { if call.audioEnabled { chatModel.callCommand = cmd } else { Task { // Microphone has to be enabled before flipping the camera to avoid prompt for user permission when getUserMedia is called in webview await webView.setMicrophoneCaptureState(.active) DispatchQueue.main.async { chatModel.callCommand = cmd } } } } } @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View { if call.hasMedia { callButton(imageName, size: 40, perform) .foregroundColor(.white) .opacity(0.85) } else { Color.clear.frame(width: 40, height: 40) } } private func callButton(_ imageName: String, size: CGFloat, _ perform: @escaping () -> Void) -> some View { Button { perform() } label: { Image(systemName: imageName) .resizable() .scaledToFit() .frame(maxWidth: size, maxHeight: size) } } } struct ActiveCallOverlay_Previews: PreviewProvider { static var previews: some View { Group{ ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), webView: WKWebView()) .background(.black) ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), webView: WKWebView()) .background(.black) } } }