import Foundation import UIKit import Display import AsyncDisplayKit import Postbox import TelegramCore import SyncCore import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import TelegramAudio import AccountContext import LocalizedPeerData import PhotoResources import CallsEmoji import TooltipUI import AlertUI import PresentationDataUtils import DeviceAccess private func interpolateFrame(from fromValue: CGRect, to toValue: CGRect, t: CGFloat) -> CGRect { return CGRect(x: floorToScreenPixels(toValue.origin.x * t + fromValue.origin.x * (1.0 - t)), y: floorToScreenPixels(toValue.origin.y * t + fromValue.origin.y * (1.0 - t)), width: floorToScreenPixels(toValue.size.width * t + fromValue.size.width * (1.0 - t)), height: floorToScreenPixels(toValue.size.height * t + fromValue.size.height * (1.0 - t))) } private func interpolate(from: CGFloat, to: CGFloat, value: CGFloat) -> CGFloat { return (1.0 - value) * from + value * to } private final class CallVideoNode: ASDisplayNode { private let videoTransformContainer: ASDisplayNode private let videoView: PresentationCallVideoView private var effectView: UIVisualEffectView? private let videoPausedNode: ImmediateTextNode private var isBlurred: Bool = false private var currentCornerRadius: CGFloat = 0.0 private let isReadyUpdated: () -> Void private(set) var isReady: Bool = false private var isReadyTimer: SwiftSignalKit.Timer? private let isFlippedUpdated: (CallVideoNode) -> Void private(set) var currentOrientation: PresentationCallVideoView.Orientation private(set) var currentAspect: CGFloat = 0.0 private var previousVideoHeight: CGFloat? init(videoView: PresentationCallVideoView, disabledText: String?, assumeReadyAfterTimeout: Bool, isReadyUpdated: @escaping () -> Void, orientationUpdated: @escaping () -> Void, isFlippedUpdated: @escaping (CallVideoNode) -> Void) { self.isReadyUpdated = isReadyUpdated self.isFlippedUpdated = isFlippedUpdated self.videoTransformContainer = ASDisplayNode() self.videoView = videoView videoView.view.clipsToBounds = true videoView.view.backgroundColor = .black self.currentOrientation = videoView.getOrientation() self.currentAspect = videoView.getAspect() self.videoPausedNode = ImmediateTextNode() self.videoPausedNode.alpha = 0.0 self.videoPausedNode.maximumNumberOfLines = 3 super.init() self.backgroundColor = .black self.clipsToBounds = true if #available(iOS 13.0, *) { self.layer.cornerCurve = .continuous } self.videoTransformContainer.view.addSubview(self.videoView.view) self.addSubnode(self.videoTransformContainer) if let disabledText = disabledText { self.videoPausedNode.attributedText = NSAttributedString(string: disabledText, font: Font.regular(17.0), textColor: .white) self.addSubnode(self.videoPausedNode) } self.videoView.setOnFirstFrameReceived { [weak self] aspectRatio in Queue.mainQueue().async { guard let strongSelf = self else { return } if !strongSelf.isReady { strongSelf.isReady = true strongSelf.isReadyTimer?.invalidate() strongSelf.isReadyUpdated() } } } self.videoView.setOnOrientationUpdated { [weak self] orientation, aspect in Queue.mainQueue().async { guard let strongSelf = self else { return } if strongSelf.currentOrientation != orientation || strongSelf.currentAspect != aspect { strongSelf.currentOrientation = orientation strongSelf.currentAspect = aspect orientationUpdated() } } } self.videoView.setOnIsMirroredUpdated { [weak self] _ in Queue.mainQueue().async { guard let strongSelf = self else { return } strongSelf.isFlippedUpdated(strongSelf) } } if assumeReadyAfterTimeout { self.isReadyTimer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in guard let strongSelf = self else { return } if !strongSelf.isReady { strongSelf.isReady = true strongSelf.isReadyUpdated() } }, queue: .mainQueue()) } self.isReadyTimer?.start() } deinit { self.isReadyTimer?.invalidate() } override func didLoad() { super.didLoad() if #available(iOS 13.0, *) { self.layer.cornerCurve = .continuous } } func animateRadialMask(from fromRect: CGRect, to toRect: CGRect) { let maskLayer = CAShapeLayer() maskLayer.frame = fromRect let path = CGMutablePath() path.addEllipse(in: CGRect(origin: CGPoint(), size: fromRect.size)) maskLayer.path = path self.layer.mask = maskLayer let topLeft = CGPoint(x: 0.0, y: 0.0) let topRight = CGPoint(x: self.bounds.width, y: 0.0) let bottomLeft = CGPoint(x: 0.0, y: self.bounds.height) let bottomRight = CGPoint(x: self.bounds.width, y: self.bounds.height) func distance(_ v1: CGPoint, _ v2: CGPoint) -> CGFloat { let dx = v1.x - v2.x let dy = v1.y - v2.y return sqrt(dx * dx + dy * dy) } var maxRadius = distance(toRect.center, topLeft) maxRadius = max(maxRadius, distance(toRect.center, topRight)) maxRadius = max(maxRadius, distance(toRect.center, bottomLeft)) maxRadius = max(maxRadius, distance(toRect.center, bottomRight)) maxRadius = ceil(maxRadius) let targetFrame = CGRect(origin: CGPoint(x: toRect.center.x - maxRadius, y: toRect.center.y - maxRadius), size: CGSize(width: maxRadius * 2.0, height: maxRadius * 2.0)) let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) transition.updatePosition(layer: maskLayer, position: targetFrame.center) transition.updateTransformScale(layer: maskLayer, scale: maxRadius * 2.0 / fromRect.width, completion: { [weak self] _ in self?.layer.mask = nil }) } func updateLayout(size: CGSize, cornerRadius: CGFloat, isOutgoing: Bool, deviceOrientation: UIDeviceOrientation, isCompactLayout: Bool, transition: ContainedViewLayoutTransition) { self.currentCornerRadius = cornerRadius var rotationAngle: CGFloat if isOutgoing && isCompactLayout { rotationAngle = CGFloat.pi / 2.0 } else { switch self.currentOrientation { case .rotation0: rotationAngle = 0.0 case .rotation90: rotationAngle = CGFloat.pi / 2.0 case .rotation180: if isCompactLayout { rotationAngle = CGFloat.pi } else { rotationAngle = 0.0 } case .rotation270: if isCompactLayout { rotationAngle = -CGFloat.pi / 2.0 } else { rotationAngle = CGFloat.pi / 2.0 } } var additionalAngle: CGFloat = 0.0 switch deviceOrientation { case .portrait: additionalAngle = 0.0 case .landscapeLeft: additionalAngle = CGFloat.pi / 2.0 case .landscapeRight: additionalAngle = -CGFloat.pi / 2.0 case .portraitUpsideDown: rotationAngle = CGFloat.pi default: additionalAngle = 0.0 } rotationAngle += additionalAngle if abs(rotationAngle - (-CGFloat.pi)) < 1.0 { rotationAngle = -CGFloat.pi + 0.001 } } let rotateFrame = abs(rotationAngle.remainder(dividingBy: CGFloat.pi)) > 1.0 let fittingSize: CGSize if rotateFrame { fittingSize = CGSize(width: size.height, height: size.width) } else { fittingSize = size } let unboundVideoSize = CGSize(width: self.currentAspect * 10000.0, height: 10000.0) var fittedVideoSize = unboundVideoSize.fitted(fittingSize) if fittedVideoSize.width < fittingSize.width || fittedVideoSize.height < fittingSize.height { let isVideoPortrait = unboundVideoSize.width < unboundVideoSize.height let isFittingSizePortrait = fittingSize.width < fittingSize.height if isCompactLayout && isVideoPortrait == isFittingSizePortrait { fittedVideoSize = unboundVideoSize.aspectFilled(fittingSize) } else { let maxFittingEdgeDistance: CGFloat if isCompactLayout { maxFittingEdgeDistance = 200.0 } else { maxFittingEdgeDistance = 400.0 } if fittedVideoSize.width > fittingSize.width - maxFittingEdgeDistance && fittedVideoSize.height > fittingSize.height - maxFittingEdgeDistance { fittedVideoSize = unboundVideoSize.aspectFilled(fittingSize) } } } let rotatedVideoHeight: CGFloat = max(fittedVideoSize.height, fittedVideoSize.width) let videoFrame: CGRect = CGRect(origin: CGPoint(), size: fittedVideoSize) let videoPausedSize = self.videoPausedNode.updateLayout(CGSize(width: size.width - 16.0, height: 100.0)) transition.updateFrame(node: self.videoPausedNode, frame: CGRect(origin: CGPoint(x: floor((size.width - videoPausedSize.width) / 2.0), y: floor((size.height - videoPausedSize.height) / 2.0)), size: videoPausedSize)) self.videoTransformContainer.bounds = CGRect(origin: CGPoint(), size: videoFrame.size) if transition.isAnimated && !videoFrame.height.isZero, let previousVideoHeight = self.previousVideoHeight, !previousVideoHeight.isZero { let scaleDifference = previousVideoHeight / rotatedVideoHeight if abs(scaleDifference - 1.0) > 0.001 { transition.animateTransformScale(node: self.videoTransformContainer, from: scaleDifference, additive: true) } } self.previousVideoHeight = rotatedVideoHeight transition.updatePosition(node: self.videoTransformContainer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) transition.updateTransformRotation(view: self.videoTransformContainer.view, angle: rotationAngle) let localVideoFrame = CGRect(origin: CGPoint(), size: videoFrame.size) self.videoView.view.bounds = localVideoFrame self.videoView.view.center = localVideoFrame.center // TODO: properly fix the issue // On iOS 13 and later metal layer transformation is broken if the layer does not require compositing self.videoView.view.alpha = 0.995 if let effectView = self.effectView { transition.updateFrame(view: effectView, frame: localVideoFrame) } transition.updateCornerRadius(layer: self.layer, cornerRadius: self.currentCornerRadius) } func updateIsBlurred(isBlurred: Bool, light: Bool = false, animated: Bool = true) { if self.isBlurred == isBlurred { return } self.isBlurred = isBlurred if isBlurred { if self.effectView == nil { let effectView = UIVisualEffectView() self.effectView = effectView effectView.frame = self.videoTransformContainer.bounds self.videoTransformContainer.view.addSubview(effectView) } if animated { UIView.animate(withDuration: 0.3, animations: { self.videoPausedNode.alpha = 1.0 self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark) }) } else { self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark) } } else if let effectView = self.effectView { self.effectView = nil UIView.animate(withDuration: 0.3, animations: { self.videoPausedNode.alpha = 0.0 effectView.effect = nil }, completion: { [weak effectView] _ in effectView?.removeFromSuperview() }) } } func flip(withBackground: Bool) { if withBackground { self.backgroundColor = .black } UIView.transition(with: withBackground ? self.videoTransformContainer.view : self.view, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: { UIView.performWithoutAnimation { self.updateIsBlurred(isBlurred: true, light: true, animated: false) } }) { finished in self.backgroundColor = nil Queue.mainQueue().after(0.5) { self.updateIsBlurred(isBlurred: false) } } } } 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 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 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.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() 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.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.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: 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() } if strongSelf.displayedCameraConfirmation { proceed() } else { strongSelf.present?(textAlertController(sharedContext: strongSelf.sharedContext, title: nil, text: strongSelf.presentationData.strings.Call_CameraConfirmationText, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Call_CameraConfirmationConfirm, action: { proceed() })])) } }) } 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)) } } }) } 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(text: self.presentationData.strings.Call_CameraTooltip, 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 = peer.compactDisplayTitle self.statusNode.title = peer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder) if hasOther { self.statusNode.subtitle = self.presentationData.strings.Call_AnsweringWithAccount(accountPeer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)).0 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: 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.containerNode.insertSubnode(incomingVideoNode, belowSubnode: expandedVideoNode) } else { strongSelf.containerNode.insertSubnode(incomingVideoNode, belowSubnode: strongSelf.dimNode) } 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?.compactDisplayTitle ?? "").0, 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, .paused: 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.containerNode.insertSubnode(outgoingVideoNode, aboveSubnode: expandedVideoNode) } else { strongSelf.expandedVideoNode = outgoingVideoNode strongSelf.containerNode.insertSubnode(outgoingVideoNode, belowSubnode: strongSelf.dimNode) } 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() } } }) } case .notAvailable, .inactive: 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(peer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)).0 } else { text = self.presentationData.strings.Call_ParticipantVersionOutdatedError(peer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)).0 } 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)! 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) } } 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, 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, .paused: mappedVideoState.isAvailable = true mappedVideoState.canChangeStatus = 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 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 = uiDisplayTransition var toastAlpha: CGFloat = 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.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) if let keyPreviewNode = self.keyPreviewNode { transition.updateFrame(node: keyPreviewNode, frame: CGRect(origin: CGPoint(), size: layout.size)) keyPreviewNode.updateLayout(size: layout.size, transition: .immediate) } transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: layout.size)) 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 = CGRect(origin: CGPoint(), size: layout.size) 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: pipTransitionAlpha) 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: false, 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(peer.compactDisplayTitle).0.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) } }