Files
simplex-chat/apps/ios/Shared/Views/Call/ActiveCallView.swift
Stanislav Dmitrenko 950bbe19da ios: fix calls connecting state (#3475)
* ios: fix calls connecting state

* optimization

* changes

* removed relay protocol

* simplify

* use actor

* fix loop, better onChange, some questions

* remove extra iteration

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-27 22:20:51 +00:00

358 lines
13 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
@Environment(\.scenePhase) var scenePhase
@State private var client: WebRTCClient? = nil
@State private var activeCall: WebRTCClient.Call? = nil
@State private var localRendererAspectRatio: CGFloat? = nil
@Binding var canConnectCall: Bool
var body: some View {
ZStack(alignment: .bottom) {
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
GeometryReader { g in
let width = g.size.width * 0.3
ZStack(alignment: .topTrailing) {
CallViewRemote(client: client, activeCall: $activeCall)
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio)
.cornerRadius(10)
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
.padding([.top, .trailing], 17)
}
}
}
if let call = m.activeCall, let client = client {
ActiveCallOverlay(call: call, client: client)
}
}
.onAppear {
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)")
AppDelegate.keepScreenOn(true)
createWebRTCClient()
dismissAllSheets()
}
.onChange(of: canConnectCall) { _ in
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)")
createWebRTCClient()
}
.onDisappear {
logger.debug("ActiveCallView: disappear")
AppDelegate.keepScreenOn(false)
client?.endCall()
}
.onChange(of: m.callCommand) { cmd in
if let cmd = cmd {
m.callCommand = nil
sendCommandToClient(cmd)
}
}
.background(.black)
.preferredColorScheme(.dark)
}
private func createWebRTCClient() {
if client == nil && canConnectCall {
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
if let cmd = m.callCommand {
m.callCommand = nil
sendCommandToClient(cmd)
}
}
}
private func sendCommandToClient(_ cmd: WCallCommand) {
if call == m.activeCall,
m.activeCall != nil,
let client = client {
logger.debug("sendCallCommand: \(cmd.cmdType)")
Task {
await client.sendCallCommand(command: cmd)
}
}
}
@MainActor
private func processRtcMessage(msg: WVAPIMessage) {
if call == m.activeCall,
let call = m.activeCall,
let client = client {
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))")
}
await MainActor.run {
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))")
}
await MainActor.run {
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))")
}
await MainActor.run {
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 {
call.direction == .outgoing
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
call.callState = .connected
}
if state.connectionState == "closed" {
closeCallView(client)
m.activeCall = nil
}
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(client)
call.callState = .ended
if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid)
}
case .ok:
switch msg.command {
case .answer:
call.callState = .negotiated
case .end:
closeCallView(client)
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(_ client: WebRTCClient) {
if m.activeCall != nil {
m.showCallView = false
}
}
}
struct ActiveCallOverlay: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var call: Call
var client: WebRTCClient
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()
toggleSpeakerButton()
.frame(maxWidth: .infinity, alignment: .trailing)
}
.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(")")
}
}
}
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: alignment)
}
}
private func endCallButton() -> some View {
let cc = CallController.shared
return callButton("phone.down.fill", width: 60, height: 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 {
client.setAudioEnabled(!call.audioEnabled)
DispatchQueue.main.async {
call.audioEnabled = !call.audioEnabled
}
}
}
}
private func toggleSpeakerButton() -> some View {
controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
Task {
client.setSpeakerEnabledAndConfigureSession(!call.speakerEnabled)
DispatchQueue.main.async {
call.speakerEnabled = !call.speakerEnabled
}
}
}
}
private func toggleVideoButton() -> some View {
controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
Task {
client.setVideoEnabled(!call.videoEnabled)
DispatchQueue.main.async {
call.videoEnabled = !call.videoEnabled
}
}
}
}
@ViewBuilder private func flipCameraButton() -> some View {
controlButton(call, "arrow.triangle.2.circlepath") {
Task {
client.flipCamera()
}
}
}
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
if call.hasMedia {
callButton(imageName, width: 50, height: 38, perform)
.foregroundColor(.white)
.opacity(0.85)
} else {
Color.clear.frame(width: 50, height: 38)
}
}
private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View {
Button {
perform()
} label: {
Image(systemName: imageName)
.resizable()
.scaledToFit()
.frame(maxWidth: width, maxHeight: height)
}
}
}
struct ActiveCallOverlay_Previews: PreviewProvider {
static var previews: some View {
Group{
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black)
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black)
}
}
}