mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 20:29:53 +00:00
* call.ts: include udp stun/turn * update JS * show protocol, support TURNS * mobile: add turn via UDP, remove protocol from view * remove enums for protocol strings in ICE candidates * 0.2.3 --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
324 lines
12 KiB
Swift
324 lines
12 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|
|
}
|