From 4e964d4546aa3ba607f93c59e15976c3db1b7889 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Fri, 6 Dec 2024 22:15:52 +0800 Subject: [PATCH] Various improvements --- .../Sources/PresentationCallManager.swift | 8 +- .../Sources/DebugController.swift | 18 +- .../Sources/CallController.swift | 320 ++- .../Sources/CallControllerNode.swift | 1842 ----------------- .../Sources/CallControllerNodeV2.swift | 45 +- .../Sources/PresentationCall.swift | 12 +- .../Sources/Network/FetchV2.swift | 194 +- .../Sources/Network/MultipartFetch.swift | 2 +- .../Components/Calls/CallScreen/BUILD | 1 + .../Sources/ShareExtensionContext.swift | 5 + .../Sources/SharedAccountContext.swift | 108 +- .../Sources/ExperimentalUISettings.swift | 10 +- 12 files changed, 570 insertions(+), 1995 deletions(-) diff --git a/submodules/AccountContext/Sources/PresentationCallManager.swift b/submodules/AccountContext/Sources/PresentationCallManager.swift index d4d605a69a..993e5f436d 100644 --- a/submodules/AccountContext/Sources/PresentationCallManager.swift +++ b/submodules/AccountContext/Sources/PresentationCallManager.swift @@ -57,14 +57,14 @@ public struct PresentationCallState: Equatable { public enum VideoState: Equatable { case notAvailable case inactive - case active(isScreencast: Bool) - case paused(isScreencast: Bool) + case active(isScreencast: Bool, endpointId: String) + case paused(isScreencast: Bool, endpointId: String) } public enum RemoteVideoState: Equatable { case inactive - case active - case paused + case active(endpointId: String) + case paused(endpointId: String) } public enum RemoteAudioState: Equatable { diff --git a/submodules/DebugSettingsUI/Sources/DebugController.swift b/submodules/DebugSettingsUI/Sources/DebugController.swift index a6201959f4..2650b1ca4f 100644 --- a/submodules/DebugSettingsUI/Sources/DebugController.swift +++ b/submodules/DebugSettingsUI/Sources/DebugController.swift @@ -105,7 +105,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { case disableReloginTokens(Bool) case disableCallV2(Bool) case experimentalCallMute(Bool) - case autoBenchmarkReflectors(Bool) + case conferenceCalls(Bool) case benchmarkReflectors case enableLocalTranslation(Bool) case preferredVideoCodec(Int, String, String?, Bool) @@ -132,7 +132,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return DebugControllerSection.web.rawValue case .keepChatNavigationStack, .skipReadHistory, .dustEffect, .crashOnSlowQueries, .crashOnMemoryPressure: return DebugControllerSection.experiments.rawValue - case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .autoBenchmarkReflectors, .benchmarkReflectors, .enableLocalTranslation: + case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .conferenceCalls, .benchmarkReflectors, .enableLocalTranslation: return DebugControllerSection.experiments.rawValue case .logTranslationRecognition, .resetTranslationStates: return DebugControllerSection.translation.rawValue @@ -249,7 +249,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return 51 case .experimentalCallMute: return 52 - case .autoBenchmarkReflectors: + case .conferenceCalls: return 53 case .benchmarkReflectors: return 54 @@ -1345,12 +1345,12 @@ private enum DebugControllerEntry: ItemListNodeEntry { }) }).start() }) - case let .autoBenchmarkReflectors(value): - return ItemListSwitchItem(presentationData: presentationData, title: "Auto-Benchmark Reflectors [Restart]", value: value, sectionId: self.section, style: .blocks, updated: { value in + case let .conferenceCalls(value): + return ItemListSwitchItem(presentationData: presentationData, title: "Conference [WIP]", value: value, sectionId: self.section, style: .blocks, updated: { value in let _ = arguments.sharedContext.accountManager.transaction ({ transaction in transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings - settings.autoBenchmarkReflectors = value + settings.conferenceCalls = value return PreferencesEntry(settings) }) }).start() @@ -1577,11 +1577,7 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present entries.append(.disableCallV2(experimentalSettings.disableCallV2)) entries.append(.experimentalCallMute(experimentalSettings.experimentalCallMute)) - var defaultAutoBenchmarkReflectors = false - if case .internal = sharedContext.applicationBindings.appBuildType { - defaultAutoBenchmarkReflectors = true - } - entries.append(.autoBenchmarkReflectors(experimentalSettings.autoBenchmarkReflectors ?? defaultAutoBenchmarkReflectors)) + entries.append(.conferenceCalls(experimentalSettings.conferenceCalls)) entries.append(.benchmarkReflectors) entries.append(.enableLocalTranslation(experimentalSettings.enableLocalTranslation)) diff --git a/submodules/TelegramCallsUI/Sources/CallController.swift b/submodules/TelegramCallsUI/Sources/CallController.swift index 0128b35a16..0e47f02b4d 100644 --- a/submodules/TelegramCallsUI/Sources/CallController.swift +++ b/submodules/TelegramCallsUI/Sources/CallController.swift @@ -41,6 +41,193 @@ protocol CallControllerNodeProtocol: AnyObject { } public final class CallController: ViewController { + public enum Call: Equatable { + case call(PresentationCall) + case groupCall(PresentationGroupCall) + + public static func ==(lhs: Call, rhs: Call) -> Bool { + switch lhs { + case let .call(lhsCall): + if case let .call(rhsCall) = rhs { + return lhsCall === rhsCall + } else { + return false + } + case let .groupCall(lhsGroupCall): + if case let .groupCall(rhsGroupCall) = rhs { + return lhsGroupCall === rhsGroupCall + } else { + return false + } + } + } + + public var context: AccountContext { + switch self { + case let .call(call): + return call.context + case let .groupCall(groupCall): + return groupCall.accountContext + } + } + + public var peerId: EnginePeer.Id { + switch self { + case let .call(call): + return call.peerId + case let .groupCall(groupCall): + return groupCall.peerId + } + } + + public func requestVideo() { + switch self { + case let .call(call): + call.requestVideo() + case let .groupCall(groupCall): + groupCall.requestVideo() + } + } + + public func disableVideo() { + switch self { + case let .call(call): + call.disableVideo() + case let .groupCall(groupCall): + groupCall.disableVideo() + } + } + + public func disableScreencast() { + switch self { + case let .call(call): + (call as? PresentationCallImpl)?.disableScreencast() + case let .groupCall(groupCall): + groupCall.disableScreencast() + } + } + + public func switchVideoCamera() { + switch self { + case let .call(call): + call.switchVideoCamera() + case let .groupCall(groupCall): + groupCall.switchVideoCamera() + } + } + + public func toggleIsMuted() { + switch self { + case let .call(call): + call.toggleIsMuted() + case let .groupCall(groupCall): + groupCall.toggleIsMuted() + } + } + + public func setCurrentAudioOutput(_ output: AudioSessionOutput) { + switch self { + case let .call(call): + call.setCurrentAudioOutput(output) + case let .groupCall(groupCall): + groupCall.setCurrentAudioOutput(output) + } + } + + public var isMuted: Signal { + switch self { + case let .call(call): + return call.isMuted + case let .groupCall(groupCall): + return groupCall.isMuted + } + } + + public var audioLevel: Signal { + switch self { + case let .call(call): + return call.audioLevel + case let .groupCall(groupCall): + var audioLevelId: UInt32? + return groupCall.audioLevels |> map { audioLevels -> Float in + var result: Float = 0 + for item in audioLevels { + if let audioLevelId { + if item.1 == audioLevelId { + result = item.2 + break + } + } else { + if item.1 != 0 { + audioLevelId = item.1 + result = item.2 + break + } + } + } + + return result + } + } + } + + public var isOutgoing: Bool { + switch self { + case let .call(call): + return call.isOutgoing + case .groupCall: + return false + } + } + + public func makeOutgoingVideoView(completion: @escaping (PresentationCallVideoView?) -> Void) { + switch self { + case let .call(call): + call.makeOutgoingVideoView(completion: completion) + case let .groupCall(groupCall): + groupCall.makeOutgoingVideoView(requestClone: false, completion: { a, _ in + completion(a) + }) + } + } + + public var audioOutputState: Signal<([AudioSessionOutput], AudioSessionOutput?), NoError> { + switch self { + case let .call(call): + return call.audioOutputState + case let .groupCall(groupCall): + return groupCall.audioOutputState + } + } + + public func debugInfo() -> Signal<(String, String), NoError> { + switch self { + case let .call(call): + return call.debugInfo() + case .groupCall: + return .single(("", "")) + } + } + + public func answer() { + switch self { + case let .call(call): + call.answer() + case .groupCall: + break + } + } + + public func hangUp() -> Signal { + switch self { + case let .call(call): + return call.hangUp() + case let .groupCall(groupCall): + return groupCall.leave(terminateIfPossible: false) + } + } + } + private var controllerNode: CallControllerNodeProtocol { return self.displayNode as! CallControllerNodeProtocol } @@ -55,7 +242,7 @@ public final class CallController: ViewController { private let sharedContext: SharedAccountContext private let account: Account - public let call: PresentationCall + public let call: CallController.Call private let easyDebugAccess: Bool private var presentationData: PresentationData @@ -78,7 +265,10 @@ public final class CallController: ViewController { public var restoreUIForPictureInPicture: ((@escaping (Bool) -> Void) -> Void)? - public init(sharedContext: SharedAccountContext, account: Account, call: PresentationCall, easyDebugAccess: Bool) { + public var onViewDidAppear: (() -> Void)? + public var onViewDidDisappear: (() -> Void)? + + public init(sharedContext: SharedAccountContext, account: Account, call: CallController.Call, easyDebugAccess: Bool) { self.sharedContext = sharedContext self.account = account self.call = call @@ -103,10 +293,84 @@ public final class CallController: ViewController { self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .portrait, compactSize: .portrait) - self.disposable = (call.state - |> deliverOnMainQueue).start(next: { [weak self] callState in - self?.callStateUpdated(callState) - }) + switch call { + case let .call(call): + self.disposable = (call.state + |> deliverOnMainQueue).start(next: { [weak self] callState in + self?.callStateUpdated(callState) + }) + case let .groupCall(groupCall): + let accountPeerId = groupCall.account.peerId + let videoEndpoints: Signal<(local: String?, remote: PresentationGroupCallRequestedVideo?), NoError> = groupCall.members + |> map { members -> (local: String?, remote: PresentationGroupCallRequestedVideo?) in + guard let members else { + return (nil, nil) + } + var local: String? + var remote: PresentationGroupCallRequestedVideo? + for participant in members.participants { + if let video = participant.requestedPresentationVideoChannel(minQuality: .thumbnail, maxQuality: .full) ?? participant.requestedVideoChannel(minQuality: .thumbnail, maxQuality: .full) { + if participant.peer.id == accountPeerId { + local = video.endpointId + } else { + if remote == nil { + remote = video + } + } + } + } + return (local, remote) + } + |> distinctUntilChanged(isEqual: { lhs, rhs in + return lhs == rhs + }) + + var startTimestamp: Double? + self.disposable = (combineLatest(queue: .mainQueue(), + groupCall.state, + videoEndpoints + ) + |> deliverOnMainQueue).start(next: { [weak self] callState, videoEndpoints in + guard let self else { + return + } + let mappedState: PresentationCallState.State + switch callState.networkState { + case .connecting: + mappedState = .connecting(nil) + case .connected: + let timestamp = startTimestamp ?? CFAbsoluteTimeGetCurrent() + startTimestamp = timestamp + mappedState = .active(timestamp, nil, Data()) + } + + var mappedLocalVideoState: PresentationCallState.VideoState = .inactive + var mappedRemoteVideoState: PresentationCallState.RemoteVideoState = .inactive + + if let local = videoEndpoints.local { + mappedLocalVideoState = .active(isScreencast: false, endpointId: local) + } + if let remote = videoEndpoints.remote { + mappedRemoteVideoState = .active(endpointId: remote.endpointId) + } + + if case let .groupCall(groupCall) = self.call { + var requestedVideo: [PresentationGroupCallRequestedVideo] = [] + if let remote = videoEndpoints.remote { + requestedVideo.append(remote) + } + groupCall.setRequestedVideoList(items: requestedVideo) + } + + self.callStateUpdated(PresentationCallState( + state: mappedState, + videoState: mappedLocalVideoState, + remoteVideoState: mappedRemoteVideoState, + remoteAudioState: .active, + remoteBatteryLevel: .normal + )) + }) + } self.callMutedDisposable = (call.isMuted |> deliverOnMainQueue).start(next: { [weak self] value in @@ -148,26 +412,24 @@ public final class CallController: ViewController { } override public func loadDisplayNode() { - var useV2 = true - if let data = self.call.context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_callui_v2"] { - useV2 = false - } + let displayNode = CallControllerNodeV2( + sharedContext: self.sharedContext, + account: self.account, + presentationData: self.presentationData, + statusBar: self.statusBar, + debugInfo: self.call.debugInfo(), + easyDebugAccess: self.easyDebugAccess, + call: self.call + ) + self.displayNode = displayNode + self.isContentsReady.set(displayNode.isReady.get()) - if !useV2 { - self.displayNode = CallControllerNode(sharedContext: self.sharedContext, account: self.account, presentationData: self.presentationData, statusBar: self.statusBar, debugInfo: self.call.debugInfo(), shouldStayHiddenUntilConnection: !self.call.isOutgoing && self.call.isIntegratedWithCallKit, easyDebugAccess: self.easyDebugAccess, call: self.call) - self.isContentsReady.set(.single(true)) - } else { - let displayNode = CallControllerNodeV2(sharedContext: self.sharedContext, account: self.account, presentationData: self.presentationData, statusBar: self.statusBar, debugInfo: self.call.debugInfo(), easyDebugAccess: self.easyDebugAccess, call: self.call) - self.displayNode = displayNode - self.isContentsReady.set(displayNode.isReady.get()) - - displayNode.restoreUIForPictureInPicture = { [weak self] completion in - guard let self, let restoreUIForPictureInPicture = self.restoreUIForPictureInPicture else { - completion(false) - return - } - restoreUIForPictureInPicture(completion) + displayNode.restoreUIForPictureInPicture = { [weak self] completion in + guard let self, let restoreUIForPictureInPicture = self.restoreUIForPictureInPicture else { + completion(false) + return } + restoreUIForPictureInPicture(completion) } self.displayNodeDidLoad() @@ -335,7 +597,11 @@ public final class CallController: ViewController { self.presentingViewController?.dismiss(animated: false, completion: nil) } - self.peerDisposable = (combineLatest(self.account.postbox.peerView(id: self.account.peerId) |> take(1), self.account.postbox.peerView(id: self.call.peerId), self.sharedContext.activeAccountsWithInfo |> take(1)) + self.peerDisposable = (combineLatest(queue: .mainQueue(), + self.account.postbox.peerView(id: self.account.peerId) |> take(1), + self.account.postbox.peerView(id: self.call.peerId), + self.sharedContext.activeAccountsWithInfo |> take(1) + ) |> deliverOnMainQueue).start(next: { [weak self] accountView, view, activeAccountsWithInfo in if let strongSelf = self { if let accountPeer = accountView.peers[accountView.peerId], let peer = view.peers[view.peerId] { @@ -363,12 +629,16 @@ public final class CallController: ViewController { } self.idleTimerExtensionDisposable.set(self.sharedContext.applicationBindings.pushIdleTimerExtension()) + + self.onViewDidAppear?() } override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.idleTimerExtensionDisposable.set(nil) + + self.onViewDidDisappear?() } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { diff --git a/submodules/TelegramCallsUI/Sources/CallControllerNode.swift b/submodules/TelegramCallsUI/Sources/CallControllerNode.swift index ee3c3f164c..5c0c2d233a 100644 --- a/submodules/TelegramCallsUI/Sources/CallControllerNode.swift +++ b/submodules/TelegramCallsUI/Sources/CallControllerNode.swift @@ -368,1845 +368,3 @@ final class CallVideoNode: ASDisplayNode, PreviewVideoNode { } } -final class CallControllerNode: ViewControllerTracingNode, CallControllerNodeProtocol { - private enum VideoNodeCorner { - case topLeft - case topRight - case bottomLeft - case bottomRight - } - - private let sharedContext: SharedAccountContext - private let account: Account - - private let statusBar: StatusBar - - private var presentationData: PresentationData - private var peer: Peer? - private let debugInfo: Signal<(String, String), NoError> - private var forceReportRating = false - private let easyDebugAccess: Bool - private let call: PresentationCall - - private let containerTransformationNode: ASDisplayNode - private let containerNode: ASDisplayNode - private let videoContainerNode: PinchSourceContainerNode - - private let imageNode: TransformImageNode - private let dimNode: ASImageNode - - private var candidateIncomingVideoNodeValue: CallVideoNode? - private var incomingVideoNodeValue: CallVideoNode? - private var incomingVideoViewRequested: Bool = false - private var candidateOutgoingVideoNodeValue: CallVideoNode? - private var outgoingVideoNodeValue: CallVideoNode? - private var outgoingVideoViewRequested: Bool = false - - private var removedMinimizedVideoNodeValue: CallVideoNode? - private var removedExpandedVideoNodeValue: CallVideoNode? - - private var isRequestingVideo: Bool = false - private var animateRequestedVideoOnce: Bool = false - - private var hiddenUIForActiveVideoCallOnce: Bool = false - private var hideUIForActiveVideoCallTimer: SwiftSignalKit.Timer? - - private var displayedCameraConfirmation: Bool = false - private var displayedCameraTooltip: Bool = false - - private var expandedVideoNode: CallVideoNode? - private var minimizedVideoNode: CallVideoNode? - private var disableAnimationForExpandedVideoOnce: Bool = false - private var animationForExpandedVideoSnapshotView: UIView? = nil - - private var outgoingVideoNodeCorner: VideoNodeCorner = .bottomRight - private let backButtonArrowNode: ASImageNode - private let backButtonNode: HighlightableButtonNode - private let statusNode: CallControllerStatusNode - private let toastNode: CallControllerToastContainerNode - private let buttonsNode: CallControllerButtonsNode - private var keyPreviewNode: CallControllerKeyPreviewNode? - - private var debugNode: CallDebugNode? - - private var keyTextData: (Data, String)? - private let keyButtonNode: CallControllerKeyButton - - private var validLayout: (ContainerViewLayout, CGFloat)? - private var disableActionsUntilTimestamp: Double = 0.0 - - private var displayedVersionOutdatedAlert: Bool = false - - var isMuted: Bool = false { - didSet { - self.buttonsNode.isMuted = self.isMuted - self.updateToastContent() - if let (layout, navigationBarHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - } - } - - private var shouldStayHiddenUntilConnection: Bool = false - - private var audioOutputState: ([AudioSessionOutput], currentOutput: AudioSessionOutput?)? - private var callState: PresentationCallState? - - var toggleMute: (() -> Void)? - var setCurrentAudioOutput: ((AudioSessionOutput) -> Void)? - var beginAudioOuputSelection: ((Bool) -> Void)? - var acceptCall: (() -> Void)? - var endCall: (() -> Void)? - var back: (() -> Void)? - var presentCallRating: ((CallId, Bool) -> Void)? - var callEnded: ((Bool) -> Void)? - var dismissedInteractively: (() -> Void)? - var present: ((ViewController) -> Void)? - var dismissAllTooltips: (() -> Void)? - - private var toastContent: CallControllerToastContent? - private var displayToastsAfterTimestamp: Double? - - private var buttonsMode: CallControllerButtonsMode? - - private var isUIHidden: Bool = false - private var isVideoPaused: Bool = false - private var isVideoPinched: Bool = false - - private enum PictureInPictureGestureState { - case none - case collapsing(didSelectCorner: Bool) - case dragging(initialPosition: CGPoint, draggingPosition: CGPoint) - } - - private var pictureInPictureGestureState: PictureInPictureGestureState = .none - private var pictureInPictureCorner: VideoNodeCorner = .topRight - private var pictureInPictureTransitionFraction: CGFloat = 0.0 - - private var deviceOrientation: UIDeviceOrientation = .portrait - private var orientationDidChangeObserver: NSObjectProtocol? - - private var currentRequestedAspect: CGFloat? - - init(sharedContext: SharedAccountContext, account: Account, presentationData: PresentationData, statusBar: StatusBar, debugInfo: Signal<(String, String), NoError>, shouldStayHiddenUntilConnection: Bool = false, easyDebugAccess: Bool, call: PresentationCall) { - self.sharedContext = sharedContext - self.account = account - self.presentationData = presentationData - self.statusBar = statusBar - self.debugInfo = debugInfo - self.shouldStayHiddenUntilConnection = shouldStayHiddenUntilConnection - self.easyDebugAccess = easyDebugAccess - self.call = call - - self.containerTransformationNode = ASDisplayNode() - self.containerTransformationNode.clipsToBounds = true - - self.containerNode = ASDisplayNode() - - self.videoContainerNode = PinchSourceContainerNode() - - self.imageNode = TransformImageNode() - self.imageNode.contentAnimations = [.subsequentUpdates] - self.dimNode = ASImageNode() - self.dimNode.contentMode = .scaleToFill - self.dimNode.isUserInteractionEnabled = false - self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.3) - - self.backButtonArrowNode = ASImageNode() - self.backButtonArrowNode.displayWithoutProcessing = true - self.backButtonArrowNode.displaysAsynchronously = false - self.backButtonArrowNode.image = NavigationBarTheme.generateBackArrowImage(color: .white) - self.backButtonNode = HighlightableButtonNode() - - self.statusNode = CallControllerStatusNode() - - self.buttonsNode = CallControllerButtonsNode(strings: self.presentationData.strings) - self.toastNode = CallControllerToastContainerNode(strings: self.presentationData.strings) - self.keyButtonNode = CallControllerKeyButton() - self.keyButtonNode.accessibilityElementsHidden = false - - super.init() - - self.containerNode.backgroundColor = .black - - self.addSubnode(self.containerTransformationNode) - self.containerTransformationNode.addSubnode(self.containerNode) - - self.backButtonNode.setTitle(presentationData.strings.Common_Back, with: Font.regular(17.0), with: .white, for: []) - self.backButtonNode.accessibilityLabel = presentationData.strings.Call_VoiceOver_Minimize - self.backButtonNode.accessibilityTraits = [.button] - self.backButtonNode.hitTestSlop = UIEdgeInsets(top: -8.0, left: -20.0, bottom: -8.0, right: -8.0) - self.backButtonNode.highligthedChanged = { [weak self] highlighted in - if let strongSelf = self { - if highlighted { - strongSelf.backButtonNode.layer.removeAnimation(forKey: "opacity") - strongSelf.backButtonArrowNode.layer.removeAnimation(forKey: "opacity") - strongSelf.backButtonNode.alpha = 0.4 - strongSelf.backButtonArrowNode.alpha = 0.4 - } else { - strongSelf.backButtonNode.alpha = 1.0 - strongSelf.backButtonArrowNode.alpha = 1.0 - strongSelf.backButtonNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) - strongSelf.backButtonArrowNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) - } - } - } - - self.containerNode.addSubnode(self.imageNode) - self.containerNode.addSubnode(self.videoContainerNode) - self.containerNode.addSubnode(self.dimNode) - self.containerNode.addSubnode(self.statusNode) - self.containerNode.addSubnode(self.buttonsNode) - self.containerNode.addSubnode(self.toastNode) - self.containerNode.addSubnode(self.keyButtonNode) - self.containerNode.addSubnode(self.backButtonArrowNode) - self.containerNode.addSubnode(self.backButtonNode) - - self.buttonsNode.mute = { [weak self] in - self?.toggleMute?() - self?.cancelScheduledUIHiding() - } - - self.buttonsNode.speaker = { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.beginAudioOuputSelection?(strongSelf.hasVideoNodes) - strongSelf.cancelScheduledUIHiding() - } - - self.buttonsNode.acceptOrEnd = { [weak self] in - guard let strongSelf = self, let callState = strongSelf.callState else { - return - } - switch callState.state { - case .active, .connecting, .reconnecting: - strongSelf.endCall?() - strongSelf.cancelScheduledUIHiding() - case .requesting: - strongSelf.endCall?() - case .ringing: - strongSelf.acceptCall?() - default: - break - } - } - - self.buttonsNode.decline = { [weak self] in - self?.endCall?() - } - - self.buttonsNode.toggleVideo = { [weak self] in - guard let strongSelf = self, let callState = strongSelf.callState else { - return - } - switch callState.state { - case .active: - var isScreencastActive = false - switch callState.videoState { - case .active(true), .paused(true): - isScreencastActive = true - default: - break - } - - if isScreencastActive { - (strongSelf.call as! PresentationCallImpl).disableScreencast() - } else if strongSelf.outgoingVideoNodeValue == nil { - DeviceAccess.authorizeAccess(to: .camera(.videoCall), onlyCheck: true, presentationData: strongSelf.presentationData, present: { [weak self] c, a in - if let strongSelf = self { - strongSelf.present?(c) - } - }, openSettings: { [weak self] in - self?.sharedContext.applicationBindings.openSettings() - }, _: { [weak self] ready in - guard let strongSelf = self, ready else { - return - } - let proceed = { - strongSelf.displayedCameraConfirmation = true - switch callState.videoState { - case .inactive: - strongSelf.isRequestingVideo = true - strongSelf.updateButtonsMode() - default: - break - } - strongSelf.call.requestVideo() - } - - strongSelf.call.makeOutgoingVideoView(completion: { [weak self] outgoingVideoView in - guard let strongSelf = self else { - return - } - - if let outgoingVideoView = outgoingVideoView { - outgoingVideoView.view.backgroundColor = .black - outgoingVideoView.view.clipsToBounds = true - - var updateLayoutImpl: ((ContainerViewLayout, CGFloat) -> Void)? - - let outgoingVideoNode = CallVideoNode(videoView: outgoingVideoView, disabledText: nil, assumeReadyAfterTimeout: true, isReadyUpdated: { - guard let strongSelf = self, let (layout, navigationBarHeight) = strongSelf.validLayout else { - return - } - updateLayoutImpl?(layout, navigationBarHeight) - }, orientationUpdated: { - guard let strongSelf = self, let (layout, navigationBarHeight) = strongSelf.validLayout else { - return - } - updateLayoutImpl?(layout, navigationBarHeight) - }, isFlippedUpdated: { _ in - guard let strongSelf = self, let (layout, navigationBarHeight) = strongSelf.validLayout else { - return - } - updateLayoutImpl?(layout, navigationBarHeight) - }) - - let controller = VoiceChatCameraPreviewController(sharedContext: strongSelf.sharedContext, cameraNode: outgoingVideoNode, shareCamera: { _, _ in - proceed() - }, switchCamera: { [weak self] in - Queue.mainQueue().after(0.1) { - self?.call.switchVideoCamera() - } - }) - strongSelf.present?(controller) - - updateLayoutImpl = { [weak controller] layout, navigationBarHeight in - controller?.containerLayoutUpdated(layout, transition: .immediate) - } - } - }) - }) - } else { - strongSelf.call.disableVideo() - strongSelf.cancelScheduledUIHiding() - } - default: - break - } - } - - self.buttonsNode.rotateCamera = { [weak self] in - guard let strongSelf = self, !strongSelf.areUserActionsDisabledNow() else { - return - } - strongSelf.disableActionsUntilTimestamp = CACurrentMediaTime() + 1.0 - if let outgoingVideoNode = strongSelf.outgoingVideoNodeValue { - outgoingVideoNode.flip(withBackground: outgoingVideoNode !== strongSelf.minimizedVideoNode) - } - strongSelf.call.switchVideoCamera() - if let _ = strongSelf.outgoingVideoNodeValue { - if let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) - } - } - strongSelf.cancelScheduledUIHiding() - } - - self.keyButtonNode.addTarget(self, action: #selector(self.keyPressed), forControlEvents: .touchUpInside) - - self.backButtonNode.addTarget(self, action: #selector(self.backPressed), forControlEvents: .touchUpInside) - - if shouldStayHiddenUntilConnection { - self.containerNode.alpha = 0.0 - Queue.mainQueue().after(3.0, { [weak self] in - self?.containerNode.alpha = 1.0 - self?.animateIn() - }) - } else if call.isVideo && call.isOutgoing { - self.containerNode.alpha = 0.0 - Queue.mainQueue().after(1.0, { [weak self] in - self?.containerNode.alpha = 1.0 - self?.animateIn() - }) - } - - self.orientationDidChangeObserver = NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil, using: { [weak self] _ in - guard let strongSelf = self else { - return - } - let deviceOrientation = UIDevice.current.orientation - if strongSelf.deviceOrientation != deviceOrientation { - strongSelf.deviceOrientation = deviceOrientation - if let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - } - }) - - self.videoContainerNode.activate = { [weak self] sourceNode in - guard let strongSelf = self else { - return - } - let pinchController = PinchController(sourceNode: sourceNode, getContentAreaInScreenSpace: { - return UIScreen.main.bounds - }) - strongSelf.sharedContext.mainWindow?.presentInGlobalOverlay(pinchController) - strongSelf.isVideoPinched = true - - strongSelf.videoContainerNode.contentNode.clipsToBounds = true - strongSelf.videoContainerNode.backgroundColor = .black - - if let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.videoContainerNode.contentNode.cornerRadius = layout.deviceMetrics.screenCornerRadius - - strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) - } - } - - self.videoContainerNode.animatedOut = { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.isVideoPinched = false - - strongSelf.videoContainerNode.backgroundColor = .clear - strongSelf.videoContainerNode.contentNode.cornerRadius = 0.0 - - if let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - } - } - - deinit { - if let orientationDidChangeObserver = self.orientationDidChangeObserver { - NotificationCenter.default.removeObserver(orientationDidChangeObserver) - } - } - - func displayCameraTooltip() { - guard self.pictureInPictureTransitionFraction.isZero, let location = self.buttonsNode.videoButtonFrame().flatMap({ frame -> CGRect in - return self.buttonsNode.view.convert(frame, to: self.view) - }) else { - return - } - - self.present?(TooltipScreen(account: self.account, sharedContext: self.sharedContext, text: .plain(text: self.presentationData.strings.Call_CameraOrScreenTooltip), style: .light, icon: nil, location: .point(location.offsetBy(dx: 0.0, dy: -14.0), .bottom), displayDuration: .custom(5.0), shouldDismissOnTouch: { _, _ in - return .dismiss(consume: false) - })) - } - - override func didLoad() { - super.didLoad() - - let panRecognizer = CallPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) - panRecognizer.shouldBegin = { [weak self] _ in - guard let strongSelf = self else { - return false - } - if strongSelf.areUserActionsDisabledNow() { - return false - } - return true - } - self.view.addGestureRecognizer(panRecognizer) - - let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) - self.view.addGestureRecognizer(tapRecognizer) - } - - func updatePeer(accountPeer: Peer, peer: Peer, hasOther: Bool) { - if !arePeersEqual(self.peer, peer) { - self.peer = peer - if let peerReference = PeerReference(peer), !peer.profileImageRepresentations.isEmpty { - let representations: [ImageRepresentationWithReference] = peer.profileImageRepresentations.map({ ImageRepresentationWithReference(representation: $0, reference: .avatar(peer: peerReference, resource: $0.resource)) }) - self.imageNode.setSignal(chatAvatarGalleryPhoto(account: self.account, representations: representations, immediateThumbnailData: nil, autoFetchFullSize: true)) - self.dimNode.isHidden = false - } else { - self.imageNode.setSignal(callDefaultBackground()) - self.dimNode.isHidden = true - } - - self.toastNode.title = EnginePeer(peer).compactDisplayTitle - self.statusNode.title = EnginePeer(peer).displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder) - if hasOther { - self.statusNode.subtitle = self.presentationData.strings.Call_AnsweringWithAccount(EnginePeer(accountPeer).displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)).string - - if let callState = self.callState { - self.updateCallState(callState) - } - } - - if let (layout, navigationBarHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) - } - } - } - - func updateAudioOutputs(availableOutputs: [AudioSessionOutput], currentOutput: AudioSessionOutput?) { - if self.audioOutputState?.0 != availableOutputs || self.audioOutputState?.1 != currentOutput { - self.audioOutputState = (availableOutputs, currentOutput) - self.updateButtonsMode() - - self.setupAudioOutputs() - } - } - - private func setupAudioOutputs() { - if self.outgoingVideoNodeValue != nil || self.incomingVideoNodeValue != nil || self.candidateOutgoingVideoNodeValue != nil || self.candidateIncomingVideoNodeValue != nil { - if let audioOutputState = self.audioOutputState, let currentOutput = audioOutputState.currentOutput { - switch currentOutput { - case .headphones, .speaker: - break - case let .port(port) where port.type == .bluetooth || port.type == .wired: - break - default: - self.setCurrentAudioOutput?(.speaker) - } - } - } - } - - func updateCallState(_ callState: PresentationCallState) { - self.callState = callState - - let statusValue: CallControllerStatusValue - var statusReception: Int32? - - switch callState.remoteVideoState { - case .active, .paused: - if !self.incomingVideoViewRequested { - self.incomingVideoViewRequested = true - let delayUntilInitialized = true - self.call.makeIncomingVideoView(completion: { [weak self] incomingVideoView in - guard let strongSelf = self else { - return - } - if let incomingVideoView = incomingVideoView { - incomingVideoView.view.backgroundColor = .black - incomingVideoView.view.clipsToBounds = true - - let applyNode: () -> Void = { - guard let strongSelf = self, let incomingVideoNode = strongSelf.candidateIncomingVideoNodeValue else { - return - } - strongSelf.candidateIncomingVideoNodeValue = nil - - strongSelf.incomingVideoNodeValue = incomingVideoNode - if let expandedVideoNode = strongSelf.expandedVideoNode { - strongSelf.minimizedVideoNode = expandedVideoNode - strongSelf.videoContainerNode.contentNode.insertSubnode(incomingVideoNode, belowSubnode: expandedVideoNode) - } else { - strongSelf.videoContainerNode.contentNode.addSubnode(incomingVideoNode) - } - strongSelf.expandedVideoNode = incomingVideoNode - strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) - - strongSelf.updateDimVisibility() - strongSelf.maybeScheduleUIHidingForActiveVideoCall() - } - - let incomingVideoNode = CallVideoNode(videoView: incomingVideoView, disabledText: strongSelf.presentationData.strings.Call_RemoteVideoPaused(strongSelf.peer.flatMap(EnginePeer.init)?.compactDisplayTitle ?? "").string, assumeReadyAfterTimeout: false, isReadyUpdated: { - if delayUntilInitialized { - Queue.mainQueue().after(0.1, { - applyNode() - }) - } - }, orientationUpdated: { - guard let strongSelf = self else { - return - } - if let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - }, isFlippedUpdated: { _ in - }) - strongSelf.candidateIncomingVideoNodeValue = incomingVideoNode - strongSelf.setupAudioOutputs() - - if !delayUntilInitialized { - applyNode() - } - } - }) - } - case .inactive: - self.candidateIncomingVideoNodeValue = nil - if let incomingVideoNodeValue = self.incomingVideoNodeValue { - if self.minimizedVideoNode == incomingVideoNodeValue { - self.minimizedVideoNode = nil - self.removedMinimizedVideoNodeValue = incomingVideoNodeValue - } - if self.expandedVideoNode == incomingVideoNodeValue { - self.expandedVideoNode = nil - self.removedExpandedVideoNodeValue = incomingVideoNodeValue - - if let minimizedVideoNode = self.minimizedVideoNode { - self.expandedVideoNode = minimizedVideoNode - self.minimizedVideoNode = nil - } - } - self.incomingVideoNodeValue = nil - self.incomingVideoViewRequested = false - } - } - - switch callState.videoState { - case .active(false), .paused(false): - if !self.outgoingVideoViewRequested { - self.outgoingVideoViewRequested = true - let delayUntilInitialized = self.isRequestingVideo - self.call.makeOutgoingVideoView(completion: { [weak self] outgoingVideoView in - guard let strongSelf = self else { - return - } - - if let outgoingVideoView = outgoingVideoView { - outgoingVideoView.view.backgroundColor = .black - outgoingVideoView.view.clipsToBounds = true - - let applyNode: () -> Void = { - guard let strongSelf = self, let outgoingVideoNode = strongSelf.candidateOutgoingVideoNodeValue else { - return - } - strongSelf.candidateOutgoingVideoNodeValue = nil - - if strongSelf.isRequestingVideo { - strongSelf.isRequestingVideo = false - strongSelf.animateRequestedVideoOnce = true - } - - strongSelf.outgoingVideoNodeValue = outgoingVideoNode - if let expandedVideoNode = strongSelf.expandedVideoNode { - strongSelf.minimizedVideoNode = outgoingVideoNode - strongSelf.videoContainerNode.contentNode.insertSubnode(outgoingVideoNode, aboveSubnode: expandedVideoNode) - } else { - strongSelf.expandedVideoNode = outgoingVideoNode - strongSelf.videoContainerNode.contentNode.addSubnode(outgoingVideoNode) - } - strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) - - strongSelf.updateDimVisibility() - strongSelf.maybeScheduleUIHidingForActiveVideoCall() - } - - let outgoingVideoNode = CallVideoNode(videoView: outgoingVideoView, disabledText: nil, assumeReadyAfterTimeout: true, isReadyUpdated: { - if delayUntilInitialized { - Queue.mainQueue().after(0.4, { - applyNode() - }) - } - }, orientationUpdated: { - guard let strongSelf = self else { - return - } - if let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - }, isFlippedUpdated: { videoNode in - guard let _ = self else { - return - } - /*if videoNode === strongSelf.minimizedVideoNode, let tempView = videoNode.view.snapshotView(afterScreenUpdates: true) { - videoNode.view.superview?.insertSubview(tempView, aboveSubview: videoNode.view) - videoNode.view.frame = videoNode.frame - let transitionOptions: UIView.AnimationOptions = [.transitionFlipFromRight, .showHideTransitionViews] - - UIView.transition(with: tempView, duration: 1.0, options: transitionOptions, animations: { - tempView.isHidden = true - }, completion: { [weak tempView] _ in - tempView?.removeFromSuperview() - }) - - videoNode.view.isHidden = true - UIView.transition(with: videoNode.view, duration: 1.0, options: transitionOptions, animations: { - videoNode.view.isHidden = false - }) - }*/ - }) - - strongSelf.candidateOutgoingVideoNodeValue = outgoingVideoNode - strongSelf.setupAudioOutputs() - - if !delayUntilInitialized { - applyNode() - } - } - }) - } - default: - self.candidateOutgoingVideoNodeValue = nil - if let outgoingVideoNodeValue = self.outgoingVideoNodeValue { - if self.minimizedVideoNode == outgoingVideoNodeValue { - self.minimizedVideoNode = nil - self.removedMinimizedVideoNodeValue = outgoingVideoNodeValue - } - if self.expandedVideoNode == self.outgoingVideoNodeValue { - self.expandedVideoNode = nil - self.removedExpandedVideoNodeValue = outgoingVideoNodeValue - - if let minimizedVideoNode = self.minimizedVideoNode { - self.expandedVideoNode = minimizedVideoNode - self.minimizedVideoNode = nil - } - } - self.outgoingVideoNodeValue = nil - self.outgoingVideoViewRequested = false - } - } - - if let incomingVideoNode = self.incomingVideoNodeValue { - switch callState.state { - case .terminating, .terminated: - break - default: - let isActive: Bool - switch callState.remoteVideoState { - case .inactive, .paused: - isActive = false - case .active: - isActive = true - } - incomingVideoNode.updateIsBlurred(isBlurred: !isActive) - } - } - - switch callState.state { - case .waiting, .connecting: - statusValue = .text(string: self.presentationData.strings.Call_StatusConnecting, displayLogo: false) - case let .requesting(ringing): - if ringing { - statusValue = .text(string: self.presentationData.strings.Call_StatusRinging, displayLogo: false) - } else { - statusValue = .text(string: self.presentationData.strings.Call_StatusRequesting, displayLogo: false) - } - case .terminating: - statusValue = .text(string: self.presentationData.strings.Call_StatusEnded, displayLogo: false) - case let .terminated(_, reason, _): - if let reason = reason { - switch reason { - case let .ended(type): - switch type { - case .busy: - statusValue = .text(string: self.presentationData.strings.Call_StatusBusy, displayLogo: false) - case .hungUp, .missed: - statusValue = .text(string: self.presentationData.strings.Call_StatusEnded, displayLogo: false) - } - case let .error(error): - let text = self.presentationData.strings.Call_StatusFailed - switch error { - case let .notSupportedByPeer(isVideo): - if !self.displayedVersionOutdatedAlert, let peer = self.peer { - self.displayedVersionOutdatedAlert = true - - let text: String - if isVideo { - text = self.presentationData.strings.Call_ParticipantVideoVersionOutdatedError(EnginePeer(peer).displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)).string - } else { - text = self.presentationData.strings.Call_ParticipantVersionOutdatedError(EnginePeer(peer).displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)).string - } - - self.present?(textAlertController(sharedContext: self.sharedContext, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_OK, action: { - })])) - } - default: - break - } - statusValue = .text(string: text, displayLogo: false) - } - } else { - statusValue = .text(string: self.presentationData.strings.Call_StatusEnded, displayLogo: false) - } - case .ringing: - var text: String - if self.call.isVideo { - text = self.presentationData.strings.Call_IncomingVideoCall - } else { - text = self.presentationData.strings.Call_IncomingVoiceCall - } - if !self.statusNode.subtitle.isEmpty { - text += "\n\(self.statusNode.subtitle)" - } - statusValue = .text(string: text, displayLogo: false) - case .active(let timestamp, let reception, let keyVisualHash), .reconnecting(let timestamp, let reception, let keyVisualHash): - let strings = self.presentationData.strings - var isReconnecting = false - if case .reconnecting = callState.state { - isReconnecting = true - } - if self.keyTextData?.0 != keyVisualHash { - let text = stringForEmojiHashOfData(keyVisualHash, 4)!.joined(separator: "") - self.keyTextData = (keyVisualHash, text) - - self.keyButtonNode.key = text - - let keyTextSize = self.keyButtonNode.measure(CGSize(width: 200.0, height: 200.0)) - self.keyButtonNode.frame = CGRect(origin: self.keyButtonNode.frame.origin, size: keyTextSize) - - self.keyButtonNode.animateIn() - - if let (layout, navigationBarHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) - } - } - - statusValue = .timer({ value, measure in - if isReconnecting || (self.outgoingVideoViewRequested && value == "00:00" && !measure) { - return strings.Call_StatusConnecting - } else { - return value - } - }, timestamp) - if case .active = callState.state { - statusReception = reception - } - } - if self.shouldStayHiddenUntilConnection { - switch callState.state { - case .connecting, .active: - self.containerNode.alpha = 1.0 - default: - break - } - } - self.statusNode.status = statusValue - self.statusNode.reception = statusReception - - if let callState = self.callState { - switch callState.state { - case .active, .connecting, .reconnecting: - break - default: - self.isUIHidden = false - } - } - - self.updateToastContent() - self.updateButtonsMode() - self.updateDimVisibility() - - if self.incomingVideoViewRequested || self.outgoingVideoViewRequested { - if self.incomingVideoViewRequested && self.outgoingVideoViewRequested { - self.displayedCameraTooltip = true - } - self.displayedCameraConfirmation = true - } - if self.incomingVideoViewRequested && !self.outgoingVideoViewRequested && !self.displayedCameraTooltip && (self.toastContent?.isEmpty ?? true) { - self.displayedCameraTooltip = true - Queue.mainQueue().after(2.0) { - self.displayCameraTooltip() - } - } - - if case let .terminated(id, _, reportRating) = callState.state, let callId = id { - let presentRating = reportRating || self.forceReportRating - if presentRating { - self.presentCallRating?(callId, self.call.isVideo) - } - self.callEnded?(presentRating) - } - - let hasIncomingVideoNode = self.incomingVideoNodeValue != nil && self.expandedVideoNode === self.incomingVideoNodeValue - self.videoContainerNode.isPinchGestureEnabled = hasIncomingVideoNode - } - - private func updateToastContent() { - guard let callState = self.callState else { - return - } - if case .terminating = callState.state { - } else if case .terminated = callState.state { - } else { - var toastContent: CallControllerToastContent = [] - if case .active = callState.state { - if let displayToastsAfterTimestamp = self.displayToastsAfterTimestamp { - if CACurrentMediaTime() > displayToastsAfterTimestamp { - if case .inactive = callState.remoteVideoState, self.hasVideoNodes { - toastContent.insert(.camera) - } - if case .muted = callState.remoteAudioState { - toastContent.insert(.microphone) - } - if case .low = callState.remoteBatteryLevel { - toastContent.insert(.battery) - } - } - } else { - self.displayToastsAfterTimestamp = CACurrentMediaTime() + 1.5 - } - } - if self.isMuted, let (availableOutputs, _) = self.audioOutputState, availableOutputs.count > 2 { - toastContent.insert(.mute) - } - self.toastContent = toastContent - } - } - - private func updateDimVisibility(transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut)) { - guard let callState = self.callState else { - return - } - - var visible = true - if case .active = callState.state, self.incomingVideoNodeValue != nil || self.outgoingVideoNodeValue != nil { - visible = false - } - - let currentVisible = self.dimNode.image == nil - if visible != currentVisible { - let color = visible ? UIColor(rgb: 0x000000, alpha: 0.3) : UIColor.clear - let image: UIImage? = visible ? nil : generateGradientImage(size: CGSize(width: 1.0, height: 640.0), colors: [UIColor.black.withAlphaComponent(0.3), UIColor.clear, UIColor.clear, UIColor.black.withAlphaComponent(0.3)], locations: [0.0, 0.22, 0.7, 1.0]) - if case let .animated(duration, _) = transition { - UIView.transition(with: self.dimNode.view, duration: duration, options: .transitionCrossDissolve, animations: { - self.dimNode.backgroundColor = color - self.dimNode.image = image - }, completion: nil) - } else { - self.dimNode.backgroundColor = color - self.dimNode.image = image - } - } - self.statusNode.setVisible(visible || self.keyPreviewNode != nil, transition: transition) - } - - private func maybeScheduleUIHidingForActiveVideoCall() { - guard let callState = self.callState, case .active = callState.state, self.incomingVideoNodeValue != nil && self.outgoingVideoNodeValue != nil, !self.hiddenUIForActiveVideoCallOnce && self.keyPreviewNode == nil else { - return - } - - let timer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in - if let strongSelf = self { - var updated = false - if let callState = strongSelf.callState, !strongSelf.isUIHidden { - switch callState.state { - case .active, .connecting, .reconnecting: - strongSelf.isUIHidden = true - updated = true - default: - break - } - } - if updated, let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - strongSelf.hideUIForActiveVideoCallTimer = nil - } - }, queue: Queue.mainQueue()) - timer.start() - self.hideUIForActiveVideoCallTimer = timer - self.hiddenUIForActiveVideoCallOnce = true - } - - private func cancelScheduledUIHiding() { - self.hideUIForActiveVideoCallTimer?.invalidate() - self.hideUIForActiveVideoCallTimer = nil - } - - private var buttonsTerminationMode: CallControllerButtonsMode? - - private func updateButtonsMode(transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .spring)) { - guard let callState = self.callState else { - return - } - - var mode: CallControllerButtonsSpeakerMode = .none - var hasAudioRouteMenu: Bool = false - if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput { - hasAudioRouteMenu = availableOutputs.count > 2 - switch currentOutput { - case .builtin: - mode = .builtin - case .speaker: - mode = .speaker - case .headphones: - mode = .headphones - case let .port(port): - var type: CallControllerButtonsSpeakerMode.BluetoothType = .generic - let portName = port.name.lowercased() - if portName.contains("airpods pro") { - type = .airpodsPro - } else if portName.contains("airpods") { - type = .airpods - } - mode = .bluetooth(type) - } - if availableOutputs.count <= 1 { - mode = .none - } - } - var mappedVideoState = CallControllerButtonsMode.VideoState(isAvailable: false, isCameraActive: self.outgoingVideoNodeValue != nil, isScreencastActive: false, canChangeStatus: false, hasVideo: self.outgoingVideoNodeValue != nil || self.incomingVideoNodeValue != nil, isInitializingCamera: self.isRequestingVideo) - switch callState.videoState { - case .notAvailable: - break - case .inactive: - mappedVideoState.isAvailable = true - mappedVideoState.canChangeStatus = true - case .active(let isScreencast), .paused(let isScreencast): - mappedVideoState.isAvailable = true - mappedVideoState.canChangeStatus = true - if isScreencast { - mappedVideoState.isScreencastActive = true - mappedVideoState.hasVideo = true - } - } - - switch callState.state { - case .ringing: - self.buttonsMode = .incoming(speakerMode: mode, hasAudioRouteMenu: hasAudioRouteMenu, videoState: mappedVideoState) - self.buttonsTerminationMode = buttonsMode - case .waiting, .requesting: - self.buttonsMode = .outgoingRinging(speakerMode: mode, hasAudioRouteMenu: hasAudioRouteMenu, videoState: mappedVideoState) - self.buttonsTerminationMode = buttonsMode - case .active, .connecting, .reconnecting: - self.buttonsMode = .active(speakerMode: mode, hasAudioRouteMenu: hasAudioRouteMenu, videoState: mappedVideoState) - self.buttonsTerminationMode = buttonsMode - case .terminating, .terminated: - if let buttonsTerminationMode = self.buttonsTerminationMode { - self.buttonsMode = buttonsTerminationMode - } else { - self.buttonsMode = .active(speakerMode: mode, hasAudioRouteMenu: hasAudioRouteMenu, videoState: mappedVideoState) - } - } - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: transition) - } - } - - func animateIn() { - if !self.containerNode.alpha.isZero { - var bounds = self.bounds - bounds.origin = CGPoint() - self.bounds = bounds - self.layer.removeAnimation(forKey: "bounds") - self.statusBar.layer.removeAnimation(forKey: "opacity") - self.containerNode.layer.removeAnimation(forKey: "opacity") - self.containerNode.layer.removeAnimation(forKey: "scale") - self.statusBar.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - if !self.shouldStayHiddenUntilConnection { - self.containerNode.layer.animateScale(from: 1.04, to: 1.0, duration: 0.3) - self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - } - } - } - - func animateOut(completion: @escaping () -> Void) { - self.statusBar.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) - if !self.shouldStayHiddenUntilConnection || self.containerNode.alpha > 0.0 { - self.containerNode.layer.allowsGroupOpacity = true - self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in - self?.containerNode.layer.allowsGroupOpacity = false - }) - self.containerNode.layer.animateScale(from: 1.0, to: 1.04, duration: 0.3, removeOnCompletion: false, completion: { _ in - completion() - }) - } else { - completion() - } - } - - func expandFromPipIfPossible() { - if self.pictureInPictureTransitionFraction.isEqual(to: 1.0), let (layout, navigationHeight) = self.validLayout { - self.pictureInPictureTransitionFraction = 0.0 - - self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) - } - } - - private func calculatePreviewVideoRect(layout: ContainerViewLayout, navigationHeight: CGFloat) -> CGRect { - let buttonsHeight: CGFloat = self.buttonsNode.bounds.height - let toastHeight: CGFloat = self.toastNode.bounds.height - let toastInset = (toastHeight > 0.0 ? toastHeight + 22.0 : 0.0) - - var fullInsets = layout.insets(options: .statusBar) - - var cleanInsets = fullInsets - cleanInsets.bottom = max(layout.intrinsicInsets.bottom, 20.0) + toastInset - cleanInsets.left = 20.0 - cleanInsets.right = 20.0 - - fullInsets.top += 44.0 + 8.0 - fullInsets.bottom = buttonsHeight + 22.0 + toastInset - fullInsets.left = 20.0 - fullInsets.right = 20.0 - - var insets: UIEdgeInsets = self.isUIHidden ? cleanInsets : fullInsets - - let expandedInset: CGFloat = 16.0 - - insets.top = interpolate(from: expandedInset, to: insets.top, value: 1.0 - self.pictureInPictureTransitionFraction) - insets.bottom = interpolate(from: expandedInset, to: insets.bottom, value: 1.0 - self.pictureInPictureTransitionFraction) - insets.left = interpolate(from: expandedInset, to: insets.left, value: 1.0 - self.pictureInPictureTransitionFraction) - insets.right = interpolate(from: expandedInset, to: insets.right, value: 1.0 - self.pictureInPictureTransitionFraction) - - let previewVideoSide = interpolate(from: 300.0, to: 150.0, value: 1.0 - self.pictureInPictureTransitionFraction) - var previewVideoSize = layout.size.aspectFitted(CGSize(width: previewVideoSide, height: previewVideoSide)) - previewVideoSize = CGSize(width: 30.0, height: 45.0).aspectFitted(previewVideoSize) - if let minimizedVideoNode = self.minimizedVideoNode { - var aspect = minimizedVideoNode.currentAspect - var rotationCount = 0 - if minimizedVideoNode === self.outgoingVideoNodeValue { - aspect = 3.0 / 4.0 - } else { - if aspect < 1.0 { - aspect = 3.0 / 4.0 - } else { - aspect = 4.0 / 3.0 - } - - switch minimizedVideoNode.currentOrientation { - case .rotation90, .rotation270: - rotationCount += 1 - default: - break - } - - var mappedDeviceOrientation = self.deviceOrientation - if case .regular = layout.metrics.widthClass, case .regular = layout.metrics.heightClass { - mappedDeviceOrientation = .portrait - } - - switch mappedDeviceOrientation { - case .landscapeLeft, .landscapeRight: - rotationCount += 1 - default: - break - } - - if rotationCount % 2 != 0 { - aspect = 1.0 / aspect - } - } - - let unboundVideoSize = CGSize(width: aspect * 10000.0, height: 10000.0) - - previewVideoSize = unboundVideoSize.aspectFitted(CGSize(width: previewVideoSide, height: previewVideoSide)) - } - let previewVideoY: CGFloat - let previewVideoX: CGFloat - - switch self.outgoingVideoNodeCorner { - case .topLeft: - previewVideoX = insets.left - previewVideoY = insets.top - case .topRight: - previewVideoX = layout.size.width - previewVideoSize.width - insets.right - previewVideoY = insets.top - case .bottomLeft: - previewVideoX = insets.left - previewVideoY = layout.size.height - insets.bottom - previewVideoSize.height - case .bottomRight: - previewVideoX = layout.size.width - previewVideoSize.width - insets.right - previewVideoY = layout.size.height - insets.bottom - previewVideoSize.height - } - - return CGRect(origin: CGPoint(x: previewVideoX, y: previewVideoY), size: previewVideoSize) - } - - private func calculatePictureInPictureContainerRect(layout: ContainerViewLayout, navigationHeight: CGFloat) -> CGRect { - let pictureInPictureTopInset: CGFloat = layout.insets(options: .statusBar).top + 44.0 + 8.0 - let pictureInPictureSideInset: CGFloat = 8.0 - let pictureInPictureSize = layout.size.fitted(CGSize(width: 240.0, height: 240.0)) - let pictureInPictureBottomInset: CGFloat = layout.insets(options: .input).bottom + 44.0 + 8.0 - - let containerPictureInPictureFrame: CGRect - switch self.pictureInPictureCorner { - case .topLeft: - containerPictureInPictureFrame = CGRect(origin: CGPoint(x: pictureInPictureSideInset, y: pictureInPictureTopInset), size: pictureInPictureSize) - case .topRight: - containerPictureInPictureFrame = CGRect(origin: CGPoint(x: layout.size.width - pictureInPictureSideInset - pictureInPictureSize.width, y: pictureInPictureTopInset), size: pictureInPictureSize) - case .bottomLeft: - containerPictureInPictureFrame = CGRect(origin: CGPoint(x: pictureInPictureSideInset, y: layout.size.height - pictureInPictureBottomInset - pictureInPictureSize.height), size: pictureInPictureSize) - case .bottomRight: - containerPictureInPictureFrame = CGRect(origin: CGPoint(x: layout.size.width - pictureInPictureSideInset - pictureInPictureSize.width, y: layout.size.height - pictureInPictureBottomInset - pictureInPictureSize.height), size: pictureInPictureSize) - } - return containerPictureInPictureFrame - } - - func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { - self.validLayout = (layout, navigationBarHeight) - - var mappedDeviceOrientation = self.deviceOrientation - var isCompactLayout = true - if case .regular = layout.metrics.widthClass, case .regular = layout.metrics.heightClass { - mappedDeviceOrientation = .portrait - isCompactLayout = false - } - - if !self.hasVideoNodes { - self.isUIHidden = false - } - - var isUIHidden = self.isUIHidden - switch self.callState?.state { - case .terminated, .terminating: - isUIHidden = false - default: - break - } - - var uiDisplayTransition: CGFloat = isUIHidden ? 0.0 : 1.0 - let pipTransitionAlpha: CGFloat = 1.0 - self.pictureInPictureTransitionFraction - uiDisplayTransition *= pipTransitionAlpha - - let pinchTransitionAlpha: CGFloat = self.isVideoPinched ? 0.0 : 1.0 - - let previousVideoButtonFrame = self.buttonsNode.videoButtonFrame().flatMap { frame -> CGRect in - return self.buttonsNode.view.convert(frame, to: self.view) - } - - let buttonsHeight: CGFloat - if let buttonsMode = self.buttonsMode { - buttonsHeight = self.buttonsNode.updateLayout(strings: self.presentationData.strings, mode: buttonsMode, constrainedWidth: layout.size.width, bottomInset: layout.intrinsicInsets.bottom, transition: transition) - } else { - buttonsHeight = 0.0 - } - let defaultButtonsOriginY = layout.size.height - buttonsHeight - let buttonsCollapsedOriginY = self.pictureInPictureTransitionFraction > 0.0 ? layout.size.height + 30.0 : layout.size.height + 10.0 - let buttonsOriginY = interpolate(from: buttonsCollapsedOriginY, to: defaultButtonsOriginY, value: uiDisplayTransition) - - let toastHeight = self.toastNode.updateLayout(strings: self.presentationData.strings, content: self.toastContent, constrainedWidth: layout.size.width, bottomInset: layout.intrinsicInsets.bottom + buttonsHeight, transition: transition) - - let toastSpacing: CGFloat = 22.0 - let toastCollapsedOriginY = self.pictureInPictureTransitionFraction > 0.0 ? layout.size.height : layout.size.height - max(layout.intrinsicInsets.bottom, 20.0) - toastHeight - let toastOriginY = interpolate(from: toastCollapsedOriginY, to: defaultButtonsOriginY - toastSpacing - toastHeight, value: uiDisplayTransition) - - var overlayAlpha: CGFloat = min(pinchTransitionAlpha, uiDisplayTransition) - var toastAlpha: CGFloat = min(pinchTransitionAlpha, pipTransitionAlpha) - - switch self.callState?.state { - case .terminated, .terminating: - overlayAlpha *= 0.5 - toastAlpha *= 0.5 - default: - break - } - - let containerFullScreenFrame = CGRect(origin: CGPoint(), size: layout.size) - let containerPictureInPictureFrame = self.calculatePictureInPictureContainerRect(layout: layout, navigationHeight: navigationBarHeight) - - let containerFrame = interpolateFrame(from: containerFullScreenFrame, to: containerPictureInPictureFrame, t: self.pictureInPictureTransitionFraction) - - transition.updateFrame(node: self.containerTransformationNode, frame: containerFrame) - transition.updateSublayerTransformScale(node: self.containerTransformationNode, scale: min(1.0, containerFrame.width / layout.size.width * 1.01)) - transition.updateCornerRadius(layer: self.containerTransformationNode.layer, cornerRadius: self.pictureInPictureTransitionFraction * 10.0) - - transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(x: (containerFrame.width - layout.size.width) / 2.0, y: floor(containerFrame.height - layout.size.height) / 2.0), size: layout.size)) - transition.updateFrame(node: self.videoContainerNode, frame: containerFullScreenFrame) - self.videoContainerNode.update(size: containerFullScreenFrame.size, transition: transition) - - transition.updateAlpha(node: self.dimNode, alpha: pinchTransitionAlpha) - transition.updateFrame(node: self.dimNode, frame: containerFullScreenFrame) - - if let keyPreviewNode = self.keyPreviewNode { - transition.updateFrame(node: keyPreviewNode, frame: containerFullScreenFrame) - keyPreviewNode.updateLayout(size: layout.size, transition: .immediate) - } - - transition.updateFrame(node: self.imageNode, frame: containerFullScreenFrame) - let arguments = TransformImageArguments(corners: ImageCorners(), imageSize: CGSize(width: 640.0, height: 640.0).aspectFilled(layout.size), boundingSize: layout.size, intrinsicInsets: UIEdgeInsets()) - let apply = self.imageNode.asyncLayout()(arguments) - apply() - - let navigationOffset: CGFloat = max(20.0, layout.safeInsets.top) - let topOriginY = interpolate(from: -20.0, to: navigationOffset, value: uiDisplayTransition) - - let backSize = self.backButtonNode.measure(CGSize(width: 320.0, height: 100.0)) - if let image = self.backButtonArrowNode.image { - transition.updateFrame(node: self.backButtonArrowNode, frame: CGRect(origin: CGPoint(x: 10.0, y: topOriginY + 11.0), size: image.size)) - } - transition.updateFrame(node: self.backButtonNode, frame: CGRect(origin: CGPoint(x: 29.0, y: topOriginY + 11.0), size: backSize)) - - transition.updateAlpha(node: self.backButtonArrowNode, alpha: overlayAlpha) - transition.updateAlpha(node: self.backButtonNode, alpha: overlayAlpha) - transition.updateAlpha(node: self.toastNode, alpha: toastAlpha) - - var statusOffset: CGFloat - if layout.metrics.widthClass == .regular && layout.metrics.heightClass == .regular { - if layout.size.height.isEqual(to: 1366.0) { - statusOffset = 160.0 - } else { - statusOffset = 120.0 - } - } else { - if layout.size.height.isEqual(to: 736.0) { - statusOffset = 80.0 - } else if layout.size.width.isEqual(to: 320.0) { - statusOffset = 60.0 - } else { - statusOffset = 64.0 - } - } - - statusOffset += layout.safeInsets.top - - let statusHeight = self.statusNode.updateLayout(constrainedWidth: layout.size.width, transition: transition) - transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(x: 0.0, y: statusOffset), size: CGSize(width: layout.size.width, height: statusHeight))) - transition.updateAlpha(node: self.statusNode, alpha: overlayAlpha) - - transition.updateFrame(node: self.toastNode, frame: CGRect(origin: CGPoint(x: 0.0, y: toastOriginY), size: CGSize(width: layout.size.width, height: toastHeight))) - transition.updateFrame(node: self.buttonsNode, frame: CGRect(origin: CGPoint(x: 0.0, y: buttonsOriginY), size: CGSize(width: layout.size.width, height: buttonsHeight))) - transition.updateAlpha(node: self.buttonsNode, alpha: overlayAlpha) - - let fullscreenVideoFrame = containerFullScreenFrame - let previewVideoFrame = self.calculatePreviewVideoRect(layout: layout, navigationHeight: navigationBarHeight) - - if let removedMinimizedVideoNodeValue = self.removedMinimizedVideoNodeValue { - self.removedMinimizedVideoNodeValue = nil - - if transition.isAnimated { - removedMinimizedVideoNodeValue.layer.animateScale(from: 1.0, to: 0.1, duration: 0.3, removeOnCompletion: false) - removedMinimizedVideoNodeValue.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak removedMinimizedVideoNodeValue] _ in - removedMinimizedVideoNodeValue?.removeFromSupernode() - }) - } else { - removedMinimizedVideoNodeValue.removeFromSupernode() - } - } - - if let expandedVideoNode = self.expandedVideoNode { - transition.updateAlpha(node: expandedVideoNode, alpha: 1.0) - var expandedVideoTransition = transition - if expandedVideoNode.frame.isEmpty || self.disableAnimationForExpandedVideoOnce { - expandedVideoTransition = .immediate - self.disableAnimationForExpandedVideoOnce = false - } - - if let removedExpandedVideoNodeValue = self.removedExpandedVideoNodeValue { - self.removedExpandedVideoNodeValue = nil - - expandedVideoTransition.updateFrame(node: expandedVideoNode, frame: fullscreenVideoFrame, completion: { [weak removedExpandedVideoNodeValue] _ in - removedExpandedVideoNodeValue?.removeFromSupernode() - }) - } else { - expandedVideoTransition.updateFrame(node: expandedVideoNode, frame: fullscreenVideoFrame) - } - - expandedVideoNode.updateLayout(size: expandedVideoNode.frame.size, cornerRadius: 0.0, isOutgoing: expandedVideoNode === self.outgoingVideoNodeValue, deviceOrientation: mappedDeviceOrientation, isCompactLayout: isCompactLayout, transition: expandedVideoTransition) - - if self.animateRequestedVideoOnce { - self.animateRequestedVideoOnce = false - if expandedVideoNode === self.outgoingVideoNodeValue { - let videoButtonFrame = self.buttonsNode.videoButtonFrame().flatMap { frame -> CGRect in - return self.buttonsNode.view.convert(frame, to: self.view) - } - - if let previousVideoButtonFrame = previousVideoButtonFrame, let videoButtonFrame = videoButtonFrame { - expandedVideoNode.animateRadialMask(from: previousVideoButtonFrame, to: videoButtonFrame) - } - } - } - } else { - if let removedExpandedVideoNodeValue = self.removedExpandedVideoNodeValue { - self.removedExpandedVideoNodeValue = nil - - if transition.isAnimated { - removedExpandedVideoNodeValue.layer.animateScale(from: 1.0, to: 0.1, duration: 0.3, removeOnCompletion: false) - removedExpandedVideoNodeValue.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak removedExpandedVideoNodeValue] _ in - removedExpandedVideoNodeValue?.removeFromSupernode() - }) - } else { - removedExpandedVideoNodeValue.removeFromSupernode() - } - } - } - - - if let minimizedVideoNode = self.minimizedVideoNode { - transition.updateAlpha(node: minimizedVideoNode, alpha: min(pipTransitionAlpha, pinchTransitionAlpha)) - var minimizedVideoTransition = transition - var didAppear = false - if minimizedVideoNode.frame.isEmpty { - minimizedVideoTransition = .immediate - didAppear = true - } - if self.minimizedVideoDraggingPosition == nil { - if let animationForExpandedVideoSnapshotView = self.animationForExpandedVideoSnapshotView { - self.containerNode.view.addSubview(animationForExpandedVideoSnapshotView) - transition.updateAlpha(layer: animationForExpandedVideoSnapshotView.layer, alpha: 0.0, completion: { [weak animationForExpandedVideoSnapshotView] _ in - animationForExpandedVideoSnapshotView?.removeFromSuperview() - }) - transition.updateTransformScale(layer: animationForExpandedVideoSnapshotView.layer, scale: previewVideoFrame.width / fullscreenVideoFrame.width) - - transition.updatePosition(layer: animationForExpandedVideoSnapshotView.layer, position: CGPoint(x: previewVideoFrame.minX + previewVideoFrame.center.x / fullscreenVideoFrame.width * previewVideoFrame.width, y: previewVideoFrame.minY + previewVideoFrame.center.y / fullscreenVideoFrame.height * previewVideoFrame.height)) - self.animationForExpandedVideoSnapshotView = nil - } - minimizedVideoTransition.updateFrame(node: minimizedVideoNode, frame: previewVideoFrame) - minimizedVideoNode.updateLayout(size: previewVideoFrame.size, cornerRadius: interpolate(from: 14.0, to: 24.0, value: self.pictureInPictureTransitionFraction), isOutgoing: minimizedVideoNode === self.outgoingVideoNodeValue, deviceOrientation: mappedDeviceOrientation, isCompactLayout: layout.metrics.widthClass == .compact, transition: minimizedVideoTransition) - if transition.isAnimated && didAppear { - minimizedVideoNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5) - } - } - - self.animationForExpandedVideoSnapshotView = nil - } - - let keyTextSize = self.keyButtonNode.frame.size - transition.updateFrame(node: self.keyButtonNode, frame: CGRect(origin: CGPoint(x: layout.size.width - keyTextSize.width - 8.0, y: topOriginY + 8.0), size: keyTextSize)) - transition.updateAlpha(node: self.keyButtonNode, alpha: overlayAlpha) - - if let debugNode = self.debugNode { - transition.updateFrame(node: debugNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - } - - let requestedAspect: CGFloat - if case .compact = layout.metrics.widthClass, case .compact = layout.metrics.heightClass { - var isIncomingVideoRotated = false - var rotationCount = 0 - - switch mappedDeviceOrientation { - case .portrait: - break - case .landscapeLeft: - rotationCount += 1 - case .landscapeRight: - rotationCount += 1 - case .portraitUpsideDown: - break - default: - break - } - - if rotationCount % 2 != 0 { - isIncomingVideoRotated = true - } - - if !isIncomingVideoRotated { - requestedAspect = layout.size.width / layout.size.height - } else { - requestedAspect = 0.0 - } - } else { - requestedAspect = 0.0 - } - if self.currentRequestedAspect != requestedAspect { - self.currentRequestedAspect = requestedAspect - if !self.sharedContext.immediateExperimentalUISettings.disableVideoAspectScaling { - self.call.setRequestedVideoAspect(Float(requestedAspect)) - } - } - } - - @objc func keyPressed() { - if self.keyPreviewNode == nil, let keyText = self.keyTextData?.1, let peer = self.peer { - let keyPreviewNode = CallControllerKeyPreviewNode(keyText: keyText, infoText: self.presentationData.strings.Call_EmojiDescription(EnginePeer(peer).compactDisplayTitle).string.replacingOccurrences(of: "%%", with: "%"), dismiss: { [weak self] in - if let _ = self?.keyPreviewNode { - self?.backPressed() - } - }) - - self.containerNode.insertSubnode(keyPreviewNode, belowSubnode: self.statusNode) - self.keyPreviewNode = keyPreviewNode - - if let (validLayout, _) = self.validLayout { - keyPreviewNode.updateLayout(size: validLayout.size, transition: .immediate) - - self.keyButtonNode.isHidden = true - keyPreviewNode.animateIn(from: self.keyButtonNode.frame, fromNode: self.keyButtonNode) - } - - self.updateDimVisibility() - } - } - - @objc func backPressed() { - if let keyPreviewNode = self.keyPreviewNode { - self.keyPreviewNode = nil - keyPreviewNode.animateOut(to: self.keyButtonNode.frame, toNode: self.keyButtonNode, completion: { [weak self, weak keyPreviewNode] in - self?.keyButtonNode.isHidden = false - keyPreviewNode?.removeFromSupernode() - }) - self.updateDimVisibility() - } else if self.hasVideoNodes { - if let (layout, navigationHeight) = self.validLayout { - self.pictureInPictureTransitionFraction = 1.0 - self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) - } - } else { - self.back?() - } - } - - private var hasVideoNodes: Bool { - return self.expandedVideoNode != nil || self.minimizedVideoNode != nil - } - - private var debugTapCounter: (Double, Int) = (0.0, 0) - - private func areUserActionsDisabledNow() -> Bool { - return CACurrentMediaTime() < self.disableActionsUntilTimestamp - } - - @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - if !self.pictureInPictureTransitionFraction.isZero { - self.view.window?.endEditing(true) - - if let (layout, navigationHeight) = self.validLayout { - self.pictureInPictureTransitionFraction = 0.0 - - self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) - } - } else if let _ = self.keyPreviewNode { - self.backPressed() - } else { - if self.hasVideoNodes { - let point = recognizer.location(in: recognizer.view) - if let expandedVideoNode = self.expandedVideoNode, let minimizedVideoNode = self.minimizedVideoNode, minimizedVideoNode.frame.contains(point) { - if !self.areUserActionsDisabledNow() { - let copyView = minimizedVideoNode.view.snapshotView(afterScreenUpdates: false) - copyView?.frame = minimizedVideoNode.frame - self.expandedVideoNode = minimizedVideoNode - self.minimizedVideoNode = expandedVideoNode - if let supernode = expandedVideoNode.supernode { - supernode.insertSubnode(expandedVideoNode, aboveSubnode: minimizedVideoNode) - } - self.disableActionsUntilTimestamp = CACurrentMediaTime() + 0.3 - if let (layout, navigationBarHeight) = self.validLayout { - self.disableAnimationForExpandedVideoOnce = true - self.animationForExpandedVideoSnapshotView = copyView - self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - } - } else { - var updated = false - if let callState = self.callState { - switch callState.state { - case .active, .connecting, .reconnecting: - self.isUIHidden = !self.isUIHidden - updated = true - default: - break - } - } - if updated, let (layout, navigationBarHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - } - } else { - let point = recognizer.location(in: recognizer.view) - if self.statusNode.frame.contains(point) { - if self.easyDebugAccess { - self.presentDebugNode() - } else { - let timestamp = CACurrentMediaTime() - if self.debugTapCounter.0 < timestamp - 0.75 { - self.debugTapCounter.0 = timestamp - self.debugTapCounter.1 = 0 - } - - if self.debugTapCounter.0 >= timestamp - 0.75 { - self.debugTapCounter.0 = timestamp - self.debugTapCounter.1 += 1 - } - - if self.debugTapCounter.1 >= 10 { - self.debugTapCounter.1 = 0 - - self.presentDebugNode() - } - } - } - } - } - } - } - - private func presentDebugNode() { - guard self.debugNode == nil else { - return - } - - self.forceReportRating = true - - let debugNode = CallDebugNode(signal: self.debugInfo) - debugNode.dismiss = { [weak self] in - if let strongSelf = self { - strongSelf.debugNode?.removeFromSupernode() - strongSelf.debugNode = nil - } - } - self.addSubnode(debugNode) - self.debugNode = debugNode - - if let (layout, navigationBarHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) - } - } - - private var minimizedVideoInitialPosition: CGPoint? - private var minimizedVideoDraggingPosition: CGPoint? - - private func nodeLocationForPosition(layout: ContainerViewLayout, position: CGPoint, velocity: CGPoint) -> VideoNodeCorner { - let layoutInsets = UIEdgeInsets() - var result = CGPoint() - if position.x < layout.size.width / 2.0 { - result.x = 0.0 - } else { - result.x = 1.0 - } - if position.y < layoutInsets.top + (layout.size.height - layoutInsets.bottom - layoutInsets.top) / 2.0 { - result.y = 0.0 - } else { - result.y = 1.0 - } - - let currentPosition = result - - let angleEpsilon: CGFloat = 30.0 - var shouldHide = false - - if (velocity.x * velocity.x + velocity.y * velocity.y) >= 500.0 * 500.0 { - let x = velocity.x - let y = velocity.y - - var angle = atan2(y, x) * 180.0 / CGFloat.pi * -1.0 - if angle < 0.0 { - angle += 360.0 - } - - if currentPosition.x.isZero && currentPosition.y.isZero { - if ((angle > 0 && angle < 90 - angleEpsilon) || angle > 360 - angleEpsilon) { - result.x = 1.0 - result.y = 0.0 - } else if (angle > 180 + angleEpsilon && angle < 270 + angleEpsilon) { - result.x = 0.0 - result.y = 1.0 - } else if (angle > 270 + angleEpsilon && angle < 360 - angleEpsilon) { - result.x = 1.0 - result.y = 1.0 - } else { - shouldHide = true - } - } else if !currentPosition.x.isZero && currentPosition.y.isZero { - if (angle > 90 + angleEpsilon && angle < 180 + angleEpsilon) { - result.x = 0.0 - result.y = 0.0 - } - else if (angle > 270 - angleEpsilon && angle < 360 - angleEpsilon) { - result.x = 1.0 - result.y = 1.0 - } - else if (angle > 180 + angleEpsilon && angle < 270 - angleEpsilon) { - result.x = 0.0 - result.y = 1.0 - } - else { - shouldHide = true - } - } else if currentPosition.x.isZero && !currentPosition.y.isZero { - if (angle > 90 - angleEpsilon && angle < 180 - angleEpsilon) { - result.x = 0.0 - result.y = 0.0 - } - else if (angle < angleEpsilon || angle > 270 + angleEpsilon) { - result.x = 1.0 - result.y = 1.0 - } - else if (angle > angleEpsilon && angle < 90 - angleEpsilon) { - result.x = 1.0 - result.y = 0.0 - } - else if (!shouldHide) { - shouldHide = true - } - } else if !currentPosition.x.isZero && !currentPosition.y.isZero { - if (angle > angleEpsilon && angle < 90 + angleEpsilon) { - result.x = 1.0 - result.y = 0.0 - } - else if (angle > 180 - angleEpsilon && angle < 270 - angleEpsilon) { - result.x = 0.0 - result.y = 1.0 - } - else if (angle > 90 + angleEpsilon && angle < 180 - angleEpsilon) { - result.x = 0.0 - result.y = 0.0 - } - else if (!shouldHide) { - shouldHide = true - } - } - } - - if result.x.isZero { - if result.y.isZero { - return .topLeft - } else { - return .bottomLeft - } - } else { - if result.y.isZero { - return .topRight - } else { - return .bottomRight - } - } - } - - @objc private func panGesture(_ recognizer: CallPanGestureRecognizer) { - switch recognizer.state { - case .began: - guard let location = recognizer.firstLocation else { - return - } - if self.pictureInPictureTransitionFraction.isZero, let expandedVideoNode = self.expandedVideoNode, let minimizedVideoNode = self.minimizedVideoNode, minimizedVideoNode.frame.contains(location), expandedVideoNode.frame != minimizedVideoNode.frame { - self.minimizedVideoInitialPosition = minimizedVideoNode.position - } else if self.hasVideoNodes { - self.minimizedVideoInitialPosition = nil - if !self.pictureInPictureTransitionFraction.isZero { - self.pictureInPictureGestureState = .dragging(initialPosition: self.containerTransformationNode.position, draggingPosition: self.containerTransformationNode.position) - } else { - self.pictureInPictureGestureState = .collapsing(didSelectCorner: false) - } - } else { - self.pictureInPictureGestureState = .none - } - self.dismissAllTooltips?() - case .changed: - if let minimizedVideoNode = self.minimizedVideoNode, let minimizedVideoInitialPosition = self.minimizedVideoInitialPosition { - let translation = recognizer.translation(in: self.view) - let minimizedVideoDraggingPosition = CGPoint(x: minimizedVideoInitialPosition.x + translation.x, y: minimizedVideoInitialPosition.y + translation.y) - self.minimizedVideoDraggingPosition = minimizedVideoDraggingPosition - minimizedVideoNode.position = minimizedVideoDraggingPosition - } else { - switch self.pictureInPictureGestureState { - case .none: - let offset = recognizer.translation(in: self.view).y - var bounds = self.bounds - bounds.origin.y = -offset - self.bounds = bounds - case let .collapsing(didSelectCorner): - if let (layout, navigationHeight) = self.validLayout { - let offset = recognizer.translation(in: self.view) - if !didSelectCorner { - self.pictureInPictureGestureState = .collapsing(didSelectCorner: true) - if offset.x < 0.0 { - self.pictureInPictureCorner = .topLeft - } else { - self.pictureInPictureCorner = .topRight - } - } - let maxOffset: CGFloat = min(300.0, layout.size.height / 2.0) - - let offsetTransition = max(0.0, min(1.0, abs(offset.y) / maxOffset)) - self.pictureInPictureTransitionFraction = offsetTransition - switch self.pictureInPictureCorner { - case .topRight, .bottomRight: - self.pictureInPictureCorner = offset.y < 0.0 ? .topRight : .bottomRight - case .topLeft, .bottomLeft: - self.pictureInPictureCorner = offset.y < 0.0 ? .topLeft : .bottomLeft - } - - self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .immediate) - } - case .dragging(let initialPosition, var draggingPosition): - let translation = recognizer.translation(in: self.view) - draggingPosition.x = initialPosition.x + translation.x - draggingPosition.y = initialPosition.y + translation.y - self.pictureInPictureGestureState = .dragging(initialPosition: initialPosition, draggingPosition: draggingPosition) - self.containerTransformationNode.position = draggingPosition - } - } - case .cancelled, .ended: - if let minimizedVideoNode = self.minimizedVideoNode, let _ = self.minimizedVideoInitialPosition, let minimizedVideoDraggingPosition = self.minimizedVideoDraggingPosition { - self.minimizedVideoInitialPosition = nil - self.minimizedVideoDraggingPosition = nil - - if let (layout, navigationHeight) = self.validLayout { - self.outgoingVideoNodeCorner = self.nodeLocationForPosition(layout: layout, position: minimizedVideoDraggingPosition, velocity: recognizer.velocity(in: self.view)) - - let videoFrame = self.calculatePreviewVideoRect(layout: layout, navigationHeight: navigationHeight) - minimizedVideoNode.frame = videoFrame - minimizedVideoNode.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: minimizedVideoDraggingPosition.x - videoFrame.midX, y: minimizedVideoDraggingPosition.y - videoFrame.midY)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.5, delay: 0.0, initialVelocity: 0.0, damping: 110.0, removeOnCompletion: true, additive: true, completion: nil) - } - } else { - switch self.pictureInPictureGestureState { - case .none: - let velocity = recognizer.velocity(in: self.view).y - if abs(velocity) < 100.0 { - var bounds = self.bounds - let previous = bounds - bounds.origin = CGPoint() - self.bounds = bounds - self.layer.animateBounds(from: previous, to: bounds, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) - } else { - var bounds = self.bounds - let previous = bounds - bounds.origin = CGPoint(x: 0.0, y: velocity > 0.0 ? -bounds.height: bounds.height) - self.bounds = bounds - self.layer.animateBounds(from: previous, to: bounds, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, completion: { [weak self] _ in - self?.dismissedInteractively?() - }) - } - case .collapsing: - self.pictureInPictureGestureState = .none - let velocity = recognizer.velocity(in: self.view).y - if abs(velocity) < 100.0 && self.pictureInPictureTransitionFraction < 0.5 { - if let (layout, navigationHeight) = self.validLayout { - self.pictureInPictureTransitionFraction = 0.0 - - self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) - } - } else { - if let (layout, navigationHeight) = self.validLayout { - self.pictureInPictureTransitionFraction = 1.0 - - self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) - } - } - case let .dragging(initialPosition, _): - self.pictureInPictureGestureState = .none - if let (layout, navigationHeight) = self.validLayout { - let translation = recognizer.translation(in: self.view) - let draggingPosition = CGPoint(x: initialPosition.x + translation.x, y: initialPosition.y + translation.y) - self.pictureInPictureCorner = self.nodeLocationForPosition(layout: layout, position: draggingPosition, velocity: recognizer.velocity(in: self.view)) - - let containerFrame = self.calculatePictureInPictureContainerRect(layout: layout, navigationHeight: navigationHeight) - self.containerTransformationNode.frame = containerFrame - containerTransformationNode.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: draggingPosition.x - containerFrame.midX, y: draggingPosition.y - containerFrame.midY)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.5, delay: 0.0, initialVelocity: 0.0, damping: 110.0, removeOnCompletion: true, additive: true, completion: nil) - } - } - } - default: - break - } - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if self.debugNode != nil { - return super.hitTest(point, with: event) - } - if self.containerTransformationNode.frame.contains(point) { - return self.containerTransformationNode.view.hitTest(self.view.convert(point, to: self.containerTransformationNode.view), with: event) - } - return nil - } -} - -final class CallPanGestureRecognizer: UIPanGestureRecognizer { - private(set) var firstLocation: CGPoint? - - public var shouldBegin: ((CGPoint) -> Bool)? - - override public init(target: Any?, action: Selector?) { - super.init(target: target, action: action) - - self.maximumNumberOfTouches = 1 - } - - override public func reset() { - super.reset() - - self.firstLocation = nil - } - - override public func touchesBegan(_ touches: Set, with event: UIEvent) { - super.touchesBegan(touches, with: event) - - let touch = touches.first! - let point = touch.location(in: self.view) - if let shouldBegin = self.shouldBegin, !shouldBegin(point) { - self.state = .failed - return - } - - self.firstLocation = point - } - - override public func touchesMoved(_ touches: Set, with event: UIEvent) { - super.touchesMoved(touches, with: event) - } -} diff --git a/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift b/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift index e8dca56706..608651f188 100644 --- a/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift +++ b/submodules/TelegramCallsUI/Sources/CallControllerNodeV2.swift @@ -32,7 +32,7 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP private let account: Account private let presentationData: PresentationData private let statusBar: StatusBar - private let call: PresentationCall + private let call: CallController.Call private let containerView: UIView private let callScreen: PrivateCallScreen @@ -90,7 +90,7 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP statusBar: StatusBar, debugInfo: Signal<(String, String), NoError>, easyDebugAccess: Bool, - call: PresentationCall + call: CallController.Call ) { self.sharedContext = sharedContext self.account = account @@ -172,9 +172,6 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP isRemoteBatteryLow: false, isEnergySavingEnabled: !self.sharedContext.energyUsageSettings.fullTranslucency ) - if let peer = call.peer { - self.updatePeer(peer: peer) - } self.isMicrophoneMutedDisposable = (call.isMuted |> deliverOnMainQueue).startStrict(next: { [weak self] isMuted in @@ -308,9 +305,9 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP switch callState.state { case .active: switch callState.videoState { - case .active(let isScreencast), .paused(let isScreencast): + case .active(let isScreencast, _), .paused(let isScreencast, _): if isScreencast { - (self.call as? PresentationCallImpl)?.disableScreencast() + self.call.disableScreencast() } else { self.call.disableVideo() } @@ -489,12 +486,23 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP self.remoteVideo = nil default: switch callState.videoState { - case .active(let isScreencast), .paused(let isScreencast): + case .active(let isScreencast, let endpointId), .paused(let isScreencast, let endpointId): if isScreencast { self.localVideo = nil } else { - if self.localVideo == nil, let call = self.call as? PresentationCallImpl, let videoStreamSignal = call.video(isIncoming: false) { - self.localVideo = AdaptedCallVideoSource(videoStreamSignal: videoStreamSignal) + if self.localVideo == nil { + switch self.call { + case let .call(call): + if let call = call as? PresentationCallImpl, let videoStreamSignal = call.video(isIncoming: false) { + self.localVideo = AdaptedCallVideoSource(videoStreamSignal: videoStreamSignal) + } + case let .groupCall(groupCall): + if let groupCall = groupCall as? PresentationGroupCallImpl { + if let videoStreamSignal = groupCall.video(endpointId: endpointId) { + self.localVideo = AdaptedCallVideoSource(videoStreamSignal: videoStreamSignal) + } + } + } } } case .inactive, .notAvailable: @@ -502,9 +510,20 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP } switch callState.remoteVideoState { - case .active, .paused: - if self.remoteVideo == nil, let call = self.call as? PresentationCallImpl, let videoStreamSignal = call.video(isIncoming: true) { - self.remoteVideo = AdaptedCallVideoSource(videoStreamSignal: videoStreamSignal) + case .active(let endpointId), .paused(let endpointId): + if self.remoteVideo == nil { + switch self.call { + case let .call(call): + if let call = call as? PresentationCallImpl, let videoStreamSignal = call.video(isIncoming: true) { + self.remoteVideo = AdaptedCallVideoSource(videoStreamSignal: videoStreamSignal) + } + case let .groupCall(groupCall): + if let groupCall = groupCall as? PresentationGroupCallImpl { + if let videoStreamSignal = groupCall.video(endpointId: endpointId) { + self.remoteVideo = AdaptedCallVideoSource(videoStreamSignal: videoStreamSignal) + } + } + } } case .inactive: self.remoteVideo = nil diff --git a/submodules/TelegramCallsUI/Sources/PresentationCall.swift b/submodules/TelegramCallsUI/Sources/PresentationCall.swift index bd564a7b95..aef6cfb00c 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationCall.swift @@ -183,7 +183,7 @@ public final class PresentationCallImpl: PresentationCall { self.isVideo = startWithVideo if self.isVideo { self.videoCapturer = OngoingCallVideoCapturer() - self.statePromise.set(PresentationCallState(state: isOutgoing ? .waiting : .ringing, videoState: .active(isScreencast: self.isScreencastActive), remoteVideoState: .inactive, remoteAudioState: .active, remoteBatteryLevel: .normal)) + self.statePromise.set(PresentationCallState(state: isOutgoing ? .waiting : .ringing, videoState: .active(isScreencast: self.isScreencastActive, endpointId: ""), remoteVideoState: .inactive, remoteAudioState: .active, remoteBatteryLevel: .normal)) } else { self.statePromise.set(PresentationCallState(state: isOutgoing ? .waiting : .ringing, videoState: self.isVideoPossible ? .inactive : .notAvailable, remoteVideoState: .inactive, remoteAudioState: .active, remoteBatteryLevel: .normal)) } @@ -418,19 +418,19 @@ public final class PresentationCallImpl: PresentationCall { case .notAvailable: mappedVideoState = .notAvailable case .active: - mappedVideoState = .active(isScreencast: self.isScreencastActive) + mappedVideoState = .active(isScreencast: self.isScreencastActive, endpointId: "") case .inactive: mappedVideoState = .inactive case .paused: - mappedVideoState = .paused(isScreencast: self.isScreencastActive) + mappedVideoState = .paused(isScreencast: self.isScreencastActive, endpointId: "") } switch callContextState.remoteVideoState { case .inactive: mappedRemoteVideoState = .inactive case .active: - mappedRemoteVideoState = .active + mappedRemoteVideoState = .active(endpointId: "") case .paused: - mappedRemoteVideoState = .paused + mappedRemoteVideoState = .paused(endpointId: "") } switch callContextState.remoteAudioState { case .active: @@ -453,7 +453,7 @@ public final class PresentationCallImpl: PresentationCall { mappedVideoState = previousVideoState } else { if self.isVideo { - mappedVideoState = .active(isScreencast: self.isScreencastActive) + mappedVideoState = .active(isScreencast: self.isScreencastActive, endpointId: "") } else if self.isVideoPossible && sessionState.isVideoPossible { mappedVideoState = .inactive } else { diff --git a/submodules/TelegramCore/Sources/Network/FetchV2.swift b/submodules/TelegramCore/Sources/Network/FetchV2.swift index 6d460bf17b..b7f52b98d3 100644 --- a/submodules/TelegramCore/Sources/Network/FetchV2.swift +++ b/submodules/TelegramCore/Sources/Network/FetchV2.swift @@ -77,18 +77,18 @@ private final class FetchImpl { let partRange: Range let fetchRange: Range let fetchedData: Data - let decryptedData: Data + let cleanData: Data init( partRange: Range, fetchRange: Range, fetchedData: Data, - decryptedData: Data + cleanData: Data ) { self.partRange = partRange self.fetchRange = fetchRange self.fetchedData = fetchedData - self.decryptedData = decryptedData + self.cleanData = cleanData } } @@ -148,6 +148,48 @@ private final class FetchImpl { case cdn(CdnData) } + private final class DecryptionState { + let aesKey: Data + var aesIv: Data + let decryptedSize: Int64 + var offset: Int = 0 + + init(aesKey: Data, aesIv: Data, decryptedSize: Int64) { + self.aesKey = aesKey + self.aesIv = aesIv + self.decryptedSize = decryptedSize + } + + func tryDecrypt(data: Data, offset: Int, loggingIdentifier: String) -> Data? { + if offset == self.offset { + var decryptedData = data + if self.decryptedSize == 0 { + Logger.shared.log("FetchV2", "\(loggingIdentifier): not decrypting part \(offset) ..< \(offset + data.count) (decryptedSize == 0)") + return nil + } + if decryptedData.count % 16 != 0 { + Logger.shared.log("FetchV2", "\(loggingIdentifier): not decrypting part \(offset) ..< \(offset + data.count) (decryptedData.count % 16 != 0)") + } + let decryptedDataCount = decryptedData.count + decryptedData.withUnsafeMutableBytes { rawBytes -> Void in + let bytes = rawBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) + self.aesIv.withUnsafeMutableBytes { rawIv -> Void in + let iv = rawIv.baseAddress!.assumingMemoryBound(to: UInt8.self) + MTAesDecryptBytesInplaceAndModifyIv(bytes, decryptedDataCount, self.aesKey, iv) + } + } + if self.offset + decryptedData.count > self.decryptedSize { + decryptedData.count = Int(self.decryptedSize) - self.offset + } + self.offset += decryptedData.count + Logger.shared.log("FetchV2", "\(loggingIdentifier): decrypted part \(offset) ..< \(offset + data.count) (new offset is \(self.offset))") + return decryptedData + } else { + return nil + } + } + } + private final class FetchingState { let fetchLocation: FetchLocation let partSize: Int64 @@ -160,6 +202,7 @@ private final class FetchImpl { var pendingParts: [PendingPart] = [] var completedRanges = RangeSet() + var decryptionState: DecryptionState? var pendingReadyParts: [PendingReadyPart] = [] var completedHashRanges = RangeSet() var pendingHashRanges: [PendingHashRange] = [] @@ -174,7 +217,8 @@ private final class FetchImpl { maxPartSize: Int64, partAlignment: Int64, partDivision: Int64, - maxPendingParts: Int + maxPendingParts: Int, + decryptionState: DecryptionState? ) { self.fetchLocation = fetchLocation self.partSize = partSize @@ -183,6 +227,7 @@ private final class FetchImpl { self.partAlignment = partAlignment self.partDivision = partDivision self.maxPendingParts = maxPendingParts + self.decryptionState = decryptionState } deinit { @@ -373,6 +418,12 @@ private final class FetchImpl { if self.state == nil { Logger.shared.log("FetchV2", "\(self.loggingIdentifier): initializing to .datacenter(\(self.datacenterId))") + var decryptionState: DecryptionState? + if let encryptionKey = self.encryptionKey, let decryptedSize = self.decryptedSize { + decryptionState = DecryptionState(aesKey: encryptionKey.aesKey, aesIv: encryptionKey.aesIv, decryptedSize: decryptedSize) + self.onNext(.reset) + } + self.state = .fetching(FetchingState( fetchLocation: .datacenter(self.datacenterId), partSize: self.defaultPartSize, @@ -380,7 +431,8 @@ private final class FetchImpl { maxPartSize: 1 * 1024 * 1024, partAlignment: 4 * 1024, partDivision: 1 * 1024 * 1024, - maxPendingParts: 6 + maxPendingParts: 6, + decryptionState: decryptionState )) } guard let state = self.state else { @@ -396,55 +448,75 @@ private final class FetchImpl { do { var removedPendingReadyPartIndices: [Int] = [] - for i in 0 ..< state.pendingReadyParts.count { - let pendingReadyPart = state.pendingReadyParts[i] - if state.completedHashRanges.isSuperset(of: RangeSet(pendingReadyPart.fetchRange)) { - removedPendingReadyPartIndices.append(i) - - var checkOffset: Int64 = 0 - var checkFailed = false - while checkOffset < pendingReadyPart.fetchedData.count { - if let hashRange = state.hashRanges[pendingReadyPart.fetchRange.lowerBound + checkOffset] { - var clippedHashRange = hashRange.range - - if pendingReadyPart.fetchRange.lowerBound + Int64(pendingReadyPart.fetchedData.count) < clippedHashRange.lowerBound { - Logger.shared.log("FetchV2", "\(self.loggingIdentifier): unable to check \(pendingReadyPart.fetchRange): data range \(clippedHashRange) out of bounds (0 ..< \(pendingReadyPart.fetchedData.count))") - checkFailed = true - break - } - clippedHashRange = clippedHashRange.lowerBound ..< min(clippedHashRange.upperBound, pendingReadyPart.fetchRange.lowerBound + Int64(pendingReadyPart.fetchedData.count)) - - let partLocalHashRange = (clippedHashRange.lowerBound - pendingReadyPart.fetchRange.lowerBound) ..< (clippedHashRange.upperBound - pendingReadyPart.fetchRange.lowerBound) - - if partLocalHashRange.lowerBound < 0 || partLocalHashRange.upperBound > pendingReadyPart.fetchedData.count { - Logger.shared.log("FetchV2", "\(self.loggingIdentifier): unable to check \(pendingReadyPart.fetchRange): data range \(partLocalHashRange) out of bounds (0 ..< \(pendingReadyPart.fetchedData.count))") - checkFailed = true - break - } - - let dataToHash = pendingReadyPart.decryptedData.subdata(in: Int(partLocalHashRange.lowerBound) ..< Int(partLocalHashRange.upperBound)) - let localHash = MTSha256(dataToHash) - if localHash != hashRange.data { - Logger.shared.log("FetchV2", "\(self.loggingIdentifier): failed to verify \(pendingReadyPart.fetchRange): hash mismatch") - checkFailed = true - break - } - - checkOffset += partLocalHashRange.upperBound - partLocalHashRange.lowerBound - } else { - Logger.shared.log("FetchV2", "\(self.loggingIdentifier): unable to find \(pendingReadyPart.fetchRange) hash range despite it being marked as ready") - checkFailed = true - break + if let decryptionState = state.decryptionState { + while true { + var removedSomePendingReadyPart = false + for i in 0 ..< state.pendingReadyParts.count { + if removedPendingReadyPartIndices.contains(i) { + continue + } + let pendingReadyPart = state.pendingReadyParts[i] + if let resultData = decryptionState.tryDecrypt(data: pendingReadyPart.cleanData, offset: Int(pendingReadyPart.fetchRange.lowerBound), loggingIdentifier: self.loggingIdentifier) { + removedPendingReadyPartIndices.append(i) + removedSomePendingReadyPart = true + self.commitPendingReadyPart(state: state, partRange: pendingReadyPart.partRange, fetchRange: pendingReadyPart.fetchRange, data: resultData) } } - if !checkFailed { - self.commitPendingReadyPart(state: state, partRange: pendingReadyPart.partRange, fetchRange: pendingReadyPart.fetchRange, data: pendingReadyPart.decryptedData) - } else { - Logger.shared.log("FetchV2", "\(self.loggingIdentifier): unable to find \(pendingReadyPart.fetchRange) hash check failed") + if !removedSomePendingReadyPart { + break + } + } + } else { + for i in 0 ..< state.pendingReadyParts.count { + let pendingReadyPart = state.pendingReadyParts[i] + if state.completedHashRanges.isSuperset(of: RangeSet(pendingReadyPart.fetchRange)) { + removedPendingReadyPartIndices.append(i) + + var checkOffset: Int64 = 0 + var checkFailed = false + while checkOffset < pendingReadyPart.fetchedData.count { + if let hashRange = state.hashRanges[pendingReadyPart.fetchRange.lowerBound + checkOffset] { + var clippedHashRange = hashRange.range + + if pendingReadyPart.fetchRange.lowerBound + Int64(pendingReadyPart.fetchedData.count) < clippedHashRange.lowerBound { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): unable to check \(pendingReadyPart.fetchRange): data range \(clippedHashRange) out of bounds (0 ..< \(pendingReadyPart.fetchedData.count))") + checkFailed = true + break + } + clippedHashRange = clippedHashRange.lowerBound ..< min(clippedHashRange.upperBound, pendingReadyPart.fetchRange.lowerBound + Int64(pendingReadyPart.fetchedData.count)) + + let partLocalHashRange = (clippedHashRange.lowerBound - pendingReadyPart.fetchRange.lowerBound) ..< (clippedHashRange.upperBound - pendingReadyPart.fetchRange.lowerBound) + + if partLocalHashRange.lowerBound < 0 || partLocalHashRange.upperBound > pendingReadyPart.fetchedData.count { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): unable to check \(pendingReadyPart.fetchRange): data range \(partLocalHashRange) out of bounds (0 ..< \(pendingReadyPart.fetchedData.count))") + checkFailed = true + break + } + + let dataToHash = pendingReadyPart.cleanData.subdata(in: Int(partLocalHashRange.lowerBound) ..< Int(partLocalHashRange.upperBound)) + let localHash = MTSha256(dataToHash) + if localHash != hashRange.data { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): failed to verify \(pendingReadyPart.fetchRange): hash mismatch") + checkFailed = true + break + } + + checkOffset += partLocalHashRange.upperBound - partLocalHashRange.lowerBound + } else { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): unable to find \(pendingReadyPart.fetchRange) hash range despite it being marked as ready") + checkFailed = true + break + } + } + if !checkFailed { + self.commitPendingReadyPart(state: state, partRange: pendingReadyPart.partRange, fetchRange: pendingReadyPart.fetchRange, data: pendingReadyPart.cleanData) + } else { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): unable to find \(pendingReadyPart.fetchRange) hash check failed") + } } } } - for index in removedPendingReadyPartIndices.reversed() { + for index in removedPendingReadyPartIndices.sorted(by: >) { state.pendingReadyParts.remove(at: index) } } @@ -452,7 +524,9 @@ private final class FetchImpl { var requiredHashRanges = RangeSet() for pendingReadyPart in state.pendingReadyParts { //TODO:check if already have hashes - requiredHashRanges.formUnion(RangeSet(pendingReadyPart.fetchRange)) + if state.decryptionState == nil { + requiredHashRanges.formUnion(RangeSet(pendingReadyPart.fetchRange)) + } } requiredHashRanges.subtract(state.completedHashRanges) for pendingHashRange in state.pendingHashRanges { @@ -613,7 +687,8 @@ private final class FetchImpl { maxPartSize: self.cdnPartSize * 2, partAlignment: self.cdnPartSize, partDivision: 1 * 1024 * 1024, - maxPendingParts: 6 + maxPendingParts: 6, + decryptionState: nil )) self.update() }, error: { [weak self] error in @@ -661,7 +736,8 @@ private final class FetchImpl { maxPartSize: self.defaultPartSize, partAlignment: 4 * 1024, partDivision: 1 * 1024 * 1024, - maxPendingParts: 6 + maxPendingParts: 6, + decryptionState: nil )) self.update() @@ -819,7 +895,16 @@ private final class FetchImpl { partRange: partRange, fetchRange: fetchRange, fetchedData: verifyPartHashData.fetchedData, - decryptedData: data + cleanData: data + )) + } else if state.decryptionState != nil { + Logger.shared.log("FetchV2", "\(self.loggingIdentifier): stashing data part \(partRange) (aligned as \(fetchRange)) for decryption") + + state.pendingReadyParts.append(FetchImpl.PendingReadyPart( + partRange: partRange, + fetchRange: fetchRange, + fetchedData: data, + cleanData: data )) } else { self.commitPendingReadyPart( @@ -837,7 +922,8 @@ private final class FetchImpl { maxPartSize: self.cdnPartSize * 2, partAlignment: self.cdnPartSize, partDivision: 1 * 1024 * 1024, - maxPendingParts: 6 + maxPendingParts: 6, + decryptionState: nil )) case let .cdnRefresh(cdnData, refreshToken): self.state = .reuploadingToCdn(ReuploadingToCdnState( diff --git a/submodules/TelegramCore/Sources/Network/MultipartFetch.swift b/submodules/TelegramCore/Sources/Network/MultipartFetch.swift index fcae6d0bc7..6f944d4769 100644 --- a/submodules/TelegramCore/Sources/Network/MultipartFetch.swift +++ b/submodules/TelegramCore/Sources/Network/MultipartFetch.swift @@ -1115,7 +1115,7 @@ func multipartFetch( continueInBackground: Bool = false, useMainConnection: Bool = false ) -> Signal { - if network.useExperimentalFeatures, let _ = resource as? TelegramCloudMediaResource, !(resource is SecretFileMediaResource) { + if network.useExperimentalFeatures, let _ = resource as? TelegramCloudMediaResource { return multipartFetchV2( accountPeerId: accountPeerId, postbox: postbox, diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/BUILD b/submodules/TelegramUI/Components/Calls/CallScreen/BUILD index 6773bd07e8..9f2a634bda 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/BUILD +++ b/submodules/TelegramUI/Components/Calls/CallScreen/BUILD @@ -69,6 +69,7 @@ swift_library( "//submodules/AppBundle", "//submodules/UIKitRuntimeUtils", "//submodules/TelegramPresentationData", + "//submodules/Components/MultilineTextComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift b/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift index d963c523a2..1f00632fb8 100644 --- a/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift +++ b/submodules/TelegramUI/Components/ShareExtensionContext/Sources/ShareExtensionContext.swift @@ -317,6 +317,10 @@ public class ShareRootControllerImpl { presentationDataPromise.set(.single(presentationData)) var immediatePeerId: PeerId? + #if DEBUG + // Xcode crashes + immediatePeerId = nil + #else if #available(iOS 13.2, *), let sendMessageIntent = self.getExtensionContext()?.intent as? INSendMessageIntent { if let contact = sendMessageIntent.recipients?.first, let handle = contact.customIdentifier, handle.hasPrefix("tg") { let string = handle.suffix(from: handle.index(handle.startIndex, offsetBy: 2)) @@ -325,6 +329,7 @@ public class ShareRootControllerImpl { } } } + #endif /*let account: Signal<(SharedAccountContextImpl, Account, [AccountWithInfo]), ShareAuthorizationError> = internalContext.sharedContext.accountManager.transaction { transaction -> (SharedAccountContextImpl, LoggingSettings) in return (internalContext.sharedContext, transaction.getSharedData(SharedDataKeys.loggingSettings)?.get(LoggingSettings.self) ?? LoggingSettings.defaultSettings) diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index e43cf24cba..d96b85c4c6 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -885,48 +885,57 @@ public final class SharedAccountContextImpl: SharedAccountContext { self.groupCallDisposable = (callManager.currentGroupCallSignal |> deliverOnMainQueue).start(next: { [weak self] call in if let strongSelf = self { - if call !== strongSelf.groupCallController?.call { - strongSelf.groupCallController?.dismiss(closing: true, manual: false) - strongSelf.groupCallController = nil - strongSelf.hasOngoingCall.set(false) - - if let call = call, let navigationController = mainWindow.viewController as? NavigationController { - mainWindow.hostView.containerView.endEditing(true) + if strongSelf.immediateExperimentalUISettings.conferenceCalls { + let mappedCall = call.flatMap(CallController.Call.groupCall) + if mappedCall != strongSelf.callController?.call { + strongSelf.callController?.dismiss() + strongSelf.callController = nil + strongSelf.hasOngoingCall.set(false) - if call.isStream { + if let call { + mainWindow.hostView.containerView.endEditing(true) + strongSelf.hasGroupCallOnScreenPromise.set(true) - let groupCallController = MediaStreamComponentController(call: call) - groupCallController.onViewDidAppear = { [weak self] in - if let strongSelf = self { + + let callController = CallController(sharedContext: strongSelf, account: call.accountContext.account, call: .groupCall(call), easyDebugAccess: !GlobalExperimentalSettings.isAppStoreBuild) + + callController.onViewDidAppear = { [weak strongSelf] in + if let strongSelf { strongSelf.hasGroupCallOnScreenPromise.set(true) } } - groupCallController.onViewDidDisappear = { [weak self] in - if let strongSelf = self { + callController.onViewDidDisappear = { [weak strongSelf] in + if let strongSelf { strongSelf.hasGroupCallOnScreenPromise.set(false) } } - groupCallController.navigationPresentation = .flatModal - groupCallController.parentNavigationController = navigationController - strongSelf.groupCallController = groupCallController - navigationController.pushViewController(groupCallController) - } else { - strongSelf.hasGroupCallOnScreenPromise.set(true) + strongSelf.callController = callController + strongSelf.mainWindow?.present(callController, on: .calls) - let _ = (makeVoiceChatControllerInitialData(sharedContext: strongSelf, accountContext: call.accountContext, call: call) - |> deliverOnMainQueue).start(next: { [weak strongSelf, weak navigationController] initialData in - guard let strongSelf, let navigationController else { - return - } - - let groupCallController = makeVoiceChatController(sharedContext: strongSelf, accountContext: call.accountContext, call: call, initialData: initialData) - groupCallController.onViewDidAppear = { [weak strongSelf] in - if let strongSelf { + strongSelf.hasOngoingCall.set(true) + } else { + strongSelf.hasOngoingCall.set(false) + } + } + } else { + if call !== strongSelf.groupCallController?.call { + strongSelf.groupCallController?.dismiss(closing: true, manual: false) + strongSelf.groupCallController = nil + strongSelf.hasOngoingCall.set(false) + + if let call = call, let navigationController = mainWindow.viewController as? NavigationController { + mainWindow.hostView.containerView.endEditing(true) + + if call.isStream { + strongSelf.hasGroupCallOnScreenPromise.set(true) + let groupCallController = MediaStreamComponentController(call: call) + groupCallController.onViewDidAppear = { [weak self] in + if let strongSelf = self { strongSelf.hasGroupCallOnScreenPromise.set(true) } } - groupCallController.onViewDidDisappear = { [weak strongSelf] in - if let strongSelf { + groupCallController.onViewDidDisappear = { [weak self] in + if let strongSelf = self { strongSelf.hasGroupCallOnScreenPromise.set(false) } } @@ -934,12 +943,37 @@ public final class SharedAccountContextImpl: SharedAccountContext { groupCallController.parentNavigationController = navigationController strongSelf.groupCallController = groupCallController navigationController.pushViewController(groupCallController) - }) - } + } else { + strongSelf.hasGroupCallOnScreenPromise.set(true) + + let _ = (makeVoiceChatControllerInitialData(sharedContext: strongSelf, accountContext: call.accountContext, call: call) + |> deliverOnMainQueue).start(next: { [weak strongSelf, weak navigationController] initialData in + guard let strongSelf, let navigationController else { + return + } + + let groupCallController = makeVoiceChatController(sharedContext: strongSelf, accountContext: call.accountContext, call: call, initialData: initialData) + groupCallController.onViewDidAppear = { [weak strongSelf] in + if let strongSelf { + strongSelf.hasGroupCallOnScreenPromise.set(true) + } + } + groupCallController.onViewDidDisappear = { [weak strongSelf] in + if let strongSelf { + strongSelf.hasGroupCallOnScreenPromise.set(false) + } + } + groupCallController.navigationPresentation = .flatModal + groupCallController.parentNavigationController = navigationController + strongSelf.groupCallController = groupCallController + navigationController.pushViewController(groupCallController) + }) + } - strongSelf.hasOngoingCall.set(true) - } else { - strongSelf.hasOngoingCall.set(false) + strongSelf.hasOngoingCall.set(true) + } else { + strongSelf.hasOngoingCall.set(false) + } } } } @@ -1178,7 +1212,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { } if let currentCallController = self.callController { - if currentCallController.call === call { + if currentCallController.call == .call(call) { self.navigateToCurrentCall() return } else { @@ -1188,7 +1222,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { } self.mainWindow?.hostView.containerView.endEditing(true) - let callController = CallController(sharedContext: self, account: call.context.account, call: call, easyDebugAccess: !GlobalExperimentalSettings.isAppStoreBuild) + let callController = CallController(sharedContext: self, account: call.context.account, call: .call(call), easyDebugAccess: !GlobalExperimentalSettings.isAppStoreBuild) self.callController = callController callController.restoreUIForPictureInPicture = { [weak self, weak callController] completion in guard let self, let callController else { diff --git a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift index 479963e575..db8fa85144 100644 --- a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift +++ b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift @@ -62,6 +62,7 @@ public struct ExperimentalUISettings: Codable, Equatable { public var dynamicStreaming: Bool public var enableLocalTranslation: Bool public var autoBenchmarkReflectors: Bool? + public var conferenceCalls: Bool public static var defaultSettings: ExperimentalUISettings { return ExperimentalUISettings( @@ -101,7 +102,8 @@ public struct ExperimentalUISettings: Codable, Equatable { liveStreamV2: false, dynamicStreaming: false, enableLocalTranslation: false, - autoBenchmarkReflectors: nil + autoBenchmarkReflectors: nil, + conferenceCalls: false ) } @@ -142,7 +144,8 @@ public struct ExperimentalUISettings: Codable, Equatable { liveStreamV2: Bool, dynamicStreaming: Bool, enableLocalTranslation: Bool, - autoBenchmarkReflectors: Bool? + autoBenchmarkReflectors: Bool?, + conferenceCalls: Bool ) { self.keepChatNavigationStack = keepChatNavigationStack self.skipReadHistory = skipReadHistory @@ -181,6 +184,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.dynamicStreaming = dynamicStreaming self.enableLocalTranslation = enableLocalTranslation self.autoBenchmarkReflectors = autoBenchmarkReflectors + self.conferenceCalls = conferenceCalls } public init(from decoder: Decoder) throws { @@ -223,6 +227,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.dynamicStreaming = try container.decodeIfPresent(Bool.self, forKey: "dynamicStreaming_v2") ?? false self.enableLocalTranslation = try container.decodeIfPresent(Bool.self, forKey: "enableLocalTranslation") ?? false self.autoBenchmarkReflectors = try container.decodeIfPresent(Bool.self, forKey: "autoBenchmarkReflectors") + self.conferenceCalls = try container.decodeIfPresent(Bool.self, forKey: "conferenceCalls") ?? false } public func encode(to encoder: Encoder) throws { @@ -265,6 +270,7 @@ public struct ExperimentalUISettings: Codable, Equatable { try container.encode(self.dynamicStreaming, forKey: "dynamicStreaming") try container.encode(self.enableLocalTranslation, forKey: "enableLocalTranslation") try container.encodeIfPresent(self.autoBenchmarkReflectors, forKey: "autoBenchmarkReflectors") + try container.encodeIfPresent(self.conferenceCalls, forKey: "conferenceCalls") } }