From 063de1d61becf390f33e52b280890cd915c7f8a4 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Thu, 2 Mar 2023 20:36:05 +0300 Subject: [PATCH 01/11] TELEGRAM-[added new controls with views] --- .../Sources/PinchViewController.swift | 383 +++ .../ContainedViewLayoutTransition.swift | 95 + .../Source/ViewControllerTracingNode.swift | 6 +- .../{ => CallController}/CallController.swift | 26 - .../CallControllerButtonsNode.swift | 0 .../CallControllerKeyPreviewNode.swift | 0 .../CallControllerNode.swift | 0 .../CallControllerNodeProtocol.swift | 32 + .../CallControllerStatusNode.swift | 0 .../LegacyCallControllerNode.swift | 0 .../CallControllerButtonsView.swift | 605 +++++ .../CallControllerKeyPreviewView.swift | 108 + .../CallControllerStatusView.swift | 250 ++ .../CallControllerView.swift | 2282 +++++++++++++++++ .../CallViewController.swift | 446 ++++ ...VoiceChatCameraPreviewViewController.swift | 701 +++++ .../VoiceChatTileItemView.swift | 877 +++++++ .../Sources/SharedAccountContext.swift | 67 +- 18 files changed, 5832 insertions(+), 46 deletions(-) create mode 100644 submodules/ContextUI/Sources/PinchViewController.swift rename submodules/TelegramCallsUI/Sources/{ => CallController}/CallController.swift (92%) rename submodules/TelegramCallsUI/Sources/{ => CallController}/CallControllerButtonsNode.swift (100%) rename submodules/TelegramCallsUI/Sources/{ => CallController}/CallControllerKeyPreviewNode.swift (100%) rename submodules/TelegramCallsUI/Sources/{ => CallController}/CallControllerNode.swift (100%) create mode 100644 submodules/TelegramCallsUI/Sources/CallController/CallControllerNodeProtocol.swift rename submodules/TelegramCallsUI/Sources/{ => CallController}/CallControllerStatusNode.swift (100%) rename submodules/TelegramCallsUI/Sources/{ => CallController}/LegacyCallControllerNode.swift (100%) create mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift create mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/CallControllerKeyPreviewView.swift create mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/CallControllerStatusView.swift create mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift create mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift create mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift create mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatTileItemView.swift diff --git a/submodules/ContextUI/Sources/PinchViewController.swift b/submodules/ContextUI/Sources/PinchViewController.swift new file mode 100644 index 00000000000..0aea3d888b8 --- /dev/null +++ b/submodules/ContextUI/Sources/PinchViewController.swift @@ -0,0 +1,383 @@ +import AsyncDisplayKit +import Foundation +import UIKit +import Display +import TelegramPresentationData +import TextSelectionNode +import TelegramCore +import SwiftSignalKit + +private func cancelContextGestures(sourceView: UIView) { + if let view = sourceView as? ContextControllerSourceView { + view.cancelGesture() + } + + if let superview = sourceView.superview { + cancelContextGestures(view: superview) + } +} + +private func cancelContextGestures(view: UIView) { + if let gestureRecognizers = view.gestureRecognizers { + for recognizer in gestureRecognizers { + if let recognizer = recognizer as? InteractiveTransitionGestureRecognizer { + recognizer.cancel() + } else if let recognizer = recognizer as? WindowPanRecognizer { + recognizer.cancel() + } + } + } + + if let superview = view.superview { + cancelContextGestures(view: superview) + } +} + +public final class PinchSourceContainerView: UIView, UIGestureRecognizerDelegate { + public let contentView: UIView + public var contentRect: CGRect = CGRect() + private(set) var naturalContentFrame: CGRect? + + fileprivate let gesture: PinchSourceGesture + fileprivate var panGesture: UIPanGestureRecognizer? + + public var isPinchGestureEnabled: Bool = true { + didSet { + if self.isPinchGestureEnabled != oldValue { + self.gesture.isEnabled = self.isPinchGestureEnabled + } + } + } + + public var maxPinchScale: CGFloat = 10.0 + + private var isActive: Bool = false + + public var activate: ((PinchSourceContainerView) -> Void)? + public var scaleUpdated: ((CGFloat, ContainedViewLayoutTransition) -> Void)? + public var animatedOut: (() -> Void)? + var deactivate: (() -> Void)? + public var deactivated: (() -> Void)? + var updated: ((CGFloat, CGPoint, CGPoint) -> Void)? + + public init() { + self.gesture = PinchSourceGesture() + self.contentView = UIView() + + super.init(frame: CGRect.zero) + + self.addSubview(self.contentView) + + self.gesture.began = { [weak self] in + guard let strongSelf = self else { + return + } + cancelContextGestures(sourceView: strongSelf) + strongSelf.isActive = true + + strongSelf.activate?(strongSelf) + } + + self.gesture.ended = { [weak self] in + guard let strongSelf = self else { + return + } + + strongSelf.isActive = false + strongSelf.deactivate?() + strongSelf.deactivated?() + } + + self.gesture.updated = { [weak self] scale, pinchLocation, offset in + guard let strongSelf = self else { + return + } + strongSelf.updated?(min(scale, strongSelf.maxPinchScale), pinchLocation, offset) + strongSelf.scaleUpdated?(min(scale, strongSelf.maxPinchScale), .immediate) + } + + self.addGestureRecognizer(self.gesture) + self.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in + guard let strongSelf = self else { + return false + } + return strongSelf.isActive + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) { + } + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } + + public func update(size: CGSize, transition: ContainedViewLayoutTransition) { + let contentFrame = CGRect(origin: CGPoint(), size: size) + self.naturalContentFrame = contentFrame + if !self.isActive { + transition.updateFrame(view: self.contentView, frame: contentFrame) + } + } + + func restoreToNaturalSize() { + guard let naturalContentFrame = self.naturalContentFrame else { + return + } + self.contentView.frame = naturalContentFrame + } +} + +private final class PinchControllerView: ViewControllerTracingNodeView { + private weak var controller: PinchViewController? + + private var initialSourceFrame: CGRect? + + private let clippingNode: UIView + private let scrollingContainer: UIView + + private let sourceNode: PinchSourceContainerView + private let getContentAreaInScreenSpace: () -> CGRect + + private let dimNode: UIView + + private var validLayout: ContainerViewLayout? + private var isAnimatingOut: Bool = false + + private var hapticFeedback: HapticFeedback? + + init(controller: PinchViewController, sourceNode: PinchSourceContainerView, getContentAreaInScreenSpace: @escaping () -> CGRect) { + self.controller = controller + self.sourceNode = sourceNode + self.getContentAreaInScreenSpace = getContentAreaInScreenSpace + + self.dimNode = UIView() + self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + self.dimNode.alpha = 0.0 + + self.clippingNode = UIView() + self.clippingNode.clipsToBounds = true + + self.scrollingContainer = UIView() + + super.init(frame: CGRect.zero) + + self.addSubview(self.dimNode) + self.addSubview(self.clippingNode) + self.clippingNode.addSubview(self.scrollingContainer) + + self.sourceNode.deactivate = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.controller?.dismiss() + } + + self.sourceNode.updated = { [weak self] scale, pinchLocation, offset in + guard let strongSelf = self, let initialSourceFrame = strongSelf.initialSourceFrame else { + return + } + strongSelf.dimNode.alpha = max(0.0, min(1.0, scale - 1.0)) + + let pinchOffset = CGPoint( + x: pinchLocation.x - initialSourceFrame.width / 2.0, + y: pinchLocation.y - initialSourceFrame.height / 2.0 + ) + + var transform = CATransform3DIdentity + transform = CATransform3DTranslate(transform, offset.x - pinchOffset.x * (scale - 1.0), offset.y - pinchOffset.y * (scale - 1.0), 0.0) + transform = CATransform3DScale(transform, scale, scale, 0.0) + + strongSelf.sourceNode.contentView.layer.transform = transform + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition, previousActionsContainerNode: ContextActionsContainerNode?) { + if self.isAnimatingOut { + return + } + + self.validLayout = layout + + transition.updateFrame(view: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + transition.updateFrame(view: self.clippingNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + } + + func animateIn() { + let convertedFrame = convertFrame(self.sourceNode.bounds, from: self.sourceNode, to: self) + self.sourceNode.contentView.frame = convertedFrame + self.initialSourceFrame = convertedFrame + self.scrollingContainer.addSubview(self.sourceNode.contentView) + + var updatedContentAreaInScreenSpace = self.getContentAreaInScreenSpace() + updatedContentAreaInScreenSpace.origin.x = 0.0 + updatedContentAreaInScreenSpace.size.width = self.bounds.width + + self.clippingNode.layer.animateFrame(from: updatedContentAreaInScreenSpace, to: self.clippingNode.frame, duration: 0.18 * 1.0, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) + self.clippingNode.layer.animateBoundsOriginYAdditive(from: updatedContentAreaInScreenSpace.minY, to: 0.0, duration: 0.18 * 1.0, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) + } + + func animateOut(completion: @escaping () -> Void) { + self.isAnimatingOut = true + + let performCompletion: () -> Void = { [weak self] in + guard let strongSelf = self else { + return + } + + strongSelf.isAnimatingOut = false + + strongSelf.sourceNode.restoreToNaturalSize() + strongSelf.sourceNode.addSubview(strongSelf.sourceNode.contentView) + + strongSelf.sourceNode.animatedOut?() + + completion() + } + + let convertedFrame = convertFrame(self.sourceNode.bounds, from: self.sourceNode, to: self) + self.sourceNode.contentView.frame = convertedFrame + self.initialSourceFrame = convertedFrame + + if let (scale, pinchLocation, offset) = self.sourceNode.gesture.currentTransform, let initialSourceFrame = self.initialSourceFrame { + let duration = 0.3 + let transitionCurve: ContainedViewLayoutTransitionCurve = .easeInOut + + var updatedContentAreaInScreenSpace = self.getContentAreaInScreenSpace() + updatedContentAreaInScreenSpace.origin.x = 0.0 + updatedContentAreaInScreenSpace.size.width = self.bounds.width + + self.clippingNode.layer.animateFrame(from: self.clippingNode.frame, to: updatedContentAreaInScreenSpace, duration: duration * 1.0, timingFunction: transitionCurve.timingFunction, removeOnCompletion: false) + self.clippingNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: updatedContentAreaInScreenSpace.minY, duration: duration * 1.0, timingFunction: transitionCurve.timingFunction, removeOnCompletion: false) + + let transition: ContainedViewLayoutTransition = .animated(duration: duration, curve: .spring) + if self.hapticFeedback == nil { + self.hapticFeedback = HapticFeedback() + } + self.hapticFeedback?.prepareImpact(.light) + self.hapticFeedback?.impact(.light) + + self.sourceNode.scaleUpdated?(1.0, transition) + + let pinchOffset = CGPoint( + x: pinchLocation.x - initialSourceFrame.width / 2.0, + y: pinchLocation.y - initialSourceFrame.height / 2.0 + ) + + var transform = CATransform3DIdentity + transform = CATransform3DScale(transform, scale, scale, 0.0) + + self.sourceNode.contentView.layer.transform = CATransform3DIdentity + self.sourceNode.contentView.center = CGPoint(x: initialSourceFrame.midX, y: initialSourceFrame.midY) + self.sourceNode.contentView.layer.animateSpring(from: scale as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: duration * 1.2, damping: 110.0) + self.sourceNode.contentView.layer.animatePosition(from: CGPoint(x: offset.x - pinchOffset.x * (scale - 1.0), y: offset.y - pinchOffset.y * (scale - 1.0)), to: CGPoint(), duration: duration, timingFunction: kCAMediaTimingFunctionSpring, additive: true, force: true, completion: { _ in + performCompletion() + }) + + let dimNodeTransition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: transitionCurve) + dimNodeTransition.updateAlpha(view: self.dimNode, alpha: 0.0) + } else { + performCompletion() + } + } + + func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { + if self.isAnimatingOut { + self.scrollingContainer.bounds = self.scrollingContainer.bounds.offsetBy(dx: 0.0, dy: offset.y) + transition.animateOffsetAdditive(view: self.scrollingContainer, offset: -offset.y) + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return nil + } +} + +public final class PinchViewController: ViewController, StandalonePresentableController { + private let _ready = Promise() + override public var ready: Promise { + return self._ready + } + + private let sourceNode: PinchSourceContainerView + private let getContentAreaInScreenSpace: () -> CGRect + + private var wasDismissed = false + + private var controllerView: PinchControllerView! + + public init(sourceNode: PinchSourceContainerView, getContentAreaInScreenSpace: @escaping () -> CGRect) { + self.sourceNode = sourceNode + self.getContentAreaInScreenSpace = getContentAreaInScreenSpace + + super.init(navigationBarPresentationData: nil) + + self.statusBar.statusBarStyle = .Ignore + + self.lockOrientation = true + self.blocksBackgroundWhenInOverlay = true + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + override public func loadDisplayNode() { + self.displayNode = ASDisplayNode() + self.displayNodeDidLoad() + + self._ready.set(.single(true)) + } + + public override func displayNodeDidLoad() { + super.displayNodeDidLoad() + let controllerViewLocal = PinchControllerView(controller: self, sourceNode: sourceNode, getContentAreaInScreenSpace: getContentAreaInScreenSpace) + controllerView = controllerViewLocal + displayNode.view.addSubview(controllerViewLocal) + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerView.updateLayout(layout: layout, transition: transition, previousActionsContainerNode: nil) + } + + override public func viewDidAppear(_ animated: Bool) { + if self.ignoreAppearanceMethodInvocations() { + return + } + super.viewDidAppear(animated) + + self.controllerView.animateIn() + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + controllerView.frame = self.displayNode.view.bounds + } + + override public func dismiss(completion: (() -> Void)? = nil) { + if !self.wasDismissed { + self.wasDismissed = true + self.controllerView.animateOut(completion: { [weak self] in + self?.presentingViewController?.dismiss(animated: false, completion: nil) + completion?() + }) + } + } + + public func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { + self.controllerView.addRelativeContentOffset(offset, transition: transition) + } +} diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index b767121111c..c2ab71ab9d1 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -399,6 +399,34 @@ public extension ContainedViewLayoutTransition { } } } + + func updatePosition(view: UIView, position: CGPoint, beginWithCurrentState: Bool = false, completion: ((Bool) -> Void)? = nil) { + if view.center.equalTo(position) { + completion?(true) + } else { + switch self { + case .immediate: + view.layer.removeAnimation(forKey: "position") + view.center = position + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + let previousPosition: CGPoint + if beginWithCurrentState, view.layer.animation(forKey: "position") != nil, let presentation = view.layer.presentation() { + previousPosition = presentation.position + } else { + previousPosition = view.center + } + view.center = position + view.layer.animatePosition(from: previousPosition, to: position, duration: duration, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction, completion: { result in + if let completion = completion { + completion(result) + } + }) + } + } + } func updatePosition(layer: CALayer, position: CGPoint, force: Bool = false, completion: ((Bool) -> Void)? = nil) { if layer.position.equalTo(position) && !force { @@ -570,6 +598,15 @@ public extension ContainedViewLayoutTransition { node.layer.animateBoundsOriginYAdditive(from: offset, to: 0.0, duration: duration, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction) } } + + func animateOffsetAdditive(view: UIView, offset: CGFloat) { + switch self { + case .immediate: + break + case let .animated(duration, curve): + view.layer.animateBoundsOriginYAdditive(from: offset, to: 0.0, duration: duration, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction) + } + } func animateHorizontalOffsetAdditive(node: ASDisplayNode, offset: CGFloat, completion: (() -> Void)? = nil) { switch self { @@ -765,6 +802,36 @@ public extension ContainedViewLayoutTransition { }) } } + + func updateAlpha(view: UIView, alpha: CGFloat, beginWithCurrentState: Bool = false, force: Bool = false, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { + if view.alpha.isEqual(to: alpha) && !force { + if let completion = completion { + completion(true) + } + return + } + + switch self { + case .immediate: + view.alpha = alpha + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + let previousAlpha: CGFloat + if beginWithCurrentState, let presentation = view.layer.presentation() { + previousAlpha = CGFloat(presentation.opacity) + } else { + previousAlpha = view.alpha + } + view.alpha = alpha + view.layer.animateAlpha(from: previousAlpha, to: alpha, duration: duration, delay: delay, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction, completion: { result in + if let completion = completion { + completion(result) + } + }) + } + } func updateAlpha(layer: CALayer, alpha: CGFloat, completion: ((Bool) -> Void)? = nil) { if layer.opacity.isEqual(to: Float(alpha)) { @@ -1174,6 +1241,34 @@ public extension ContainedViewLayoutTransition { }) } } + + func updateSublayerTransformScale(view: UIView, scale: CGFloat, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { + let t = view.layer.sublayerTransform + let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13)) + if currentScale.isEqual(to: scale) { + if let completion = completion { + completion(true) + } + return + } + + switch self { + case .immediate: + view.layer.removeAnimation(forKey: "sublayerTransform") + view.layer.sublayerTransform = CATransform3DMakeScale(scale, scale, 1.0) + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + view.layer.sublayerTransform = CATransform3DMakeScale(scale, scale, 1.0) + view.layer.animate(from: NSValue(caTransform3D: t), to: NSValue(caTransform3D: view.layer.sublayerTransform), keyPath: "sublayerTransform", timingFunction: curve.timingFunction, duration: duration, delay: delay, mediaTimingFunction: curve.mediaTimingFunction, removeOnCompletion: true, additive: false, completion: { + result in + if let completion = completion { + completion(result) + } + }) + } + } func updateSublayerTransformScaleAdditive(node: ASDisplayNode, scale: CGFloat, completion: ((Bool) -> Void)? = nil) { if !node.isNodeLoaded { diff --git a/submodules/Display/Source/ViewControllerTracingNode.swift b/submodules/Display/Source/ViewControllerTracingNode.swift index 91f678585a4..8f08ddf4577 100644 --- a/submodules/Display/Source/ViewControllerTracingNode.swift +++ b/submodules/Display/Source/ViewControllerTracingNode.swift @@ -2,11 +2,11 @@ import Foundation import UIKit import AsyncDisplayKit -private final class ViewControllerTracingNodeView: UITracingLayerView { +open class ViewControllerTracingNodeView: UITracingLayerView { private var inHitTest = false - var hitTestImpl: ((CGPoint, UIEvent?) -> UIView?)? + open var hitTestImpl: ((CGPoint, UIEvent?) -> UIView?)? - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.inHitTest { return super.hitTest(point, with: event) } else { diff --git a/submodules/TelegramCallsUI/Sources/CallController.swift b/submodules/TelegramCallsUI/Sources/CallController/CallController.swift similarity index 92% rename from submodules/TelegramCallsUI/Sources/CallController.swift rename to submodules/TelegramCallsUI/Sources/CallController/CallController.swift index 5845fc5d365..200179f69f8 100644 --- a/submodules/TelegramCallsUI/Sources/CallController.swift +++ b/submodules/TelegramCallsUI/Sources/CallController/CallController.swift @@ -14,32 +14,6 @@ import TelegramNotices import AppBundle import TooltipUI -protocol CallControllerNodeProtocol: AnyObject { - var isMuted: Bool { get set } - - var toggleMute: (() -> Void)? { get set } - var setCurrentAudioOutput: ((AudioSessionOutput) -> Void)? { get set } - var beginAudioOuputSelection: ((Bool) -> Void)? { get set } - var acceptCall: (() -> Void)? { get set } - var endCall: (() -> Void)? { get set } - var back: (() -> Void)? { get set } - var presentCallRating: ((CallId, Bool) -> Void)? { get set } - var present: ((ViewController) -> Void)? { get set } - var callEnded: ((Bool) -> Void)? { get set } - var dismissedInteractively: (() -> Void)? { get set } - var dismissAllTooltips: (() -> Void)? { get set } - - func updateAudioOutputs(availableOutputs: [AudioSessionOutput], currentOutput: AudioSessionOutput?) - func updateCallState(_ callState: PresentationCallState) - func updatePeer(accountPeer: Peer, peer: Peer, hasOther: Bool) - - func animateIn() - func animateOut(completion: @escaping () -> Void) - func expandFromPipIfPossible() - - func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) -} - public final class CallController: ViewController { private var controllerNode: CallControllerNodeProtocol { return self.displayNode as! CallControllerNodeProtocol diff --git a/submodules/TelegramCallsUI/Sources/CallControllerButtonsNode.swift b/submodules/TelegramCallsUI/Sources/CallController/CallControllerButtonsNode.swift similarity index 100% rename from submodules/TelegramCallsUI/Sources/CallControllerButtonsNode.swift rename to submodules/TelegramCallsUI/Sources/CallController/CallControllerButtonsNode.swift diff --git a/submodules/TelegramCallsUI/Sources/CallControllerKeyPreviewNode.swift b/submodules/TelegramCallsUI/Sources/CallController/CallControllerKeyPreviewNode.swift similarity index 100% rename from submodules/TelegramCallsUI/Sources/CallControllerKeyPreviewNode.swift rename to submodules/TelegramCallsUI/Sources/CallController/CallControllerKeyPreviewNode.swift diff --git a/submodules/TelegramCallsUI/Sources/CallControllerNode.swift b/submodules/TelegramCallsUI/Sources/CallController/CallControllerNode.swift similarity index 100% rename from submodules/TelegramCallsUI/Sources/CallControllerNode.swift rename to submodules/TelegramCallsUI/Sources/CallController/CallControllerNode.swift diff --git a/submodules/TelegramCallsUI/Sources/CallController/CallControllerNodeProtocol.swift b/submodules/TelegramCallsUI/Sources/CallController/CallControllerNodeProtocol.swift new file mode 100644 index 00000000000..1d09d2824b1 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/CallController/CallControllerNodeProtocol.swift @@ -0,0 +1,32 @@ +import AccountContext +import Display +import Foundation +import Postbox +import TelegramAudio +import TelegramCore + +public protocol CallControllerNodeProtocol: AnyObject { + var isMuted: Bool { get set } + + var toggleMute: (() -> Void)? { get set } + var setCurrentAudioOutput: ((AudioSessionOutput) -> Void)? { get set } + var beginAudioOuputSelection: ((Bool) -> Void)? { get set } + var acceptCall: (() -> Void)? { get set } + var endCall: (() -> Void)? { get set } + var back: (() -> Void)? { get set } + var presentCallRating: ((CallId, Bool) -> Void)? { get set } + var present: ((ViewController) -> Void)? { get set } + var callEnded: ((Bool) -> Void)? { get set } + var dismissedInteractively: (() -> Void)? { get set } + var dismissAllTooltips: (() -> Void)? { get set } + + func updateAudioOutputs(availableOutputs: [AudioSessionOutput], currentOutput: AudioSessionOutput?) + func updateCallState(_ callState: PresentationCallState) + func updatePeer(accountPeer: Peer, peer: Peer, hasOther: Bool) + + func animateIn() + func animateOut(completion: @escaping () -> Void) + func expandFromPipIfPossible() + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) +} diff --git a/submodules/TelegramCallsUI/Sources/CallControllerStatusNode.swift b/submodules/TelegramCallsUI/Sources/CallController/CallControllerStatusNode.swift similarity index 100% rename from submodules/TelegramCallsUI/Sources/CallControllerStatusNode.swift rename to submodules/TelegramCallsUI/Sources/CallController/CallControllerStatusNode.swift diff --git a/submodules/TelegramCallsUI/Sources/LegacyCallControllerNode.swift b/submodules/TelegramCallsUI/Sources/CallController/LegacyCallControllerNode.swift similarity index 100% rename from submodules/TelegramCallsUI/Sources/LegacyCallControllerNode.swift rename to submodules/TelegramCallsUI/Sources/CallController/LegacyCallControllerNode.swift diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift new file mode 100644 index 00000000000..6c434aef066 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift @@ -0,0 +1,605 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import MediaPlayer +import TelegramPresentationData + +private enum ButtonDescription: Equatable { + enum Key: Hashable { + case accept + case acceptOrEnd + case decline + case enableCamera + case switchCamera + case soundOutput + case mute + } + + enum SoundOutput { + case builtin + case speaker + case bluetooth + case airpods + case airpodsPro + case airpodsMax + case headphones + } + + enum EndType { + case outgoing + case decline + case end + } + + case accept + case end(EndType) + case enableCamera(isActive: Bool, isEnabled: Bool, isLoading: Bool, isScreencast: Bool) + case switchCamera(Bool) + case soundOutput(SoundOutput) + case mute(Bool) + + var key: Key { + switch self { + case .accept: + return .acceptOrEnd + case let .end(type): + if type == .decline { + return .decline + } else { + return .acceptOrEnd + } + case .enableCamera: + return .enableCamera + case .switchCamera: + return .switchCamera + case .soundOutput: + return .soundOutput + case .mute: + return .mute + } + } +} + +final class CallControllerButtonsView: UIView { + + private var buttonNodes: [ButtonDescription.Key: CallControllerButtonItemNode] = [:] + + private var mode: CallControllerButtonsMode? + + private var validLayout: (CGFloat, CGFloat)? + + var isMuted = false + + var acceptOrEnd: (() -> Void)? + var decline: (() -> Void)? + var mute: (() -> Void)? + var speaker: (() -> Void)? + var toggleVideo: (() -> Void)? + var rotateCamera: (() -> Void)? + + init(strings: PresentationStrings) { + super.init(frame: CGRect.zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateLayout(strings: PresentationStrings, mode: CallControllerButtonsMode, constrainedWidth: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + self.validLayout = (constrainedWidth, bottomInset) + + self.mode = mode + + if let mode = self.mode { + return self.updateButtonsLayout(strings: strings, mode: mode, width: constrainedWidth, bottomInset: bottomInset, animated: transition.isAnimated) + } else { + return 0.0 + } + } + + private var appliedMode: CallControllerButtonsMode? + + func videoButtonFrame() -> CGRect? { + return self.buttonNodes[.enableCamera]?.frame + } + + private func updateButtonsLayout(strings: PresentationStrings, mode: CallControllerButtonsMode, width: CGFloat, bottomInset: CGFloat, animated: Bool) -> CGFloat { + let transition: ContainedViewLayoutTransition + if animated { + transition = .animated(duration: 0.3, curve: .spring) + } else { + transition = .immediate + } + + let previousMode = self.appliedMode + self.appliedMode = mode + + var animatePositionsWithDelay = false + if let previousMode = previousMode { + switch previousMode { + case .incoming, .outgoingRinging: + if case .active = mode { + animatePositionsWithDelay = true + } + default: + break + } + } + + let minSmallButtonSideInset: CGFloat = width > 320.0 ? 34.0 : 16.0 + let maxSmallButtonSpacing: CGFloat = 34.0 + let smallButtonSize: CGFloat = 60.0 + let topBottomSpacing: CGFloat = 84.0 + + let maxLargeButtonSpacing: CGFloat = 115.0 + let largeButtonSize: CGFloat = 72.0 + let minLargeButtonSideInset: CGFloat = minSmallButtonSideInset - 6.0 + + struct PlacedButton { + let button: ButtonDescription + let frame: CGRect + } + + let height: CGFloat + + let speakerMode: CallControllerButtonsSpeakerMode + var videoState: CallControllerButtonsMode.VideoState + let hasAudioRouteMenu: Bool + switch mode { + case .incoming(let speakerModeValue, let hasAudioRouteMenuValue, let videoStateValue), .outgoingRinging(let speakerModeValue, let hasAudioRouteMenuValue, let videoStateValue), .active(let speakerModeValue, let hasAudioRouteMenuValue, let videoStateValue): + speakerMode = speakerModeValue + videoState = videoStateValue + hasAudioRouteMenu = hasAudioRouteMenuValue + } + + enum MappedState { + case incomingRinging + case outgoingRinging + case active + } + + let mappedState: MappedState + switch mode { + case .incoming: + mappedState = .incomingRinging + case .outgoingRinging: + mappedState = .outgoingRinging + case let .active(_, _, videoStateValue): + mappedState = .active + videoState = videoStateValue + } + + var buttons: [PlacedButton] = [] + switch mappedState { + case .incomingRinging, .outgoingRinging: + var topButtons: [ButtonDescription] = [] + var bottomButtons: [ButtonDescription] = [] + + let soundOutput: ButtonDescription.SoundOutput + switch speakerMode { + case .none, .builtin: + soundOutput = .builtin + case .speaker: + soundOutput = .speaker + case .headphones: + soundOutput = .headphones + case let .bluetooth(type): + switch type { + case .generic: + soundOutput = .bluetooth + case .airpods: + soundOutput = .airpods + case .airpodsPro: + soundOutput = .airpodsPro + case .airpodsMax: + soundOutput = .airpodsMax + } + } + + if videoState.isAvailable { + let isCameraActive: Bool + let isScreencastActive: Bool + let isCameraInitializing: Bool + if videoState.hasVideo { + isCameraActive = videoState.isCameraActive + isScreencastActive = videoState.isScreencastActive + isCameraInitializing = videoState.isInitializingCamera + } else { + isCameraActive = false + isScreencastActive = false + isCameraInitializing = videoState.isInitializingCamera + } + topButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: false, isLoading: isCameraInitializing, isScreencast: isScreencastActive)) + if !videoState.hasVideo { + topButtons.append(.mute(self.isMuted)) + topButtons.append(.soundOutput(soundOutput)) + } else { + if hasAudioRouteMenu { + topButtons.append(.soundOutput(soundOutput)) + } else { + topButtons.append(.mute(self.isMuted)) + } + if !isScreencastActive { + topButtons.append(.switchCamera(isCameraActive && !isCameraInitializing)) + } + } + } else { + topButtons.append(.mute(self.isMuted)) + topButtons.append(.soundOutput(soundOutput)) + } + + let topButtonsContentWidth = CGFloat(topButtons.count) * largeButtonSize + let topButtonsAvailableSpacingWidth = width - topButtonsContentWidth - minSmallButtonSideInset * 2.0 + let topButtonsSpacing = min(maxSmallButtonSpacing, topButtonsAvailableSpacingWidth / CGFloat(topButtons.count - 1)) + let topButtonsWidth = CGFloat(topButtons.count) * largeButtonSize + CGFloat(topButtons.count - 1) * topButtonsSpacing + var topButtonsLeftOffset = floor((width - topButtonsWidth) / 2.0) + for button in topButtons { + buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: topButtonsLeftOffset, y: 0.0), size: CGSize(width: largeButtonSize, height: largeButtonSize)))) + topButtonsLeftOffset += largeButtonSize + topButtonsSpacing + } + + if case .incomingRinging = mappedState { + bottomButtons.append(.end(.decline)) + bottomButtons.append(.accept) + } else { + bottomButtons.append(.end(.outgoing)) + } + + let bottomButtonsContentWidth = CGFloat(bottomButtons.count) * largeButtonSize + let bottomButtonsAvailableSpacingWidth = width - bottomButtonsContentWidth - minLargeButtonSideInset * 2.0 + let bottomButtonsSpacing = min(maxLargeButtonSpacing, bottomButtonsAvailableSpacingWidth / CGFloat(bottomButtons.count - 1)) + let bottomButtonsWidth = CGFloat(bottomButtons.count) * largeButtonSize + CGFloat(bottomButtons.count - 1) * bottomButtonsSpacing + var bottomButtonsLeftOffset = floor((width - bottomButtonsWidth) / 2.0) + for button in bottomButtons { + buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: bottomButtonsLeftOffset, y: largeButtonSize + topBottomSpacing), size: CGSize(width: largeButtonSize, height: largeButtonSize)))) + bottomButtonsLeftOffset += largeButtonSize + bottomButtonsSpacing + } + + height = largeButtonSize + topBottomSpacing + largeButtonSize + max(bottomInset + 32.0, 46.0) + case .active: + if videoState.hasVideo { + let isCameraActive: Bool + let isScreencastActive: Bool + let isCameraEnabled: Bool + let isCameraInitializing: Bool + if videoState.hasVideo { + isCameraActive = videoState.isCameraActive + isScreencastActive = videoState.isScreencastActive + isCameraEnabled = videoState.canChangeStatus + isCameraInitializing = videoState.isInitializingCamera + } else { + isCameraActive = false + isScreencastActive = false + isCameraEnabled = videoState.canChangeStatus + isCameraInitializing = videoState.isInitializingCamera + } + + var topButtons: [ButtonDescription] = [] + + let soundOutput: ButtonDescription.SoundOutput + switch speakerMode { + case .none, .builtin: + soundOutput = .builtin + case .speaker: + soundOutput = .speaker + case .headphones: + soundOutput = .headphones + case let .bluetooth(type): + switch type { + case .generic: + soundOutput = .bluetooth + case .airpods: + soundOutput = .airpods + case .airpodsPro: + soundOutput = .airpodsPro + case .airpodsMax: + soundOutput = .airpodsMax + } + } + + topButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: isCameraEnabled, isLoading: isCameraInitializing, isScreencast: isScreencastActive)) + if hasAudioRouteMenu { + topButtons.append(.soundOutput(soundOutput)) + } else { + topButtons.append(.mute(isMuted)) + } + if !isScreencastActive { + topButtons.append(.switchCamera(isCameraActive && !isCameraInitializing)) + } + topButtons.append(.end(.end)) + + let topButtonsContentWidth = CGFloat(topButtons.count) * smallButtonSize + let topButtonsAvailableSpacingWidth = width - topButtonsContentWidth - minSmallButtonSideInset * 2.0 + let topButtonsSpacing = min(maxSmallButtonSpacing, topButtonsAvailableSpacingWidth / CGFloat(topButtons.count - 1)) + let topButtonsWidth = CGFloat(topButtons.count) * smallButtonSize + CGFloat(topButtons.count - 1) * topButtonsSpacing + var topButtonsLeftOffset = floor((width - topButtonsWidth) / 2.0) + for button in topButtons { + buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: topButtonsLeftOffset, y: 0.0), size: CGSize(width: smallButtonSize, height: smallButtonSize)))) + topButtonsLeftOffset += smallButtonSize + topButtonsSpacing + } + + height = smallButtonSize + max(bottomInset + 19.0, 46.0) + } else { + var topButtons: [ButtonDescription] = [] + var bottomButtons: [ButtonDescription] = [] + + let isCameraActive: Bool + let isScreencastActive: Bool + let isCameraEnabled: Bool + let isCameraInitializing: Bool + if videoState.hasVideo { + isCameraActive = videoState.isCameraActive + isScreencastActive = videoState.isScreencastActive + isCameraEnabled = videoState.canChangeStatus + isCameraInitializing = videoState.isInitializingCamera + } else { + isCameraActive = false + isScreencastActive = false + isCameraEnabled = videoState.canChangeStatus + isCameraInitializing = videoState.isInitializingCamera + } + + let soundOutput: ButtonDescription.SoundOutput + switch speakerMode { + case .none, .builtin: + soundOutput = .builtin + case .speaker: + soundOutput = .speaker + case .headphones: + soundOutput = .bluetooth + case let .bluetooth(type): + switch type { + case .generic: + soundOutput = .bluetooth + case .airpods: + soundOutput = .airpods + case .airpodsPro: + soundOutput = .airpodsPro + case .airpodsMax: + soundOutput = .airpodsMax + } + } + + topButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: isCameraEnabled, isLoading: isCameraInitializing, isScreencast: isScreencastActive)) + topButtons.append(.mute(self.isMuted)) + topButtons.append(.soundOutput(soundOutput)) + + let topButtonsContentWidth = CGFloat(topButtons.count) * largeButtonSize + let topButtonsAvailableSpacingWidth = width - topButtonsContentWidth - minSmallButtonSideInset * 2.0 + let topButtonsSpacing = min(maxSmallButtonSpacing, topButtonsAvailableSpacingWidth / CGFloat(topButtons.count - 1)) + let topButtonsWidth = CGFloat(topButtons.count) * largeButtonSize + CGFloat(topButtons.count - 1) * topButtonsSpacing + var topButtonsLeftOffset = floor((width - topButtonsWidth) / 2.0) + for button in topButtons { + buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: topButtonsLeftOffset, y: 0.0), size: CGSize(width: largeButtonSize, height: largeButtonSize)))) + topButtonsLeftOffset += largeButtonSize + topButtonsSpacing + } + + bottomButtons.append(.end(.outgoing)) + + let bottomButtonsContentWidth = CGFloat(bottomButtons.count) * largeButtonSize + let bottomButtonsAvailableSpacingWidth = width - bottomButtonsContentWidth - minLargeButtonSideInset * 2.0 + let bottomButtonsSpacing = min(maxLargeButtonSpacing, bottomButtonsAvailableSpacingWidth / CGFloat(bottomButtons.count - 1)) + let bottomButtonsWidth = CGFloat(bottomButtons.count) * largeButtonSize + CGFloat(bottomButtons.count - 1) * bottomButtonsSpacing + var bottomButtonsLeftOffset = floor((width - bottomButtonsWidth) / 2.0) + for button in bottomButtons { + buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: bottomButtonsLeftOffset, y: largeButtonSize + topBottomSpacing), size: CGSize(width: largeButtonSize, height: largeButtonSize)))) + bottomButtonsLeftOffset += largeButtonSize + bottomButtonsSpacing + } + + height = largeButtonSize + topBottomSpacing + largeButtonSize + max(bottomInset + 32.0, 46.0) + } + } + + let delayIncrement = 0.015 + var validKeys: [ButtonDescription.Key] = [] + for button in buttons { + validKeys.append(button.button.key) + var buttonTransition = transition + var animateButtonIn = false + let buttonNode: CallControllerButtonItemNode + if let current = self.buttonNodes[button.button.key] { + buttonNode = current + } else { + buttonNode = CallControllerButtonItemNode() + self.buttonNodes[button.button.key] = buttonNode + self.addSubnode(buttonNode) + buttonNode.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside) + buttonTransition = .immediate + animateButtonIn = transition.isAnimated + } + let buttonContent: CallControllerButtonItemNode.Content + let buttonText: String + var buttonAccessibilityLabel = "" + var buttonAccessibilityValue = "" + var buttonAccessibilityTraits: UIAccessibilityTraits = [.button] + switch button.button { + case .accept: + buttonContent = CallControllerButtonItemNode.Content( + appearance: .color(.green), + image: .accept + ) + buttonText = strings.Call_Accept + buttonAccessibilityLabel = buttonText + case let .end(type): + buttonContent = CallControllerButtonItemNode.Content( + appearance: .color(.red), + image: .end + ) + switch type { + case .outgoing: + buttonText = "" + case .decline: + buttonText = strings.Call_Decline + case .end: + buttonText = strings.Call_End + } + if !buttonText.isEmpty { + buttonAccessibilityLabel = buttonText + } else { + buttonAccessibilityLabel = strings.Call_End + } + case let .enableCamera(isActivated, isEnabled, isInitializing, isScreencastActive): + buttonContent = CallControllerButtonItemNode.Content( + appearance: .blurred(isFilled: isActivated), + image: isScreencastActive ? .screencast : .camera, + isEnabled: isEnabled, + hasProgress: isInitializing + ) + buttonText = strings.Call_Camera + buttonAccessibilityLabel = buttonText + if !isEnabled { + buttonAccessibilityTraits.insert(.notEnabled) + } + if isActivated { + buttonAccessibilityTraits.insert(.selected) + } + case let .switchCamera(isEnabled): + buttonContent = CallControllerButtonItemNode.Content( + appearance: .blurred(isFilled: false), + image: .flipCamera, + isEnabled: isEnabled + ) + buttonText = strings.Call_Flip + buttonAccessibilityLabel = buttonText + if !isEnabled { + buttonAccessibilityTraits.insert(.notEnabled) + } + case let .soundOutput(value): + let image: CallControllerButtonItemNode.Content.Image + var isFilled = false + var title: String = strings.Call_Speaker + switch value { + case .builtin: + image = .speaker + case .speaker: + image = .speaker + isFilled = true + case .bluetooth: + image = .bluetooth + title = strings.Call_Audio + buttonAccessibilityValue = "Bluetooth" + case .airpods: + image = .airpods + title = strings.Call_Audio + buttonAccessibilityValue = "Airpods" + case .airpodsPro: + image = .airpodsPro + title = strings.Call_Audio + buttonAccessibilityValue = "Airpods Pro" + case .airpodsMax: + image = .airpodsMax + title = strings.Call_Audio + buttonAccessibilityValue = "Airpods Max" + case .headphones: + image = .headphones + title = strings.Call_Audio + buttonAccessibilityValue = strings.Call_AudioRouteHeadphones + } + buttonContent = CallControllerButtonItemNode.Content( + appearance: .blurred(isFilled: isFilled), + image: image + ) + buttonText = title + buttonAccessibilityLabel = buttonText + if isFilled { + buttonAccessibilityTraits.insert(.selected) + } + case let .mute(isMuted): + buttonContent = CallControllerButtonItemNode.Content( + appearance: .blurred(isFilled: isMuted), + image: .mute + ) + buttonText = strings.Call_Mute + buttonAccessibilityLabel = buttonText + if isMuted { + buttonAccessibilityTraits.insert(.selected) + } + } + var buttonDelay = 0.0 + if animatePositionsWithDelay { + switch button.button.key { + case .enableCamera: + buttonDelay = 0.0 + case .mute: + buttonDelay = delayIncrement * 1.0 + case .switchCamera: + buttonDelay = delayIncrement * 2.0 + case .acceptOrEnd: + buttonDelay = delayIncrement * 3.0 + default: + break + } + } + buttonTransition.updateFrame(node: buttonNode, frame: button.frame, delay: buttonDelay) + buttonNode.update(size: button.frame.size, content: buttonContent, text: buttonText, transition: buttonTransition) + buttonNode.accessibilityLabel = buttonAccessibilityLabel + buttonNode.accessibilityValue = buttonAccessibilityValue + buttonNode.accessibilityTraits = buttonAccessibilityTraits + + if animateButtonIn { + buttonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + + var removedKeys: [ButtonDescription.Key] = [] + for (key, button) in self.buttonNodes { + if !validKeys.contains(key) { + removedKeys.append(key) + if animated { + if case .decline = key { + transition.updateTransformScale(node: button, scale: 0.1) + transition.updateAlpha(node: button, alpha: 0.0, completion: { [weak button] _ in + button?.removeFromSupernode() + }) + } else { + transition.updateAlpha(node: button, alpha: 0.0, completion: { [weak button] _ in + button?.removeFromSupernode() + }) + } + } else { + button.removeFromSupernode() + } + } + } + for key in removedKeys { + self.buttonNodes.removeValue(forKey: key) + } + + return height + } + + @objc func buttonPressed(_ button: CallControllerButtonItemNode) { + for (key, listButton) in self.buttonNodes { + if button === listButton { + switch key { + case .accept: + self.acceptOrEnd?() + case .acceptOrEnd: + self.acceptOrEnd?() + case .decline: + self.decline?() + case .enableCamera: + self.toggleVideo?() + case .switchCamera: + self.rotateCamera?() + case .soundOutput: + self.speaker?() + case .mute: + self.mute?() + } + break + } + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + for (_, button) in self.buttonNodes { + if let result = button.view.hitTest(self.convert(point, to: button.view), with: event) { + return result + } + } + + return super.hitTest(point, with: event) + } +} diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerKeyPreviewView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerKeyPreviewView.swift new file mode 100644 index 00000000000..5b6177ca867 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerKeyPreviewView.swift @@ -0,0 +1,108 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import LegacyComponents + +private let emojiFont = Font.regular(28.0) +private let textFont = Font.regular(15.0) + +final class CallControllerKeyPreviewView: UIView { + private let keyTextNode: ASTextNode + private let infoTextNode: ASTextNode + + private let effectView: UIVisualEffectView + + private let dismiss: () -> Void + + init(keyText: String, infoText: String, dismiss: @escaping () -> Void) { + self.keyTextNode = ASTextNode() + self.keyTextNode.displaysAsynchronously = false + self.infoTextNode = ASTextNode() + self.infoTextNode.displaysAsynchronously = false + self.dismiss = dismiss + + self.effectView = UIVisualEffectView() + if #available(iOS 9.0, *) { + } else { + self.effectView.effect = UIBlurEffect(style: .dark) + self.effectView.alpha = 0.0 + } + + super.init(frame: CGRect.zero) + + self.keyTextNode.attributedText = NSAttributedString(string: keyText, attributes: [NSAttributedString.Key.font: Font.regular(58.0), NSAttributedString.Key.kern: 11.0 as NSNumber]) + + self.infoTextNode.attributedText = NSAttributedString(string: infoText, font: Font.regular(14.0), textColor: UIColor.white, paragraphAlignment: .center) + + self.addSubview(self.effectView) + self.addSubnode(self.keyTextNode) + self.addSubnode(self.infoTextNode) + + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + self.effectView.frame = CGRect(origin: CGPoint(), size: size) + + let keyTextSize = self.keyTextNode.measure(CGSize(width: 300.0, height: 300.0)) + transition.updateFrame(node: self.keyTextNode, frame: CGRect(origin: CGPoint(x: floor((size.width - keyTextSize.width) / 2) + 6.0, y: floor((size.height - keyTextSize.height) / 2) - 50.0), size: keyTextSize)) + + let infoTextSize = self.infoTextNode.measure(CGSize(width: size.width - 32.0, height: CGFloat.greatestFiniteMagnitude)) + transition.updateFrame(node: self.infoTextNode, frame: CGRect(origin: CGPoint(x: floor((size.width - infoTextSize.width) / 2.0), y: floor((size.height - infoTextSize.height) / 2.0) + 30.0), size: infoTextSize)) + } + + func animateIn(from rect: CGRect, fromNode: ASDisplayNode) { + self.keyTextNode.layer.animatePosition(from: CGPoint(x: rect.midX, y: rect.midY), to: self.keyTextNode.layer.position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + if let transitionView = fromNode.view.snapshotView(afterScreenUpdates: false) { + self.addSubview(transitionView) + transitionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + transitionView.layer.animatePosition(from: CGPoint(x: rect.midX, y: rect.midY), to: self.keyTextNode.layer.position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak transitionView] _ in + transitionView?.removeFromSuperview() + }) + transitionView.layer.animateScale(from: 1.0, to: self.keyTextNode.frame.size.width / rect.size.width, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + } + self.keyTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + + self.keyTextNode.layer.animateScale(from: rect.size.width / self.keyTextNode.frame.size.width, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + + self.infoTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + + UIView.animate(withDuration: 0.3, animations: { + if #available(iOS 9.0, *) { + self.effectView.effect = UIBlurEffect(style: .dark) + } else { + self.effectView.alpha = 1.0 + } + }) + } + + func animateOut(to rect: CGRect, toNode: ASDisplayNode, completion: @escaping () -> Void) { + self.keyTextNode.layer.animatePosition(from: self.keyTextNode.layer.position, to: CGPoint(x: rect.midX + 2.0, y: rect.midY), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + completion() + }) + self.keyTextNode.layer.animateScale(from: 1.0, to: rect.size.width / (self.keyTextNode.frame.size.width - 2.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + + self.infoTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) + + UIView.animate(withDuration: 0.3, animations: { + if #available(iOS 9.0, *) { + self.effectView.effect = nil + } else { + self.effectView.alpha = 0.0 + } + }) + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.dismiss() + } + } +} + diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerStatusView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerStatusView.swift new file mode 100644 index 00000000000..929a6e339ba --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerStatusView.swift @@ -0,0 +1,250 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit + +private let compactNameFont = Font.regular(28.0) +private let regularNameFont = Font.regular(36.0) + +private let compactStatusFont = Font.regular(18.0) +private let regularStatusFont = Font.regular(18.0) + +final class CallControllerStatusView: UIView { + private let titleNode: TextNode + private let statusContainerNode: UIView + private let statusNode: TextNode + private let statusMeasureNode: TextNode + private let receptionNode: CallControllerReceptionView + private let logoNode: ASImageNode + + private let titleActivateAreaNode: AccessibilityAreaNode + private let statusActivateAreaNode: AccessibilityAreaNode + + var title: String = "" + var subtitle: String = "" + var status: CallControllerStatusValue = .text(string: "", displayLogo: false) { + didSet { + if self.status != oldValue { + self.statusTimer?.invalidate() + + if let snapshotView = self.statusContainerNode.snapshotView(afterScreenUpdates: false) { + snapshotView.frame = self.statusContainerNode.frame + self.insertSubview(snapshotView, belowSubview: self.statusContainerNode) + + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView?.removeFromSuperview() + }) + snapshotView.layer.animateScale(from: 1.0, to: 0.3, duration: 0.3, removeOnCompletion: false) + snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: snapshotView.frame.height / 2.0), duration: 0.3, delay: 0.0, removeOnCompletion: false, additive: true) + + self.statusContainerNode.layer.animateScale(from: 0.3, to: 1.0, duration: 0.3) + self.statusContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + self.statusContainerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -snapshotView.frame.height / 2.0), to: CGPoint(), duration: 0.3, delay: 0.0, additive: true) + } + + if case .timer = self.status { + self.statusTimer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in + if let strongSelf = self, let validLayoutWidth = strongSelf.validLayoutWidth { + let _ = strongSelf.updateLayout(constrainedWidth: validLayoutWidth, transition: .immediate) + } + }, queue: Queue.mainQueue()) + self.statusTimer?.start() + } else { + if let validLayoutWidth = self.validLayoutWidth { + let _ = self.updateLayout(constrainedWidth: validLayoutWidth, transition: .immediate) + } + } + } + } + } + var reception: Int32? { + didSet { + if self.reception != oldValue { + if let reception = self.reception { + self.receptionNode.reception = reception + + if oldValue == nil { + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring) + transition.updateAlpha(view: self.receptionNode, alpha: 1.0) + } + } else if self.reception == nil, oldValue != nil { + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring) + transition.updateAlpha(view: self.receptionNode, alpha: 0.0) + } + + if (oldValue == nil) != (self.reception != nil) { + if let validLayoutWidth = self.validLayoutWidth { + let _ = self.updateLayout(constrainedWidth: validLayoutWidth, transition: .immediate) + } + } + } + } + } + + private var statusTimer: SwiftSignalKit.Timer? + private var validLayoutWidth: CGFloat? + + init() { + self.titleNode = TextNode() + self.statusContainerNode = UIView() + self.statusNode = TextNode() + self.statusNode.displaysAsynchronously = false + self.statusMeasureNode = TextNode() + + self.receptionNode = CallControllerReceptionView() + self.receptionNode.alpha = 0.0 + + self.logoNode = ASImageNode() + self.logoNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallTitleLogo"), color: .white) + self.logoNode.isHidden = true + + self.titleActivateAreaNode = AccessibilityAreaNode() + self.titleActivateAreaNode.accessibilityTraits = .staticText + + self.statusActivateAreaNode = AccessibilityAreaNode() + self.statusActivateAreaNode.accessibilityTraits = [.staticText, .updatesFrequently] + + super.init(frame: CGRect.zero) + + self.isUserInteractionEnabled = false + + self.addSubnode(self.titleNode) + self.addSubview(self.statusContainerNode) + self.statusContainerNode.addSubnode(self.statusNode) + self.statusContainerNode.addSubview(self.receptionNode) + self.statusContainerNode.addSubnode(self.logoNode) + + self.addSubnode(self.titleActivateAreaNode) + self.addSubnode(self.statusActivateAreaNode) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.statusTimer?.invalidate() + } + + func setVisible(_ visible: Bool, transition: ContainedViewLayoutTransition) { + let alpha: CGFloat = visible ? 1.0 : 0.0 + transition.updateAlpha(node: self.titleNode, alpha: alpha) + transition.updateAlpha(view: self.statusContainerNode, alpha: alpha) + } + + func updateLayout(constrainedWidth: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + self.validLayoutWidth = constrainedWidth + + let nameFont: UIFont + let statusFont: UIFont + if constrainedWidth < 330.0 { + nameFont = compactNameFont + statusFont = compactStatusFont + } else { + nameFont = regularNameFont + statusFont = regularStatusFont + } + + var statusOffset: CGFloat = 0.0 + let statusText: String + let statusMeasureText: String + var statusDisplayLogo: Bool = false + switch self.status { + case let .text(text, displayLogo): + statusText = text + statusMeasureText = text + statusDisplayLogo = displayLogo + if displayLogo { + statusOffset += 10.0 + } + case let .timer(format, referenceTime): + let duration = Int32(CFAbsoluteTimeGetCurrent() - referenceTime) + let durationString: String + let measureDurationString: String + if duration > 60 * 60 { + durationString = String(format: "%02d:%02d:%02d", arguments: [duration / 3600, (duration / 60) % 60, duration % 60]) + measureDurationString = "00:00:00" + } else { + durationString = String(format: "%02d:%02d", arguments: [(duration / 60) % 60, duration % 60]) + measureDurationString = "00:00" + } + statusText = format(durationString, false) + statusMeasureText = format(measureDurationString, true) + if self.reception != nil { + statusOffset += 8.0 + } + } + + let spacing: CGFloat = 1.0 + let (titleLayout, titleApply) = TextNode.asyncLayout(self.titleNode)(TextNodeLayoutArguments(attributedString: NSAttributedString(string: self.title, font: nameFont, textColor: .white), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0))) + let (statusMeasureLayout, statusMeasureApply) = TextNode.asyncLayout(self.statusMeasureNode)(TextNodeLayoutArguments(attributedString: NSAttributedString(string: statusMeasureText, font: statusFont, textColor: .white), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0))) + let (statusLayout, statusApply) = TextNode.asyncLayout(self.statusNode)(TextNodeLayoutArguments(attributedString: NSAttributedString(string: statusText, font: statusFont, textColor: .white), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0))) + + let _ = titleApply() + let _ = statusApply() + let _ = statusMeasureApply() + + self.titleActivateAreaNode.accessibilityLabel = self.title + self.statusActivateAreaNode.accessibilityLabel = statusText + + self.titleNode.frame = CGRect(origin: CGPoint(x: floor((constrainedWidth - titleLayout.size.width) / 2.0), y: 0.0), size: titleLayout.size) + self.statusContainerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: titleLayout.size.height + spacing), size: CGSize(width: constrainedWidth, height: statusLayout.size.height)) + self.statusNode.frame = CGRect(origin: CGPoint(x: floor((constrainedWidth - statusMeasureLayout.size.width) / 2.0) + statusOffset, y: 0.0), size: statusLayout.size) + self.receptionNode.frame = CGRect(origin: CGPoint(x: self.statusNode.frame.minX - receptionNodeSize.width, y: 9.0), size: receptionNodeSize) + self.logoNode.isHidden = !statusDisplayLogo + if let image = self.logoNode.image, let firstLineRect = statusMeasureLayout.linesRects().first { + let firstLineOffset = floor((statusMeasureLayout.size.width - firstLineRect.width) / 2.0) + self.logoNode.frame = CGRect(origin: CGPoint(x: self.statusNode.frame.minX + firstLineOffset - image.size.width - 7.0, y: 5.0), size: image.size) + } + + self.titleActivateAreaNode.frame = self.titleNode.frame + self.statusActivateAreaNode.frame = self.statusContainerNode.frame + + return titleLayout.size.height + spacing + statusLayout.size.height + } +} + +private let receptionNodeSize = CGSize(width: 24.0, height: 10.0) + +final class CallControllerReceptionView : UIView { + + var reception: Int32 = 4 { + didSet { + self.setNeedsDisplay() + } + } + + init() { + super.init(frame: CGRect.zero) + self.isOpaque = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + super.draw(rect) + guard let context = UIGraphicsGetCurrentContext() else { + return + } + context.setFillColor(UIColor.white.cgColor) + let width: CGFloat = 3.0 + var spacing: CGFloat = 1.5 + if UIScreenScale > 2 { + spacing = 4.0 / 3.0 + } + for i in 0 ..< 4 { + let height = 4.0 + 2.0 * CGFloat(i) + let rect = CGRect(x: bounds.minX + CGFloat(i) * (width + spacing), y: receptionNodeSize.height - height, width: width, height: height) + if i >= reception { + context.setAlpha(0.4) + } + let path = UIBezierPath(roundedRect: rect, cornerRadius: 0.5) + context.addPath(path.cgPath) + context.fillPath() + } + } + +} diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift new file mode 100644 index 00000000000..c18a6c03c0f --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -0,0 +1,2282 @@ +import AudioBlob +import AvatarNode +import Foundation +import UIKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit +import TelegramPresentationData +import TelegramUIPreferences +import TelegramAudio +import AccountContext +import LocalizedPeerData +import PhotoResources +import CallsEmoji +import TooltipUI +import AlertUI +import PresentationDataUtils +import DeviceAccess +import ContextUI +import GradientBackground + +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 CallVideoView: UIView, PreviewVideoView { + + private let videoTransformContainer: UIView + 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 readyPromise = ValuePromise(false) + var ready: Signal { + return self.readyPromise.get() + } + + private let isFlippedUpdated: (CallVideoView) -> 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 (CallVideoView) -> Void) { + self.isReadyUpdated = isReadyUpdated + self.isFlippedUpdated = isFlippedUpdated + + self.videoTransformContainer = UIView() + 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(frame: CGRect.zero) + + self.backgroundColor = .black + self.clipsToBounds = true + + if #available(iOS 13.0, *) { + self.layer.cornerCurve = .continuous + } + + self.videoTransformContainer.addSubview(self.videoView.view) + self.addSubview(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.readyPromise.set(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.readyPromise.set(true) + strongSelf.isReadyUpdated() + } + }, queue: .mainQueue()) + } + self.isReadyTimer?.start() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.isReadyTimer?.invalidate() + } + + 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, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition) { + self.updateLayout(size: size, cornerRadius: self.currentCornerRadius, isOutgoing: true, deviceOrientation: .portrait, isCompactLayout: false, transition: transition) + } + + func updateLayout(size: CGSize, cornerRadius: CGFloat, isOutgoing: Bool, deviceOrientation: UIDeviceOrientation, isCompactLayout: Bool, transition: ContainedViewLayoutTransition) { + self.currentCornerRadius = cornerRadius + + var rotationAngle: CGFloat + if false && 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: + rotationAngle = CGFloat.pi + case .rotation270: + 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 * 3.0 / 2.0) < 0.01 { + rotationAngle = -CGFloat.pi / 2.0 + } + if abs(rotationAngle - (-CGFloat.pi)) < 0.01 { + 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(view: self.videoTransformContainer, from: scaleDifference) + } + } + self.previousVideoHeight = rotatedVideoHeight + transition.updatePosition(view: self.videoTransformContainer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + transition.updateTransformRotation(view: self.videoTransformContainer, 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.hasScheduledUnblur { + self.hasScheduledUnblur = false + } + 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.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() + }) + } + } + + private var hasScheduledUnblur = false + func flip(withBackground: Bool) { + if withBackground { + self.backgroundColor = .black + } + UIView.transition(with: withBackground ? self.videoTransformContainer : self, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: { + UIView.performWithoutAnimation { + self.updateIsBlurred(isBlurred: true, light: false, animated: false) + } + }) { finished in + self.backgroundColor = nil + self.hasScheduledUnblur = true + Queue.mainQueue().after(0.5) { + if self.hasScheduledUnblur { + self.updateIsBlurred(isBlurred: false) + } + } + } + } +} + +final class CallControllerView: ViewControllerTracingNodeView { + + private enum VideoNodeCorner { + case topLeft + case topRight + case bottomLeft + case bottomRight + } + + private enum UIState { + case ringing + case active + case weakSignal + case video + } + + private let sharedContext: SharedAccountContext + private let accountContext: AccountContext + 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 audioLevelDisposable = MetaDisposable() + + private let containerTransformationView: UIView + private let contentContainerView: UIView + private let videoContainerNode: PinchSourceContainerView + + private var gradientBackgroundNode: GradientBackgroundNode + private let dimNode: ASImageNode // TODO: implement - remove? + + private var candidateIncomingVideoNodeValue: CallVideoView? + private var incomingVideoNodeValue: CallVideoView? + private var incomingVideoViewRequested: Bool = false + private var candidateOutgoingVideoNodeValue: CallVideoView? + private var outgoingVideoNodeValue: CallVideoView? + private var outgoingVideoViewRequested: Bool = false + + private var removedMinimizedVideoNodeValue: CallVideoView? + private var removedExpandedVideoNodeValue: CallVideoView? + + 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: CallVideoView? + private var minimizedVideoNode: CallVideoView? + 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 avatarNode: AvatarNode + private let audioLevelView: VoiceBlobView + private let statusNode: CallControllerStatusView + private let toastNode: CallControllerToastContainerNode + private let buttonsNode: CallControllerButtonsNode + private var keyPreviewNode: CallControllerKeyPreviewView? + + 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 + + private var uiState: UIState? + + 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, + accountContext: AccountContext, + account: Account, + presentationData: PresentationData, + statusBar: StatusBar, + debugInfo: Signal<(String, String), NoError>, + shouldStayHiddenUntilConnection: Bool = false, + easyDebugAccess: Bool, + call: PresentationCall) { + self.sharedContext = sharedContext + self.accountContext = accountContext + self.account = account + self.presentationData = presentationData + self.statusBar = statusBar + self.debugInfo = debugInfo + self.shouldStayHiddenUntilConnection = shouldStayHiddenUntilConnection + self.easyDebugAccess = easyDebugAccess + self.call = call + + self.containerTransformationView = UIView() + self.containerTransformationView.clipsToBounds = true + + self.contentContainerView = UIView() + + self.videoContainerNode = PinchSourceContainerView() + + self.gradientBackgroundNode = createGradientBackgroundNode() + + 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() + + let avatarWidth: CGFloat = 136.0 + let avatarFrame = CGRect(x: 0, y: 0, width: avatarWidth, height: avatarWidth) + let avatarFont = avatarPlaceholderFont(size: floor(avatarWidth * 16.0 / 37.0)) + self.avatarNode = AvatarNode(font: avatarFont) + self.avatarNode.frame = avatarFrame + self.avatarNode.cornerRadius = avatarWidth / 2.0 + self.avatarNode.clipsToBounds = true + self.audioLevelView = VoiceBlobView(frame: avatarFrame, + maxLevel: 4, + smallBlobRange: (1.05, 0.15), + mediumBlobRange: (1.12, 1.47), + bigBlobRange: (1.17, 1.6) + ) + self.audioLevelView.setColor(UIColor(rgb: 0xFFFFFF)) + + self.statusNode = CallControllerStatusView() + + self.buttonsNode = CallControllerButtonsNode(strings: self.presentationData.strings) + self.toastNode = CallControllerToastContainerNode(strings: self.presentationData.strings) + self.keyButtonNode = CallControllerKeyButton() + self.keyButtonNode.accessibilityElementsHidden = false + + super.init(frame: CGRect.zero) + + self.contentContainerView.backgroundColor = .black + + self.addSubview(self.containerTransformationView) + self.containerTransformationView.addSubview(self.contentContainerView) + + 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.contentContainerView.addSubnode(self.gradientBackgroundNode) + self.contentContainerView.addSubview(self.videoContainerNode) + self.contentContainerView.addSubnode(self.dimNode) + self.contentContainerView.addSubview(self.audioLevelView) + self.contentContainerView.addSubnode(self.avatarNode) + self.contentContainerView.addSubview(self.statusNode) + self.contentContainerView.addSubnode(self.buttonsNode) + self.contentContainerView.addSubnode(self.toastNode) + self.contentContainerView.addSubnode(self.keyButtonNode) + self.contentContainerView.addSubnode(self.backButtonArrowNode) + self.contentContainerView.addSubnode(self.backButtonNode) + + 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.addGestureRecognizer(panRecognizer) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.addGestureRecognizer(tapRecognizer) + + 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 = CallVideoView(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 = VoiceChatCameraPreviewViewController(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.contentContainerView.alpha = 0.0 + Queue.mainQueue().after(3.0, { [weak self] in + self?.contentContainerView.alpha = 1.0 + self?.animateIn() + }) + } else if call.isVideo && call.isOutgoing { + self.contentContainerView.alpha = 0.0 + Queue.mainQueue().after(1.0, { [weak self] in + self?.contentContainerView.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 = PinchViewController(sourceNode: sourceNode, getContentAreaInScreenSpace: { + return UIScreen.main.bounds + }) + strongSelf.sharedContext.mainWindow?.presentInGlobalOverlay(pinchController) + strongSelf.isVideoPinched = true + + strongSelf.videoContainerNode.contentView.clipsToBounds = true + strongSelf.videoContainerNode.backgroundColor = .black + + if let (layout, navigationBarHeight) = strongSelf.validLayout { + strongSelf.videoContainerNode.contentView.layer.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.contentView.layer.cornerRadius = 0.0 + + if let (layout, navigationBarHeight) = strongSelf.validLayout { + strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) + } + } + + self.audioLevelDisposable.set((call.audioLevel + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let strongSelf = self, !strongSelf.audioLevelView.isHidden else { + return + } + strongSelf.audioLevelView.updateLevel(CGFloat(value) * 2.0) + })) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + 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) + }) else { + return + } + + self.present?(TooltipScreen(account: self.account, 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) + })) + } + + func updatePeer(accountPeer: Peer, peer: Peer, hasOther: Bool) { + if !arePeersEqual(self.peer, peer) { + self.peer = peer + if PeerReference(peer) != nil && !peer.profileImageRepresentations.isEmpty { + self.dimNode.isHidden = false + } else { + self.dimNode.isHidden = true + } + + self.avatarNode.setPeer(context: self.accountContext, + account: self.account, + theme: presentationData.theme, + peer: EnginePeer(peer), + overrideImage: nil, + clipStyle: .none, + synchronousLoad: false, + displayDimensions: self.avatarNode.bounds.size) + + setUIState(.ringing) + + 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.contentView.insertSubview(incomingVideoNode, belowSubview: expandedVideoNode) + } else { + strongSelf.videoContainerNode.contentView.addSubview(incomingVideoNode) + } + strongSelf.expandedVideoNode = incomingVideoNode + strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) + + strongSelf.updateDimVisibility() + strongSelf.maybeScheduleUIHidingForActiveVideoCall() + + if strongSelf.hasVideoNodes { + strongSelf.setUIState(.video) + } + } + + let incomingVideoNode = CallVideoView(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 + } + if hasVideoNodes { + setUIState(.video) + } else { + setUIState(.active) + } + } + 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.contentView.insertSubview(outgoingVideoNode, aboveSubview: expandedVideoNode) + } else { + strongSelf.expandedVideoNode = outgoingVideoNode + strongSelf.videoContainerNode.contentView.addSubview(outgoingVideoNode) + } + strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) + + strongSelf.updateDimVisibility() + strongSelf.maybeScheduleUIHidingForActiveVideoCall() + + if strongSelf.hasVideoNodes { + strongSelf.setUIState(.video) + } + } + + let outgoingVideoNode = CallVideoView(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 + } + if hasVideoNodes { + setUIState(.video) + } else { + setUIState(.active) + } + } + 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)! + 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 let statusReceptionActual = statusReception { + setUIState(statusReceptionActual > 1 ? .active : .weakSignal) + } else { + setUIState(.active) + } + } else { + setUIState(.active) + } + } + if self.shouldStayHiddenUntilConnection { + switch callState.state { + case .connecting, .active: + self.contentContainerView.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.contentContainerView.alpha.isZero { + var bounds = self.bounds + bounds.origin = CGPoint() + self.bounds = bounds + self.layer.removeAnimation(forKey: "bounds") + self.statusBar.layer.removeAnimation(forKey: "opacity") + self.contentContainerView.layer.removeAnimation(forKey: "opacity") + self.contentContainerView.layer.removeAnimation(forKey: "scale") + self.statusBar.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + if !self.shouldStayHiddenUntilConnection { + self.contentContainerView.layer.animateScale(from: 1.04, to: 1.0, duration: 0.3) + self.contentContainerView.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.contentContainerView.alpha > 0.0 { + self.contentContainerView.layer.allowsGroupOpacity = true + self.contentContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in + self?.contentContainerView.layer.allowsGroupOpacity = false + }) + self.contentContainerView.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) + } + + 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(view: self.containerTransformationView, frame: containerFrame) + transition.updateSublayerTransformScale(view: self.containerTransformationView, scale: min(1.0, containerFrame.width / layout.size.width * 1.01)) + transition.updateCornerRadius(layer: self.containerTransformationView.layer, cornerRadius: self.pictureInPictureTransitionFraction * 10.0) + + transition.updateFrame(view: self.contentContainerView, 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(view: 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(view: keyPreviewNode, frame: containerFullScreenFrame) + keyPreviewNode.updateLayout(size: layout.size, transition: .immediate) + } + + transition.updateFrame(node: gradientBackgroundNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + gradientBackgroundNode.updateLayout(size: layout.size, transition: transition, extendAnimation: false, backwards: false, completion: {}) + + 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 topOffset: CGFloat = layout.safeInsets.top + // TODO: implement - some magic here +// 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 +// } +// } + + topOffset += 174 + + let avatarFrame = CGRect(origin: CGPoint(x: (layout.size.width - avatarNode.bounds.width) / 2.0, y: topOffset), + size: self.avatarNode.bounds.size) + transition.updateFrame(node: self.avatarNode, frame: avatarFrame) + transition.updateFrame(view: self.audioLevelView, frame: avatarFrame) + + topOffset += self.avatarNode.bounds.size.height + 40 + + let statusHeight = self.statusNode.updateLayout(constrainedWidth: layout.size.width, transition: transition) + transition.updateFrame(view: self.statusNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topOffset), size: CGSize(width: layout.size.width, height: statusHeight))) + transition.updateAlpha(view: 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?.removeFromSuperview() + }) + } else { + removedMinimizedVideoNodeValue.removeFromSuperview() + } + } + + if let expandedVideoNode = self.expandedVideoNode { + transition.updateAlpha(view: 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(view: expandedVideoNode, frame: fullscreenVideoFrame, completion: { [weak removedExpandedVideoNodeValue] _ in + removedExpandedVideoNodeValue?.removeFromSuperview() + }) + } else { + expandedVideoTransition.updateFrame(view: 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) + } + + 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?.removeFromSuperview() + }) + } else { + removedExpandedVideoNodeValue.removeFromSuperview() + } + } + } + + + if let minimizedVideoNode = self.minimizedVideoNode { + transition.updateAlpha(view: 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.contentContainerView.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(view: 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 = CallControllerKeyPreviewView(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.contentContainerView.insertSubview(keyPreviewNode, belowSubview: 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?.removeFromSuperview() + }) + 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.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.snapshotView(afterScreenUpdates: false) + copyView?.frame = minimizedVideoNode.frame + self.expandedVideoNode = minimizedVideoNode + self.minimizedVideoNode = expandedVideoNode + if let superview = expandedVideoNode.superview { + superview.insertSubview(expandedVideoNode, aboveSubview: 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 setUIState(_ state: UIState) { + guard uiState != state else { + return + } + let isNewStateAllowed: Bool + switch uiState { + case .ringing: isNewStateAllowed = true + case .active: isNewStateAllowed = true + case .weakSignal: isNewStateAllowed = state == .ringing || state == .active + case .video: isNewStateAllowed = true + case .none: isNewStateAllowed = true + } + guard isNewStateAllowed else { + return + } + uiState = state + switch state { + case .ringing: + let colors = [UIColor(rgb: 0xAC65D4), UIColor(rgb: 0x7261DA), UIColor(rgb: 0x5295D6), UIColor(rgb: 0x616AD5)] + self.gradientBackgroundNode.updateColors(colors: colors) + avatarNode.isHidden = false + audioLevelView.isHidden = false + audioLevelView.startAnimating() + case .active: + let colors = [UIColor(rgb: 0x53A6DE), UIColor(rgb: 0x398D6F), UIColor(rgb: 0xBAC05D), UIColor(rgb: 0x3C9C8F)] + self.gradientBackgroundNode.updateColors(colors: colors) + avatarNode.isHidden = false + audioLevelView.isHidden = false + audioLevelView.startAnimating() + case .weakSignal: + let colors = [UIColor(rgb: 0xC94986), UIColor(rgb: 0xFF7E46), UIColor(rgb: 0xB84498), UIColor(rgb: 0xF4992E)] + self.gradientBackgroundNode.updateColors(colors: colors) + case .video: + avatarNode.isHidden = true + audioLevelView.isHidden = true + audioLevelView.stopAnimating(duration: 0.5) + } + } + + 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.center + } else if self.hasVideoNodes { + self.minimizedVideoInitialPosition = nil + if !self.pictureInPictureTransitionFraction.isZero { + self.pictureInPictureGestureState = .dragging(initialPosition: self.containerTransformationView.center, draggingPosition: self.containerTransformationView.center) + } 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) + let minimizedVideoDraggingPosition = CGPoint(x: minimizedVideoInitialPosition.x + translation.x, y: minimizedVideoInitialPosition.y + translation.y) + self.minimizedVideoDraggingPosition = minimizedVideoDraggingPosition + minimizedVideoNode.center = minimizedVideoDraggingPosition + } else { + switch self.pictureInPictureGestureState { + case .none: + let offset = recognizer.translation(in: self).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) + 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) + draggingPosition.x = initialPosition.x + translation.x + draggingPosition.y = initialPosition.y + translation.y + self.pictureInPictureGestureState = .dragging(initialPosition: initialPosition, draggingPosition: draggingPosition) + self.containerTransformationView.center = 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)) + + 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).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).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) + 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)) + + let containerFrame = self.calculatePictureInPictureContainerRect(layout: layout, navigationHeight: navigationHeight) + self.containerTransformationView.frame = containerFrame + containerTransformationView.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.containerTransformationView.frame.contains(point) { + return self.containerTransformationView.hitTest(self.convert(point, to: self.containerTransformationView), with: event) + } + return nil + } +} diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift new file mode 100644 index 00000000000..ceb7622df67 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift @@ -0,0 +1,446 @@ +import AccountContext +import AsyncDisplayKit +import Display +import Foundation +import Postbox +import SwiftSignalKit +import TelegramAudio +import TelegramCore +import TelegramNotices +import TelegramPresentationData +import TelegramUIPreferences +import TooltipUI +import UIKit + +public final class CallViewController: ViewController { + + private var rootContainerView: UIView! + private var callControllerView: CallControllerView! + + private let _ready = Promise(false) + override public var ready: Promise { + return self._ready + } + + private let sharedContext: SharedAccountContext + private let accountContext: AccountContext + private let account: Account + public let call: PresentationCall + private let easyDebugAccess: Bool + private var presentationData: PresentationData + + private var peerDisposable: Disposable? + private var disposable: Disposable? + private var callMutedDisposable: Disposable? + private var audioOutputStateDisposable: Disposable? + private let idleTimerExtensionDisposable = MetaDisposable() + + private var peer: Peer? + private var audioOutputState: ([AudioSessionOutput], AudioSessionOutput?)? + private var isMuted = false + private var didPlayPresentationAnimation = false + private var presentedCallRating = false + + // MARK: - Initialization + + public init(sharedContext: SharedAccountContext, + accountContext: AccountContext, + account: Account, + call: PresentationCall, + easyDebugAccess: Bool) { + self.sharedContext = sharedContext + self.accountContext = accountContext + self.account = account + self.call = call + self.easyDebugAccess = easyDebugAccess + self.presentationData = sharedContext.currentPresentationData.with { $0 } + + super.init(navigationBarPresentationData: nil) + + self.isOpaqueWhenInOverlay = true + + self.statusBar.statusBarStyle = .White + self.statusBar.ignoreInCall = true + + self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .portrait, compactSize: .portrait) + + self.disposable = (call.state + |> deliverOnMainQueue).start(next: { [weak self] callState in + self?.callStateUpdated(callState) + }) + + self.callMutedDisposable = (call.isMuted + |> deliverOnMainQueue).start(next: { [weak self] value in + if let strongSelf = self { + strongSelf.isMuted = value + if strongSelf.isNodeLoaded { + strongSelf.callControllerView.isMuted = value + } + } + }) + + self.audioOutputStateDisposable = (call.audioOutputState + |> deliverOnMainQueue).start(next: { [weak self] state in + if let strongSelf = self { + strongSelf.audioOutputState = state + if strongSelf.isNodeLoaded { + strongSelf.callControllerView.updateAudioOutputs(availableOutputs: state.0, currentOutput: state.1) + } + } + }) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.peerDisposable?.dispose() + self.disposable?.dispose() + self.callMutedDisposable?.dispose() + self.audioOutputStateDisposable?.dispose() + self.idleTimerExtensionDisposable.dispose() + } + + // MARK: - Overrides + + override public func loadDisplayNode() { + displayNode = ASDisplayNode() + displayNode.displaysAsynchronously = false + displayNodeDidLoad() + } + + public override func displayNodeDidLoad() { + super.displayNodeDidLoad() + let rootContainerViewLocal = UIView(frame: self.displayNode.view.bounds) + rootContainerView = rootContainerViewLocal + rootContainerViewLocal.translatesAutoresizingMaskIntoConstraints = false + rootContainerViewLocal.backgroundColor = UIColor.clear + displayNode.view.addSubview(rootContainerViewLocal) + + let callControllerViewLocal = CallControllerView(sharedContext: self.sharedContext, + accountContext: self.accountContext, + 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) + callControllerView = callControllerViewLocal + rootContainerView.addSubview(callControllerViewLocal) + + callControllerView.isMuted = self.isMuted + if let audioOutputStateActual = audioOutputState { + callControllerView.updateAudioOutputs(availableOutputs: audioOutputStateActual.0, currentOutput: audioOutputStateActual.1) + } + + setupCallControllerViewObservers() + setupObservers() + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if !self.didPlayPresentationAnimation { + self.didPlayPresentationAnimation = true + self.callControllerView.animateIn() + } + self.idleTimerExtensionDisposable.set(self.sharedContext.applicationBindings.pushIdleTimerExtension()) + + // TODO: implement + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.seconds(5), execute: { + print("===============================================VIEWS:==========================================") + let rootContainerViewLocal: UIView = self.rootContainerView + print(rootContainerViewLocal) + print(rootContainerViewLocal.subviews) + }) + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + self.idleTimerExtensionDisposable.set(nil) + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + rootContainerView.frame = self.displayNode.view.bounds + callControllerView.frame = rootContainerView.bounds + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + self.callControllerView.containerLayoutUpdated(layout, + navigationBarHeight: navigationLayout(layout: layout).navigationFrame.maxY, + transition: transition) + } + + override public func dismiss(completion: (() -> Void)? = nil) { + self.callControllerView.animateOut(completion: { [weak self] in + self?.didPlayPresentationAnimation = false + self?.presentingViewController?.dismiss(animated: false, completion: nil) + completion?() + }) + } + +} + +// MARK: - Public + +extension CallViewController { + + public func expandFromPipIfPossible() { + callControllerView.expandFromPipIfPossible() + } + +} + +// MARK: - Interface Callbacks + +private extension CallViewController { + + @objc private func backPressed() { + self.dismiss() + } + +} + +// MARK: - Private + +private extension CallViewController { + + private func setupCallControllerViewObservers() { + self.callControllerView.toggleMute = { [weak self] in + self?.call.toggleIsMuted() + } + + self.callControllerView.setCurrentAudioOutput = { [weak self] output in + self?.call.setCurrentAudioOutput(output) + } + + self.callControllerView.beginAudioOuputSelection = { [weak self] hasMute in + guard let strongSelf = self, let (availableOutputs, currentOutput) = strongSelf.audioOutputState else { + return + } + guard availableOutputs.count >= 2 else { + return + } + if availableOutputs.count == 2 { + for output in availableOutputs { + if output != currentOutput { + strongSelf.call.setCurrentAudioOutput(output) + break + } + } + } else { + let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) + var items: [ActionSheetItem] = [] + for output in availableOutputs { + if hasMute, case .builtin = output { + continue + } + let title: String + var icon: UIImage? + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = strongSelf.presentationData.strings.Call_AudioRouteSpeaker + icon = generateScaledImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false) + case .headphones: + title = strongSelf.presentationData.strings.Call_AudioRouteHeadphones + case let .port(port): + title = port.name + if port.type == .bluetooth { + var image = UIImage(bundleImageName: "Call/CallBluetoothButton") + let portName = port.name.lowercased() + if portName.contains("airpods max") { + image = UIImage(bundleImageName: "Call/CallAirpodsMaxButton") + } else if portName.contains("airpods pro") { + image = UIImage(bundleImageName: "Call/CallAirpodsProButton") + } else if portName.contains("airpods") { + image = UIImage(bundleImageName: "Call/CallAirpodsButton") + } + icon = generateScaledImage(image: image, size: CGSize(width: 48.0, height: 48.0), opaque: false) + } + } + items.append(CallRouteActionSheetItem(title: title, icon: icon, selected: output == currentOutput, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + self?.call.setCurrentAudioOutput(output) + })) + } + + if hasMute { + items.append(CallRouteActionSheetItem(title: strongSelf.presentationData.strings.Call_AudioRouteMute, icon: generateScaledImage(image: UIImage(bundleImageName: "Call/CallMuteButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false), selected: strongSelf.isMuted, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + self?.call.toggleIsMuted() + })) + } + + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: strongSelf.presentationData.strings.Call_AudioRouteHide, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + strongSelf.present(actionSheet, in: .window(.calls)) + } + } + + self.callControllerView.acceptCall = { [weak self] in + let _ = self?.call.answer() + } + + self.callControllerView.endCall = { [weak self] in + let _ = self?.call.hangUp() + } + + self.callControllerView.back = { [weak self] in + let _ = self?.dismiss() + } + + self.callControllerView.presentCallRating = { [weak self] callId, isVideo in + if let strongSelf = self, !strongSelf.presentedCallRating { + strongSelf.presentedCallRating = true + + Queue.mainQueue().after(0.5, { + let window = strongSelf.window + let controller = callRatingController(sharedContext: strongSelf.sharedContext, account: strongSelf.account, callId: callId, userInitiated: false, isVideo: isVideo, present: { c, a in + if let window = window { + c.presentationArguments = a + window.present(c, on: .root, blockInteraction: false, completion: {}) + } + }, push: { [weak self] c in + if let strongSelf = self { + strongSelf.push(c) + } + }) + strongSelf.present(controller, in: .window(.root)) + }) + } + } + + self.callControllerView.present = { [weak self] controller in + if let strongSelf = self { + strongSelf.present(controller, in: .window(.root)) + } + } + + self.callControllerView.dismissAllTooltips = { [weak self] in + if let strongSelf = self { + strongSelf.forEachController({ controller in + if let controller = controller as? TooltipScreen { + controller.dismiss() + } + return true + }) + } + } + + self.callControllerView.callEnded = { [weak self] didPresentRating in + if let strongSelf = self, !didPresentRating { + let _ = (combineLatest(strongSelf.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.callListSettings]), ApplicationSpecificNotice.getCallsTabTip(accountManager: strongSelf.sharedContext.accountManager)) + |> map { sharedData, callsTabTip -> Int32 in + var value = false + if let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.callListSettings]?.get(CallListSettings.self) { + value = settings.showTab + } + if value { + return 3 + } else { + return callsTabTip + } + } |> deliverOnMainQueue).start(next: { [weak self] callsTabTip in + if let strongSelf = self { + if callsTabTip == 2 { + Queue.mainQueue().after(1.0) { + let controller = callSuggestTabController(sharedContext: strongSelf.sharedContext) + strongSelf.present(controller, in: .window(.root)) + } + } + if callsTabTip < 3 { + let _ = ApplicationSpecificNotice.incrementCallsTabTips(accountManager: strongSelf.sharedContext.accountManager).start() + } + } + }) + } + } + + self.callControllerView.dismissedInteractively = { [weak self] in + self?.didPlayPresentationAnimation = false + self?.presentingViewController?.dismiss(animated: false, completion: nil) + } + } + + private func setupObservers() { + 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)) + |> 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] { + strongSelf.peer = peer + strongSelf.callControllerView.updatePeer(accountPeer: accountPeer, peer: peer, hasOther: activeAccountsWithInfo.accounts.count > 1) + strongSelf._ready.set(.single(true)) + } + } + }) + } + + private func callStateUpdated(_ callState: PresentationCallState) { + if self.isNodeLoaded { + self.callControllerView.updateCallState(callState) + } + } + + // private var containerTransformationView: UIView! // CallControllerNode: containerTransformationNode: ASDisplayNode + // private var contentContainerView: UIView! // CallControllerNode: containerNode: ASDisplayNode + // private var videoContainerView: UIView! // CallControllerNode: videoContainerNode: PinchSourceContainerNode + // private var backButtonArrowView: UIImageView! // CallControllerNode: backButtonArrowNode: ASImageNode + // private var backButton: UIButton! // CallControllerNode: backButtonNode: HighlightableButtonNode + +// private func setupUI() { +// let rootContainerViewLocal = UIView(frame: self.displayNode.view.bounds) +// rootContainerView = rootContainerViewLocal +// rootContainerViewLocal.translatesAutoresizingMaskIntoConstraints = false +// rootContainerViewLocal.backgroundColor = UIColor.clear +// displayNode.view.addSubview(rootContainerViewLocal) +// +// let containerTransformationViewLocal = UIView(frame: rootContainerView.bounds) +// containerTransformationView = containerTransformationViewLocal +// containerTransformationViewLocal.clipsToBounds = true +// containerTransformationViewLocal.backgroundColor = UIColor.clear +// rootContainerView.addSubview(containerTransformationViewLocal) +// +// let contentContainerViewLocal = UIView(frame: containerTransformationView.bounds) +// contentContainerView = contentContainerViewLocal +// contentContainerViewLocal.backgroundColor = UIColor.black +// containerTransformationView.addSubview(contentContainerViewLocal) +// +// backButtonArrowView = UIImageView(image: nil) +// backButtonArrowView.image = NavigationBarTheme.generateBackArrowImage(color: .white) +// contentContainerView.addSubview(backButtonArrowView) +// +// backButton = UIButton(frame: CGRectZero) +// self.backButton.addTarget(self, action: #selector(self.backPressed), for: .touchUpInside) +// self.backButton.setTitle(presentationData.strings.Common_Back, for: .normal) +//// self.backButton.setTitle(presentationData.strings.Common_Back, with: Font.regular(17.0), with: .white, for: []) +// self.backButton.accessibilityLabel = presentationData.strings.Call_VoiceOver_Minimize +// self.backButton.accessibilityTraits = [.button] +//// self.backButton.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) +//// } +//// } +//// } +// contentContainerView.addSubview(backButton) +// } + +} diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift b/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift new file mode 100644 index 00000000000..29e032b08cb --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift @@ -0,0 +1,701 @@ +import Foundation +import UIKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit +import AccountContext +import TelegramPresentationData +import SolidRoundedButtonNode +import PresentationDataUtils +import UIKitRuntimeUtils +import ReplayKit + +private let accentColor: UIColor = UIColor(rgb: 0x007aff) + +protocol PreviewVideoView: UIView { + var ready: Signal { get } + + func flip(withBackground: Bool) + func updateIsBlurred(isBlurred: Bool, light: Bool, animated: Bool) + + func updateLayout(size: CGSize, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition) +} + +final class VoiceChatCameraPreviewViewController: ViewController { + + private let sharedContext: SharedAccountContext + + private var animatedIn = false + + private var controllerNode: VoiceChatCameraPreviewViewControllerView! + private let cameraNode: PreviewVideoView + private let shareCamera: (UIView, Bool) -> Void + private let switchCamera: () -> Void + + private var presentationDataDisposable: Disposable? + + init(sharedContext: SharedAccountContext, cameraNode: PreviewVideoView, shareCamera: @escaping (UIView, Bool) -> Void, switchCamera: @escaping () -> Void) { + self.sharedContext = sharedContext + self.cameraNode = cameraNode + self.shareCamera = shareCamera + self.switchCamera = switchCamera + + super.init(navigationBarPresentationData: nil) + + self.statusBar.statusBarStyle = .Ignore + + self.blocksBackgroundWhenInOverlay = true + + self.presentationDataDisposable = (sharedContext.presentationData + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + if let strongSelf = self { + if !strongSelf.isNodeLoaded { + strongSelf.loadDisplayNode() + } + strongSelf.controllerNode.updatePresentationData(presentationData) + } + }) + + self.statusBar.statusBarStyle = .Ignore + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.presentationDataDisposable?.dispose() + } + + override public func loadDisplayNode() { + displayNode = ASDisplayNode() + displayNode.displaysAsynchronously = false + let controllerNodeLocal = VoiceChatCameraPreviewViewControllerView(controller: self, + sharedContext: self.sharedContext, + cameraNode: self.cameraNode) + controllerNode = controllerNodeLocal + displayNode.view.addSubview(controllerNodeLocal) + self.controllerNode.shareCamera = { [weak self] unmuted in + if let strongSelf = self { + strongSelf.shareCamera(strongSelf.cameraNode, unmuted) + strongSelf.dismiss() + } + } + self.controllerNode.switchCamera = { [weak self] in + self?.switchCamera() + self?.cameraNode.flip(withBackground: false) + } + self.controllerNode.dismiss = { [weak self] in + self?.presentingViewController?.dismiss(animated: false, completion: nil) + } + self.controllerNode.cancel = { [weak self] in + self?.dismiss() + } + } + + override public func loadView() { + super.loadView() + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !self.animatedIn { + self.animatedIn = true + self.controllerNode.animateIn() + } + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + controllerNode.frame = displayNode.view.bounds + } + + override public func dismiss(completion: (() -> Void)? = nil) { + self.controllerNode.animateOut(completion: completion) + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) + } +} + +private class VoiceChatCameraPreviewViewControllerView: ViewControllerTracingNodeView, UIScrollViewDelegate { + private weak var controller: VoiceChatCameraPreviewViewController? + private let sharedContext: SharedAccountContext + private var presentationData: PresentationData + + private let cameraNode: PreviewVideoView + private let dimNode: ASDisplayNode + private let wrappingScrollNode: UIScrollView + private let contentContainerNode: UIView + private let backgroundNode: UIView + private let contentBackgroundNode: UIView + private let titleNode: ASTextNode + private let previewContainerNode: UIView + private let shimmerNode: ShimmerEffectForegroundView + private let doneButton: SolidRoundedButtonNode + private var broadcastPickerView: UIView? + private let cancelButton: HighlightableButtonNode + + private let placeholderTextNode: ImmediateTextNode + private let placeholderIconNode: ASImageNode + + private var wheelNode: WheelControlNode + private var selectedTabIndex: Int = 1 + private var containerLayout: (ContainerViewLayout, CGFloat)? + + private var applicationStateDisposable: Disposable? + + private let hapticFeedback = HapticFeedback() + + private let readyDisposable = MetaDisposable() + + var shareCamera: ((Bool) -> Void)? + var switchCamera: (() -> Void)? + var dismiss: (() -> Void)? + var cancel: (() -> Void)? + + init(controller: VoiceChatCameraPreviewViewController, sharedContext: SharedAccountContext, cameraNode: PreviewVideoView) { + self.controller = controller + self.sharedContext = sharedContext + self.presentationData = sharedContext.currentPresentationData.with { $0 } + + self.cameraNode = cameraNode + + self.wrappingScrollNode = UIScrollView() + self.wrappingScrollNode.alwaysBounceVertical = true + self.wrappingScrollNode.delaysContentTouches = false + self.wrappingScrollNode.canCancelContentTouches = true + + self.dimNode = ASDisplayNode() + self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + + self.contentContainerNode = UIView() + self.contentContainerNode.isOpaque = false + + self.backgroundNode = UIView() + self.backgroundNode.clipsToBounds = true + self.backgroundNode.layer.cornerRadius = 16.0 + + let backgroundColor = UIColor(rgb: 0x000000) + + self.contentBackgroundNode = UIView() + self.contentBackgroundNode.backgroundColor = backgroundColor + + let title = self.presentationData.strings.VoiceChat_VideoPreviewTitle + + self.titleNode = ASTextNode() + self.titleNode.attributedText = NSAttributedString(string: title, font: Font.bold(17.0), textColor: UIColor(rgb: 0xffffff)) + + self.doneButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: UIColor(rgb: 0xffffff), foregroundColor: UIColor(rgb: 0x4f5352)), font: .bold, height: 48.0, cornerRadius: 24.0, gloss: false) + self.doneButton.title = self.presentationData.strings.VoiceChat_VideoPreviewContinue + + if #available(iOS 12.0, *) { + let broadcastPickerView = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 50, height: 52.0)) + broadcastPickerView.alpha = 0.02 + broadcastPickerView.isHidden = true + broadcastPickerView.preferredExtension = "\(self.sharedContext.applicationBindings.appBundleId).BroadcastUpload" + broadcastPickerView.showsMicrophoneButton = false + self.broadcastPickerView = broadcastPickerView + } + + self.cancelButton = HighlightableButtonNode() + self.cancelButton.setAttributedTitle(NSAttributedString(string: self.presentationData.strings.Common_Cancel, font: Font.regular(17.0), textColor: UIColor(rgb: 0xffffff)), for: []) + + self.previewContainerNode = UIView() + self.previewContainerNode.clipsToBounds = true + self.previewContainerNode.layer.cornerRadius = 11.0 + self.previewContainerNode.backgroundColor = UIColor(rgb: 0x2b2b2f) + + self.shimmerNode = ShimmerEffectForegroundView(size: 200.0) + self.previewContainerNode.addSubview(self.shimmerNode) + + self.placeholderTextNode = ImmediateTextNode() + self.placeholderTextNode.alpha = 0.0 + self.placeholderTextNode.maximumNumberOfLines = 3 + self.placeholderTextNode.textAlignment = .center + + self.placeholderIconNode = ASImageNode() + self.placeholderIconNode.alpha = 0.0 + self.placeholderIconNode.contentMode = .scaleAspectFit + self.placeholderIconNode.displaysAsynchronously = false + + self.wheelNode = WheelControlNode(items: [WheelControlNode.Item(title: UIDevice.current.model == "iPad" ? self.presentationData.strings.VoiceChat_VideoPreviewTabletScreen : self.presentationData.strings.VoiceChat_VideoPreviewPhoneScreen), WheelControlNode.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewFrontCamera), WheelControlNode.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewBackCamera)], selectedIndex: self.selectedTabIndex) + + super.init(frame: CGRect.zero) + + self.backgroundColor = nil + self.isOpaque = false + + self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + self.addSubnode(self.dimNode) + + self.wrappingScrollNode.delegate = self + self.addSubview(self.wrappingScrollNode) + + self.wrappingScrollNode.addSubview(self.backgroundNode) + self.wrappingScrollNode.addSubview(self.contentContainerNode) + + self.backgroundNode.addSubview(self.contentBackgroundNode) + self.contentContainerNode.addSubview(self.previewContainerNode) + self.contentContainerNode.addSubnode(self.titleNode) + self.contentContainerNode.addSubnode(self.doneButton) + if let broadcastPickerView = self.broadcastPickerView { + self.contentContainerNode.addSubview(broadcastPickerView) + } + self.contentContainerNode.addSubnode(self.cancelButton) + + self.previewContainerNode.addSubview(self.cameraNode) + + self.previewContainerNode.addSubnode(self.placeholderIconNode) + self.previewContainerNode.addSubnode(self.placeholderTextNode) + + self.previewContainerNode.addSubnode(self.wheelNode) + + self.wheelNode.selectedIndexChanged = { [weak self] index in + if let strongSelf = self { + if (index == 1 && strongSelf.selectedTabIndex == 2) || (index == 2 && strongSelf.selectedTabIndex == 1) { + strongSelf.switchCamera?() + } + if index == 0 && [1, 2].contains(strongSelf.selectedTabIndex) { + strongSelf.broadcastPickerView?.isHidden = false + strongSelf.cameraNode.updateIsBlurred(isBlurred: true, light: false, animated: true) + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) + transition.updateAlpha(node: strongSelf.placeholderTextNode, alpha: 1.0) + transition.updateAlpha(node: strongSelf.placeholderIconNode, alpha: 1.0) + } else if [1, 2].contains(index) && strongSelf.selectedTabIndex == 0 { + strongSelf.broadcastPickerView?.isHidden = true + strongSelf.cameraNode.updateIsBlurred(isBlurred: false, light: false, animated: true) + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) + transition.updateAlpha(node: strongSelf.placeholderTextNode, alpha: 0.0) + transition.updateAlpha(node: strongSelf.placeholderIconNode, alpha: 0.0) + } + strongSelf.selectedTabIndex = index + } + } + + self.doneButton.pressed = { [weak self] in + if let strongSelf = self { + strongSelf.shareCamera?(true) + } + } + self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside) + + self.readyDisposable.set(self.cameraNode.ready.start(next: { [weak self] ready in + if let strongSelf = self, ready { + Queue.mainQueue().after(0.07) { + strongSelf.shimmerNode.alpha = 0.0 + strongSelf.shimmerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) + } + } + })) + + let leftSwipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.leftSwipeGesture)) + leftSwipeGestureRecognizer.direction = .left + let rightSwipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.rightSwipeGesture)) + rightSwipeGestureRecognizer.direction = .right + + self.addGestureRecognizer(leftSwipeGestureRecognizer) + self.addGestureRecognizer(rightSwipeGestureRecognizer) + + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.wrappingScrollNode.contentInsetAdjustmentBehavior = .never + } + + hitTestImpl = { [weak self] point, event in + return self?.hitTest(point, with: event) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.readyDisposable.dispose() + self.applicationStateDisposable?.dispose() + } + + func updatePresentationData(_ presentationData: PresentationData) { + self.presentationData = presentationData + } + + @objc func leftSwipeGesture() { + if self.selectedTabIndex < 2 { + self.wheelNode.setSelectedIndex(self.selectedTabIndex + 1, animated: true) + self.wheelNode.selectedIndexChanged(self.wheelNode.selectedIndex) + } + } + + @objc func rightSwipeGesture() { + if self.selectedTabIndex > 0 { + self.wheelNode.setSelectedIndex(self.selectedTabIndex - 1, animated: true) + self.wheelNode.selectedIndexChanged(self.wheelNode.selectedIndex) + } + } + + @objc func cancelPressed() { + self.cancel?() + } + + @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.cancel?() + } + } + + func animateIn() { + self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + + let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY + let dimPosition = self.dimNode.layer.position + + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + let targetBounds = self.bounds + self.bounds = self.bounds.offsetBy(dx: 0.0, dy: -offset) + self.dimNode.position = CGPoint(x: dimPosition.x, y: dimPosition.y - offset) + transition.animateView({ + self.bounds = targetBounds + self.dimNode.position = dimPosition + }) + + self.applicationStateDisposable = (self.sharedContext.applicationBindings.applicationIsActive + |> filter { !$0 } + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] _ in + guard let strongSelf = self else { + return + } + strongSelf.controller?.dismiss() + }) + } + + func animateOut(completion: (() -> Void)? = nil) { + var dimCompleted = false + var offsetCompleted = false + + let internalCompletion: () -> Void = { [weak self] in + if let strongSelf = self, dimCompleted && offsetCompleted { + strongSelf.dismiss?() + } + completion?() + } + + self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in + dimCompleted = true + internalCompletion() + }) + + let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY + let dimPosition = self.dimNode.layer.position + self.dimNode.layer.animatePosition(from: dimPosition, to: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + self.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + offsetCompleted = true + internalCompletion() + }) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.bounds.contains(point) { + if !self.contentBackgroundNode.bounds.contains(self.convert(point, to: self.contentBackgroundNode)) { + return self.dimNode.view + } + } + return super.hitTest(point, with: event) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + let contentOffset = scrollView.contentOffset + let additionalTopHeight = max(0.0, -contentOffset.y) + + if additionalTopHeight >= 30.0 { + self.cancel?() + } + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.containerLayout = (layout, navigationBarHeight) + + let isLandscape: Bool + if layout.size.width > layout.size.height { + isLandscape = true + } else { + isLandscape = false + } + let isTablet: Bool + if case .regular = layout.metrics.widthClass { + isTablet = true + } else { + isTablet = false + } + + var insets = layout.insets(options: [.statusBar]) + insets.top = max(10.0, insets.top) + + let contentSize: CGSize + if isLandscape { + if isTablet { + contentSize = CGSize(width: 870.0, height: 690.0) + } else { + contentSize = CGSize(width: layout.size.width, height: layout.size.height) + } + } else { + if isTablet { + contentSize = CGSize(width: 600.0, height: 960.0) + } else { + contentSize = CGSize(width: layout.size.width, height: layout.size.height - insets.top - 8.0) + } + } + + let sideInset = floor((layout.size.width - contentSize.width) / 2.0) + let contentFrame: CGRect + if isTablet { + contentFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((layout.size.height - contentSize.height) / 2.0)), size: contentSize) + } else { + contentFrame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - contentSize.height), size: contentSize) + } + var backgroundFrame = contentFrame + if !isTablet { + backgroundFrame.size.height += 2000.0 + } + if backgroundFrame.minY < contentFrame.minY { + backgroundFrame.origin.y = contentFrame.minY + } + transition.updateFrame(view: self.backgroundNode, frame: backgroundFrame) + transition.updateFrame(view: self.contentBackgroundNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) + transition.updateFrame(view: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + self.wrappingScrollNode.contentSize = backgroundFrame.size + transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + + let titleSize = self.titleNode.measure(CGSize(width: contentFrame.width, height: .greatestFiniteMagnitude)) + let titleFrame = CGRect(origin: CGPoint(x: floor((contentFrame.width - titleSize.width) / 2.0), y: 20.0), size: titleSize) + transition.updateFrame(node: self.titleNode, frame: titleFrame) + + var previewSize: CGSize + var previewFrame: CGRect + let previewAspectRatio: CGFloat = 1.85 + if isLandscape { + let previewHeight = contentFrame.height + previewSize = CGSize(width: min(contentFrame.width - layout.safeInsets.left - layout.safeInsets.right, ceil(previewHeight * previewAspectRatio)), height: previewHeight) + previewFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentFrame.width - previewSize.width) / 2.0), y: 0.0), size: previewSize) + } else { + previewSize = CGSize(width: contentFrame.width, height: min(contentFrame.height, ceil(contentFrame.width * previewAspectRatio))) + previewFrame = CGRect(origin: CGPoint(), size: previewSize) + } + transition.updateFrame(view: self.previewContainerNode, frame: previewFrame) + transition.updateFrame(view: self.shimmerNode, frame: CGRect(origin: CGPoint(), size: previewFrame.size)) + self.shimmerNode.update(foregroundColor: UIColor(rgb: 0xffffff, alpha: 0.07)) + self.shimmerNode.updateAbsoluteRect(previewFrame, within: layout.size) + + let cancelButtonSize = self.cancelButton.measure(CGSize(width: (previewFrame.width - titleSize.width) / 2.0, height: .greatestFiniteMagnitude)) + let cancelButtonFrame = CGRect(origin: CGPoint(x: previewFrame.minX + 17.0, y: 20.0), size: cancelButtonSize) + transition.updateFrame(node: self.cancelButton, frame: cancelButtonFrame) + + self.cameraNode.frame = CGRect(origin: CGPoint(), size: previewSize) + self.cameraNode.updateLayout(size: previewSize, layoutMode: isLandscape ? .fillHorizontal : .fillVertical, transition: .immediate) + + self.placeholderTextNode.attributedText = NSAttributedString(string: presentationData.strings.VoiceChat_VideoPreviewShareScreenInfo, font: Font.semibold(16.0), textColor: .white) + self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: isTablet ? "Call/ScreenShareTablet" : "Call/ScreenSharePhone"), color: .white) + + let placeholderTextSize = self.placeholderTextNode.updateLayout(CGSize(width: previewSize.width - 80.0, height: 100.0)) + transition.updateFrame(node: self.placeholderTextNode, frame: CGRect(origin: CGPoint(x: floor((previewSize.width - placeholderTextSize.width) / 2.0), y: floorToScreenPixels(previewSize.height / 2.0) + 10.0), size: placeholderTextSize)) + if let imageSize = self.placeholderIconNode.image?.size { + transition.updateFrame(node: self.placeholderIconNode, frame: CGRect(origin: CGPoint(x: floor((previewSize.width - imageSize.width) / 2.0), y: floorToScreenPixels(previewSize.height / 2.0) - imageSize.height - 8.0), size: imageSize)) + } + + let buttonInset: CGFloat = 16.0 + let buttonMaxWidth: CGFloat = 360.0 + + let buttonWidth = min(buttonMaxWidth, contentFrame.width - buttonInset * 2.0) + let doneButtonHeight = self.doneButton.updateLayout(width: buttonWidth, transition: transition) + transition.updateFrame(node: self.doneButton, frame: CGRect(x: floorToScreenPixels((contentFrame.width - buttonWidth) / 2.0), y: previewFrame.maxY - doneButtonHeight - buttonInset, width: buttonWidth, height: doneButtonHeight)) + self.broadcastPickerView?.frame = self.doneButton.frame + + let wheelFrame = CGRect(origin: CGPoint(x: 16.0 + previewFrame.minX, y: previewFrame.maxY - doneButtonHeight - buttonInset - 36.0 - 20.0), size: CGSize(width: previewFrame.width - 32.0, height: 36.0)) + self.wheelNode.updateLayout(size: wheelFrame.size, transition: transition) + transition.updateFrame(node: self.wheelNode, frame: wheelFrame) + + transition.updateFrame(view: self.contentContainerNode, frame: contentFrame) + } +} + +private let textFont = Font.with(size: 14.0, design: .camera, weight: .regular) +private let selectedTextFont = Font.with(size: 14.0, design: .camera, weight: .semibold) + +private class WheelControlNode: ASDisplayNode, UIGestureRecognizerDelegate { + struct Item: Equatable { + public let title: String + + public init(title: String) { + self.title = title + } + } + + private let maskNode: ASDisplayNode + private let containerNode: ASDisplayNode + private var itemNodes: [HighlightTrackingButtonNode] + + private var validLayout: CGSize? + + private var _items: [Item] + private var _selectedIndex: Int = 0 + + public var selectedIndex: Int { + get { + return self._selectedIndex + } + set { + guard newValue != self._selectedIndex else { + return + } + self._selectedIndex = newValue + if let size = self.validLayout { + self.updateLayout(size: size, transition: .immediate) + } + } + } + + public func setSelectedIndex(_ index: Int, animated: Bool) { + guard index != self._selectedIndex else { + return + } + self._selectedIndex = index + if let size = self.validLayout { + self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .easeInOut)) + } + } + + public var selectedIndexChanged: (Int) -> Void = { _ in } + + public init(items: [Item], selectedIndex: Int) { + self._items = items + self._selectedIndex = selectedIndex + + self.maskNode = ASDisplayNode() + self.maskNode.setLayerBlock({ + let maskLayer = CAGradientLayer() + maskLayer.colors = [UIColor.clear.cgColor, UIColor.white.cgColor, UIColor.white.cgColor, UIColor.clear.cgColor] + maskLayer.locations = [0.0, 0.15, 0.85, 1.0] + maskLayer.startPoint = CGPoint(x: 0.0, y: 0.0) + maskLayer.endPoint = CGPoint(x: 1.0, y: 0.0) + return maskLayer + }) + self.containerNode = ASDisplayNode() + + self.itemNodes = items.map { item in + let itemNode = HighlightTrackingButtonNode() + itemNode.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0) + itemNode.titleNode.maximumNumberOfLines = 1 + itemNode.titleNode.truncationMode = .byTruncatingTail + itemNode.accessibilityLabel = item.title + itemNode.accessibilityTraits = [.button] + itemNode.hitTestSlop = UIEdgeInsets(top: -10.0, left: -5.0, bottom: -10.0, right: -5.0) + itemNode.setTitle(item.title.uppercased(), with: textFont, with: .white, for: .normal) + itemNode.titleNode.shadowColor = UIColor.black.cgColor + itemNode.titleNode.shadowOffset = CGSize() + itemNode.titleNode.layer.shadowRadius = 2.0 + itemNode.titleNode.layer.shadowOpacity = 0.3 + itemNode.titleNode.layer.masksToBounds = false + itemNode.titleNode.layer.shouldRasterize = true + itemNode.titleNode.layer.rasterizationScale = UIScreen.main.scale + return itemNode + } + + super.init() + + self.clipsToBounds = true + + self.addSubnode(self.containerNode) + + self.itemNodes.forEach(self.containerNode.addSubnode(_:)) + self.setupButtons() + } + + override func didLoad() { + super.didLoad() + + self.view.layer.mask = self.maskNode.layer + + self.view.disablesInteractiveTransitionGestureRecognizer = true + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + self.validLayout = size + + let bounds = CGRect(origin: CGPoint(), size: size) + + transition.updateFrame(node: self.maskNode, frame: bounds) + + let spacing: CGFloat = 15.0 + if !self.itemNodes.isEmpty { + var leftOffset: CGFloat = 0.0 + var selectedItemNode: ASDisplayNode? + for i in 0 ..< self.itemNodes.count { + let itemNode = self.itemNodes[i] + let itemSize = itemNode.measure(size) + transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: leftOffset, y: (size.height - itemSize.height) / 2.0), size: itemSize)) + + leftOffset += itemSize.width + spacing + + let isSelected = self.selectedIndex == i + if isSelected { + selectedItemNode = itemNode + } + if itemNode.isSelected != isSelected { + itemNode.isSelected = isSelected + let title = itemNode.attributedTitle(for: .normal)?.string ?? "" + itemNode.setTitle(title, with: isSelected ? selectedTextFont : textFont, with: isSelected ? UIColor(rgb: 0xffd60a) : .white, for: .normal) + if isSelected { + itemNode.accessibilityTraits.insert(.selected) + } else { + itemNode.accessibilityTraits.remove(.selected) + } + } + } + + let totalWidth = leftOffset - spacing + if let selectedItemNode = selectedItemNode { + let itemCenter = selectedItemNode.frame.center + transition.updateFrame(node: self.containerNode, frame: CGRect(x: bounds.width / 2.0 - itemCenter.x, y: 0.0, width: totalWidth, height: bounds.height)) + + for i in 0 ..< self.itemNodes.count { + let itemNode = self.itemNodes[i] + let convertedBounds = itemNode.view.convert(itemNode.bounds, to: self.view) + let position = convertedBounds.center + let offset = position.x - bounds.width / 2.0 + let angle = abs(offset / bounds.width * 0.99) + let sign: CGFloat = offset > 0 ? 1.0 : -1.0 + + var transform = CATransform3DMakeTranslation(-22.0 * angle * angle * sign, 0.0, 0.0) + transform = CATransform3DRotate(transform, angle, 0.0, sign, 0.0) + transition.animateView { + itemNode.transform = transform + } + } + } + } + } + + private func setupButtons() { + for i in 0 ..< self.itemNodes.count { + let itemNode = self.itemNodes[i] + itemNode.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside) + } + } + + @objc private func buttonPressed(_ button: HighlightTrackingButtonNode) { + guard let index = self.itemNodes.firstIndex(of: button) else { + return + } + + self._selectedIndex = index + self.selectedIndexChanged(index) + if let size = self.validLayout { + self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .slide)) + } + } +} diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatTileItemView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatTileItemView.swift new file mode 100644 index 00000000000..f85bc6aee90 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatTileItemView.swift @@ -0,0 +1,877 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import SwiftSignalKit +import Postbox +import TelegramCore +import AccountContext +import TelegramUIPreferences +import TelegramPresentationData +import AvatarNode + +private let backgroundCornerRadius: CGFloat = 11.0 +private let borderLineWidth: CGFloat = 2.0 + +private let destructiveColor: UIColor = UIColor(rgb: 0xff3b30) + +private let fadeColor = UIColor(rgb: 0x000000, alpha: 0.5) + +final class VoiceChatTileItemView: UIView { + private let context: AccountContext + + let contextSourceNode: ContextExtractedContentContainingView + private let containerNode: ContextControllerSourceView + let contentNode: UIView + let backgroundNode: ASDisplayNode + var videoContainerNode: ASDisplayNode + var videoNode: GroupVideoNode? + let infoNode: ASDisplayNode + let fadeNode: UIView + private var shimmerNode: VoiceChatTileShimmeringView? + private let titleNode: ImmediateTextNode + private var iconNode: ASImageNode? + private var animationNode: VoiceChatMicrophoneNode? + var highlightNode: VoiceChatTileHighlightView + private let statusNode: VoiceChatParticipantStatusNode + + let placeholderTextNode: ImmediateTextNode + let placeholderIconNode: ASImageNode + + private var profileNode: VoiceChatPeerProfileNode? + private var extractedRect: CGRect? + private var nonExtractedRect: CGRect? + + private var validLayout: (CGSize, CGFloat)? + var item: VoiceChatTileItem? + private var isExtracted = false + + private let audioLevelDisposable = MetaDisposable() + + private let hierarchyTrackingNode: HierarchyTrackingNode + private var isCurrentlyInHierarchy = false + + init(context: AccountContext) { + self.context = context + + self.contextSourceNode = ContextExtractedContentContainingView() + self.containerNode = ContextControllerSourceView() + + self.contentNode = UIView() + self.contentNode.clipsToBounds = true + self.contentNode.layer.cornerRadius = backgroundCornerRadius + + self.backgroundNode = ASDisplayNode() + self.backgroundNode.backgroundColor = panelBackgroundColor + + self.videoContainerNode = ASDisplayNode() + self.videoContainerNode.clipsToBounds = true + + self.infoNode = ASDisplayNode() + + self.fadeNode = UIView() + if let image = tileFadeImage { + self.fadeNode.backgroundColor = UIColor(patternImage: image) + } + + self.titleNode = ImmediateTextNode() + self.titleNode.displaysAsynchronously = false + + self.statusNode = VoiceChatParticipantStatusNode() + + self.highlightNode = VoiceChatTileHighlightView() + self.highlightNode.alpha = 0.0 + self.highlightNode.updateGlowAndGradientAnimations(type: .speaking) + + self.placeholderTextNode = ImmediateTextNode() + self.placeholderTextNode.alpha = 0.0 + self.placeholderTextNode.maximumNumberOfLines = 2 + self.placeholderTextNode.textAlignment = .center + + self.placeholderIconNode = ASImageNode() + self.placeholderIconNode.alpha = 0.0 + self.placeholderIconNode.contentMode = .scaleAspectFit + self.placeholderIconNode.displaysAsynchronously = false + + var updateInHierarchy: ((Bool) -> Void)? + self.hierarchyTrackingNode = HierarchyTrackingNode({ value in + updateInHierarchy?(value) + }) + + super.init(frame: CGRect.zero) + + self.addSubnode(self.hierarchyTrackingNode) + + self.containerNode.addSubview(self.contextSourceNode) + self.containerNode.targetViewForActivationProgress = self.contextSourceNode.contentView + self.addSubview(self.containerNode) + + self.contextSourceNode.contentView.addSubview(self.contentNode) + self.contentNode.addSubnode(self.backgroundNode) + self.contentNode.addSubnode(self.videoContainerNode) + self.contentNode.addSubview(self.fadeNode) + self.contentNode.addSubnode(self.infoNode) + self.infoNode.addSubnode(self.titleNode) + self.contentNode.addSubnode(self.placeholderTextNode) + self.contentNode.addSubnode(self.placeholderIconNode) + self.contentNode.addSubview(self.highlightNode) + + self.containerNode.shouldBegin = { [weak self] location in + guard let strongSelf = self, let item = strongSelf.item, item.videoReady && !item.isVideoLimit else { + return false + } + return true + } + self.containerNode.activated = { [weak self] gesture, _ in + guard let strongSelf = self, let item = strongSelf.item, let contextAction = item.contextAction, !item.isVideoLimit else { + gesture.cancel() + return + } + // TODO: implement + print(contextAction) +// contextAction(strongSelf.contextSourceNode, gesture) + } + self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in + guard let strongSelf = self, let _ = strongSelf.item else { + return + } + strongSelf.updateIsExtracted(isExtracted, transition: transition) + } + + updateInHierarchy = { [weak self] value in + if let strongSelf = self { + strongSelf.isCurrentlyInHierarchy = value + strongSelf.highlightNode.isCurrentlyInHierarchy = value + } + } + + if #available(iOS 13.0, *) { + self.contentNode.layer.cornerCurve = .continuous + } + + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.audioLevelDisposable.dispose() + } + + @objc private func tap() { + if let item = self.item { + item.action() + } + } + + private func updateIsExtracted(_ isExtracted: Bool, transition: ContainedViewLayoutTransition) { + guard self.isExtracted != isExtracted, let extractedRect = self.extractedRect, let nonExtractedRect = self.nonExtractedRect, let item = self.item else { + return + } + self.isExtracted = isExtracted + + let springDuration: Double = 0.42 + let springDamping: CGFloat = 124.0 + if isExtracted { + let profileNode = VoiceChatPeerProfileNode(context: self.context, size: extractedRect.size, sourceSize: nonExtractedRect.size, peer: item.peer, text: item.text, customNode: self.videoContainerNode, additionalEntry: .single(nil), requestDismiss: { [weak self] in + self?.contextSourceNode.requestDismiss?() + }) + profileNode.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + self.profileNode = profileNode + self.contextSourceNode.contentView.addSubnode(profileNode) + + // TODO: implement +// profileNode.animateIn(from: self, targetRect: extractedRect, transition: transition) + var appearenceTransition = transition + if transition.isAnimated { + appearenceTransition = .animated(duration: springDuration, curve: .customSpring(damping: springDamping, initialVelocity: 0.0)) + } + appearenceTransition.updateFrame(node: profileNode, frame: extractedRect) + + self.contextSourceNode.contentView.customHitTest = { [weak self] point in + if let strongSelf = self, let profileNode = strongSelf.profileNode { + if profileNode.avatarListWrapperNode.frame.contains(point) { + return profileNode.avatarListNode.view + } + } + return nil + } + + self.backgroundNode.isHidden = true + self.fadeNode.isHidden = true + self.infoNode.isHidden = true + self.highlightNode.isHidden = true + } else if let profileNode = self.profileNode { + self.profileNode = nil + + self.infoNode.isHidden = false + // TODO: implement +// profileNode.animateOut(to: self, targetRect: nonExtractedRect, transition: transition, completion: { [weak self] in +// if let strongSelf = self { +// strongSelf.backgroundNode.isHidden = false +// strongSelf.fadeNode.isHidden = false +// strongSelf.highlightNode.isHidden = false +// } +// }) + + var appearenceTransition = transition + if transition.isAnimated { + appearenceTransition = .animated(duration: 0.2, curve: .easeInOut) + } + appearenceTransition.updateFrame(node: profileNode, frame: nonExtractedRect) + + self.contextSourceNode.contentView.customHitTest = nil + } + } + + private var absoluteLocation: (CGRect, CGSize)? + func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { + self.absoluteLocation = (rect, containerSize) + if let shimmerNode = self.shimmerNode { + shimmerNode.updateAbsoluteRect(rect, within: containerSize) + } + self.updateIsEnabled() + } + + var visibility = true { + didSet { + self.updateIsEnabled() + } + } + + func updateIsEnabled() { + guard let (rect, containerSize) = self.absoluteLocation else { + return + } + let isVisibleInContainer = rect.maxY >= 0.0 && rect.minY <= containerSize.height + if let videoNode = self.videoNode, videoNode.supernode === self.videoContainerNode { + videoNode.updateIsEnabled(self.visibility && isVisibleInContainer) + } + } + + func update(size: CGSize, availableWidth: CGFloat, item: VoiceChatTileItem, transition: ContainedViewLayoutTransition) { + guard self.validLayout?.0 != size || self.validLayout?.1 != availableWidth || self.item != item else { + return + } + + self.validLayout = (size, availableWidth) + + if !item.videoReady || item.isOwnScreencast { + let shimmerNode: VoiceChatTileShimmeringView + let shimmerTransition: ContainedViewLayoutTransition + if let current = self.shimmerNode { + shimmerNode = current + shimmerTransition = transition + } else { + shimmerNode = VoiceChatTileShimmeringView(account: item.account, peer: item.peer) + self.contentNode.insertSubview(shimmerNode, aboveSubview: self.fadeNode) + self.shimmerNode = shimmerNode + + if let (rect, containerSize) = self.absoluteLocation { + shimmerNode.updateAbsoluteRect(rect, within: containerSize) + } + shimmerTransition = .immediate + } + shimmerTransition.updateFrame(view: shimmerNode, frame: CGRect(origin: CGPoint(), size: size)) + shimmerNode.update(shimmeringColor: UIColor.white, shimmering: !item.isOwnScreencast && !item.videoTimeouted && !item.isPaused, size: size, transition: shimmerTransition) + } else if let shimmerNode = self.shimmerNode { + self.shimmerNode = nil + shimmerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak shimmerNode] _ in + shimmerNode?.removeFromSuperview() + }) + } + + var nodeToAnimateIn: ASDisplayNode? + var placeholderAppeared = false + + var itemTransition = transition + if self.item != item { + let previousItem = self.item + self.item = item + + if let getAudioLevel = item.getAudioLevel { + self.audioLevelDisposable.set((getAudioLevel() + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let strongSelf = self else { + return + } + strongSelf.highlightNode.updateLevel(CGFloat(value)) + })) + } + + let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) + transition.updateAlpha(view: self.highlightNode, alpha: item.speaking ? 1.0 : 0.0) + + if previousItem?.videoEndpointId != item.videoEndpointId || self.videoNode == nil { + if let current = self.videoNode { + self.videoNode = nil + current.removeFromSupernode() + } + + if let videoNode = item.getVideo(item.secondary ? .list : .tile) { + itemTransition = .immediate + self.videoNode = videoNode + self.videoContainerNode.addSubnode(videoNode) + self.updateIsEnabled() + } + } + + self.videoNode?.updateIsBlurred(isBlurred: item.isPaused, light: true) + + var showPlaceholder = false + if item.isVideoLimit { + self.placeholderTextNode.attributedText = NSAttributedString(string: item.strings.VoiceChat_VideoParticipantsLimitExceeded(String(item.videoLimit)).string, font: Font.semibold(13.0), textColor: .white) + self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/VideoUnavailable"), color: .white) + showPlaceholder = true + } else if item.isOwnScreencast { + self.placeholderTextNode.attributedText = NSAttributedString(string: item.strings.VoiceChat_YouAreSharingScreen, font: Font.semibold(13.0), textColor: .white) + self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: item.isTablet ? "Call/ScreenShareTablet" : "Call/ScreenSharePhone"), color: .white) + showPlaceholder = true + } else if item.isPaused { + self.placeholderTextNode.attributedText = NSAttributedString(string: item.strings.VoiceChat_VideoPaused, font: Font.semibold(13.0), textColor: .white) + self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/Pause"), color: .white) + showPlaceholder = true + } + + placeholderAppeared = self.placeholderTextNode.alpha.isZero && showPlaceholder + transition.updateAlpha(node: self.placeholderTextNode, alpha: showPlaceholder ? 1.0 : 0.0) + transition.updateAlpha(node: self.placeholderIconNode, alpha: showPlaceholder ? 1.0 : 0.0) + + let titleFont = Font.semibold(13.0) + let titleColor = UIColor.white + var titleAttributedString: NSAttributedString? + if item.isVideoLimit { + titleAttributedString = nil + } else if let user = item.peer as? TelegramUser { + if let firstName = user.firstName, let lastName = user.lastName, !firstName.isEmpty, !lastName.isEmpty { + let string = NSMutableAttributedString() + switch item.nameDisplayOrder { + case .firstLast: + string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor)) + string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor)) + string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor)) + case .lastFirst: + string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor)) + string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor)) + string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor)) + } + titleAttributedString = string + } else if let firstName = user.firstName, !firstName.isEmpty { + titleAttributedString = NSAttributedString(string: firstName, font: titleFont, textColor: titleColor) + } else if let lastName = user.lastName, !lastName.isEmpty { + titleAttributedString = NSAttributedString(string: lastName, font: titleFont, textColor: titleColor) + } else { + titleAttributedString = NSAttributedString(string: item.strings.User_DeletedAccount, font: titleFont, textColor: titleColor) + } + } else if let group = item.peer as? TelegramGroup { + titleAttributedString = NSAttributedString(string: group.title, font: titleFont, textColor: titleColor) + } else if let channel = item.peer as? TelegramChannel { + titleAttributedString = NSAttributedString(string: channel.title, font: titleFont, textColor: titleColor) + } + + var microphoneColor = UIColor.white + if let additionalText = item.additionalText, case let .text(_, _, color) = additionalText { + if case .destructive = color { + microphoneColor = destructiveColor + } + } + self.titleNode.attributedText = titleAttributedString + + var hadMicrophoneNode = false + var hadIconNode = false + + if case let .microphone(muted) = item.icon { + let animationNode: VoiceChatMicrophoneNode + if let current = self.animationNode { + animationNode = current + } else { + animationNode = VoiceChatMicrophoneNode() + self.animationNode = animationNode + self.infoNode.addSubnode(animationNode) + + nodeToAnimateIn = animationNode + } + animationNode.alpha = 1.0 + animationNode.update(state: VoiceChatMicrophoneNode.State(muted: muted, filled: true, color: microphoneColor), animated: true) + } else if let animationNode = self.animationNode { + hadMicrophoneNode = true + self.animationNode = nil + animationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + animationNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false, completion: { [weak animationNode] _ in + animationNode?.removeFromSupernode() + }) + } + + if case .presentation = item.icon { + let iconNode: ASImageNode + if let current = self.iconNode { + iconNode = current + } else { + iconNode = ASImageNode() + iconNode.displaysAsynchronously = false + iconNode.contentMode = .center + self.iconNode = iconNode + self.infoNode.addSubnode(iconNode) + + nodeToAnimateIn = iconNode + } + + iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/StatusScreen"), color: .white) + } else if let iconNode = self.iconNode { + hadIconNode = true + self.iconNode = nil + iconNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + iconNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false, completion: { [weak iconNode] _ in + iconNode?.removeFromSupernode() + }) + } + + if let node = nodeToAnimateIn, hadMicrophoneNode || hadIconNode { + node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + node.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2) + } + } + + let bounds = CGRect(origin: CGPoint(), size: size) + self.containerNode.frame = bounds + self.contextSourceNode.frame = bounds + self.contextSourceNode.contentView.frame = bounds + + transition.updateFrame(view: self.contentNode, frame: bounds) + + let extractedWidth = availableWidth + let makeStatusLayout = self.statusNode.asyncLayout() + let (statusLayout, _) = makeStatusLayout(CGSize(width: availableWidth - 30.0, height: CGFloat.greatestFiniteMagnitude), item.text, true) + + let extractedRect = CGRect(x: 0.0, y: 0.0, width: extractedWidth, height: extractedWidth + statusLayout.height + 39.0) + let nonExtractedRect = bounds + self.extractedRect = extractedRect + self.nonExtractedRect = nonExtractedRect + + self.contextSourceNode.contentRect = extractedRect + + if self.videoContainerNode.supernode === self.contentNode { + if let videoNode = self.videoNode { + itemTransition.updateFrame(node: videoNode, frame: bounds) + if videoNode.supernode === self.videoContainerNode { + videoNode.updateLayout(size: size, layoutMode: .fillOrFitToSquare, transition: itemTransition) + } + } + transition.updateFrame(node: self.videoContainerNode, frame: bounds) + } + + transition.updateFrame(node: self.backgroundNode, frame: bounds) + transition.updateFrame(view: self.highlightNode, frame: bounds) + self.highlightNode.updateLayout(size: bounds.size, transition: transition) + transition.updateFrame(node: self.infoNode, frame: bounds) + transition.updateFrame(view: self.fadeNode, frame: CGRect(x: 0.0, y: size.height - fadeHeight, width: size.width, height: fadeHeight)) + + let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - 50.0, height: size.height)) + self.titleNode.frame = CGRect(origin: CGPoint(x: 30.0, y: size.height - titleSize.height - 8.0), size: titleSize) + + var transition = transition + if nodeToAnimateIn != nil || placeholderAppeared { + transition = .immediate + } + + if let iconNode = self.iconNode, let image = iconNode.image { + transition.updateFrame(node: iconNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels(16.0 - image.size.width / 2.0), y: floorToScreenPixels(size.height - 15.0 - image.size.height / 2.0)), size: image.size)) + } + + if let animationNode = self.animationNode { + let animationSize = CGSize(width: 36.0, height: 36.0) + animationNode.bounds = CGRect(origin: CGPoint(), size: animationSize) + animationNode.transform = CATransform3DMakeScale(0.66667, 0.66667, 1.0) + transition.updatePosition(node: animationNode, position: CGPoint(x: 16.0, y: size.height - 15.0)) + } + + let placeholderTextSize = self.placeholderTextNode.updateLayout(CGSize(width: size.width - 30.0, height: 100.0)) + transition.updateFrame(node: self.placeholderTextNode, frame: CGRect(origin: CGPoint(x: floor((size.width - placeholderTextSize.width) / 2.0), y: floorToScreenPixels(size.height / 2.0) + 10.0), size: placeholderTextSize)) + if let image = self.placeholderIconNode.image { + let imageScale: CGFloat = item.isVideoLimit ? 1.0 : 0.5 + let imageSize = CGSize(width: image.size.width * imageScale, height: image.size.height * imageScale) + transition.updateFrame(node: self.placeholderIconNode, frame: CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: floorToScreenPixels(size.height / 2.0) - imageSize.height - 4.0), size: imageSize)) + } + } + + func transitionIn(from sourceNode: ASDisplayNode?) { + guard let item = self.item else { + return + } + var videoNode: GroupVideoNode? + if let sourceNode = sourceNode as? VoiceChatFullscreenParticipantItemNode, let _ = sourceNode.item { + if let sourceVideoNode = sourceNode.videoNode { + sourceNode.videoNode = nil + videoNode = sourceVideoNode + } + } + + if videoNode == nil { + videoNode = item.getVideo(item.secondary ? .list : .tile) + } + + if let videoNode = videoNode { + videoNode.alpha = 1.0 + self.videoNode = videoNode + self.videoContainerNode.addSubnode(videoNode) + + videoNode.updateLayout(size: self.bounds.size, layoutMode: .fillOrFitToSquare, transition: .immediate) + videoNode.frame = self.bounds + + self.updateIsEnabled() + } + } +} + +private let blue = UIColor(rgb: 0x007fff) +private let lightBlue = UIColor(rgb: 0x00affe) +private let green = UIColor(rgb: 0x33c659) +private let activeBlue = UIColor(rgb: 0x00a0b9) +private let purple = UIColor(rgb: 0x3252ef) +private let pink = UIColor(rgb: 0xef436c) + +class VoiceChatTileHighlightView: UIView { + enum Gradient { + case speaking + case active + case mutedForYou + case muted + } + + private var customMaskView: UIView? + private let maskLayer = CALayer() + + private let foregroundGradientLayer = CAGradientLayer() + + var isCurrentlyInHierarchy = false { + didSet { + if self.isCurrentlyInHierarchy != oldValue && self.isCurrentlyInHierarchy { + self.updateAnimations() + } + } + } + + private var audioLevel: CGFloat = 0.0 + private var presentationAudioLevel: CGFloat = 0.0 + + private var displayLinkAnimator: ConstantDisplayLinkAnimator? + + init() { + self.foregroundGradientLayer.type = .radial + self.foregroundGradientLayer.colors = [lightBlue.cgColor, blue.cgColor, blue.cgColor] + self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] + self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + + super.init(frame: CGRect.zero) + + self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in + guard let strongSelf = self else { return } + + strongSelf.presentationAudioLevel = strongSelf.presentationAudioLevel * 0.9 + strongSelf.audioLevel * 0.1 + } + + self.layer.addSublayer(self.foregroundGradientLayer) + + let customMaskView = UIView() + customMaskView.layer.addSublayer(self.maskLayer) + self.customMaskView = customMaskView + + self.maskLayer.masksToBounds = true + self.maskLayer.cornerRadius = backgroundCornerRadius - UIScreenPixel + self.maskLayer.borderColor = UIColor.white.cgColor + self.maskLayer.borderWidth = borderLineWidth + + self.mask = self.customMaskView + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateAnimations() { + if !self.isCurrentlyInHierarchy { + self.foregroundGradientLayer.removeAllAnimations() + return + } + self.setupGradientAnimations() + } + + func updateLevel(_ level: CGFloat) { + self.audioLevel = level + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + let bounds = CGRect(origin: CGPoint(), size: size) + if let maskView = self.customMaskView { + transition.updateFrame(view: maskView, frame: bounds) + } + transition.updateFrame(layer: self.maskLayer, frame: bounds) + transition.updateFrame(layer: self.foregroundGradientLayer, frame: bounds) + } + + private func setupGradientAnimations() { + if let _ = self.foregroundGradientLayer.animation(forKey: "movement") { + } else { + let previousValue = self.foregroundGradientLayer.startPoint + let newValue: CGPoint + if self.presentationAudioLevel > 0.22 { + newValue = CGPoint(x: CGFloat.random(in: 0.9 ..< 1.0), y: CGFloat.random(in: 0.15 ..< 0.35)) + } else if self.presentationAudioLevel > 0.01 { + newValue = CGPoint(x: CGFloat.random(in: 0.57 ..< 0.85), y: CGFloat.random(in: 0.15 ..< 0.45)) + } else { + newValue = CGPoint(x: CGFloat.random(in: 0.6 ..< 0.75), y: CGFloat.random(in: 0.25 ..< 0.45)) + } + self.foregroundGradientLayer.startPoint = newValue + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "startPoint") + animation.duration = Double.random(in: 0.8 ..< 1.4) + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in + guard let strongSelf = self else { + return + } + if strongSelf.isCurrentlyInHierarchy { + strongSelf.setupGradientAnimations() + } + } + + self.foregroundGradientLayer.add(animation, forKey: "movement") + CATransaction.commit() + } + } + + private var gradient: Gradient? + func updateGlowAndGradientAnimations(type: Gradient, animated: Bool = true) { + guard self.gradient != type else { + return + } + self.gradient = type + let initialColors = self.foregroundGradientLayer.colors + let targetColors: [CGColor] + switch type { + case .speaking: + targetColors = [activeBlue.cgColor, green.cgColor, green.cgColor] + case .active: + targetColors = [lightBlue.cgColor, blue.cgColor, blue.cgColor] + case .mutedForYou: + targetColors = [pink.cgColor, destructiveColor.cgColor, destructiveColor.cgColor] + case .muted: + targetColors = [pink.cgColor, purple.cgColor, purple.cgColor] + } + self.foregroundGradientLayer.colors = targetColors + if animated { + self.foregroundGradientLayer.animate(from: initialColors as AnyObject, to: targetColors as AnyObject, keyPath: "colors", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3) + } + self.updateAnimations() + } +} + +final class ShimmerEffectForegroundView: UIView { + private var currentForegroundColor: UIColor? + private let imageNodeContainer: ASDisplayNode + private let imageNode: ASDisplayNode + + private var absoluteLocation: (CGRect, CGSize)? + private var isCurrentlyInHierarchy = false + private var shouldBeAnimating = false + + private let size: CGFloat + + init(size: CGFloat) { + self.size = size + + self.imageNodeContainer = ASDisplayNode() + self.imageNodeContainer.isLayerBacked = true + + self.imageNode = ASDisplayNode() + self.imageNode.isLayerBacked = true + self.imageNode.displaysAsynchronously = false + + super.init(frame: CGRect.zero) + + self.clipsToBounds = true + + self.imageNodeContainer.addSubnode(self.imageNode) + self.addSubnode(self.imageNodeContainer) + + self.isCurrentlyInHierarchy = true + self.updateAnimation() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(foregroundColor: UIColor) { + if let currentForegroundColor = self.currentForegroundColor, currentForegroundColor.isEqual(foregroundColor) { + return + } + self.currentForegroundColor = foregroundColor + + let image = generateImage(CGSize(width: self.size, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + context.clip(to: CGRect(origin: CGPoint(), size: size)) + + let transparentColor = foregroundColor.withAlphaComponent(0.0).cgColor + let peakColor = foregroundColor.cgColor + + var locations: [CGFloat] = [0.0, 0.5, 1.0] + let colors: [CGColor] = [transparentColor, peakColor, transparentColor] + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions()) + }) + if let image = image { + self.imageNode.backgroundColor = UIColor(patternImage: image) + } + } + + func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { + if let absoluteLocation = self.absoluteLocation, absoluteLocation.0 == rect && absoluteLocation.1 == containerSize { + return + } + let sizeUpdated = self.absoluteLocation?.1 != containerSize + let frameUpdated = self.absoluteLocation?.0 != rect + self.absoluteLocation = (rect, containerSize) + + if sizeUpdated { + if self.shouldBeAnimating { + self.imageNode.layer.removeAnimation(forKey: "shimmer") + self.addImageAnimation() + } else { + self.updateAnimation() + } + } + + if frameUpdated { + self.imageNodeContainer.frame = CGRect(origin: CGPoint(x: -rect.minX, y: -rect.minY), size: containerSize) + } + } + + private func updateAnimation() { + let shouldBeAnimating = self.isCurrentlyInHierarchy && self.absoluteLocation != nil + if shouldBeAnimating != self.shouldBeAnimating { + self.shouldBeAnimating = shouldBeAnimating + if shouldBeAnimating { + self.addImageAnimation() + } else { + self.imageNode.layer.removeAnimation(forKey: "shimmer") + } + } + } + + private func addImageAnimation() { + guard let containerSize = self.absoluteLocation?.1 else { + return + } + let gradientHeight: CGFloat = self.size + self.imageNode.frame = CGRect(origin: CGPoint(x: -gradientHeight, y: 0.0), size: CGSize(width: gradientHeight, height: containerSize.height)) + let animation = self.imageNode.layer.makeAnimation(from: 0.0 as NSNumber, to: (containerSize.width + gradientHeight) as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 1.3 * 1.0, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true) + animation.repeatCount = Float.infinity + animation.beginTime = 1.0 + self.imageNode.layer.add(animation, forKey: "shimmer") + } +} + +private class VoiceChatTileShimmeringView: UIView { + private let backgroundNode: ImageNode + private let effectNode: ShimmerEffectForegroundView + + private let borderNode: ASDisplayNode + private var borderMaskView: UIView? + private let borderEffectNode: ShimmerEffectForegroundNode + + private var currentShimmeringColor: UIColor? + private var currentShimmering: Bool? + private var currentSize: CGSize? + + public init(account: Account, peer: Peer) { + self.backgroundNode = ImageNode(enableHasImage: false, enableEmpty: false, enableAnimatedTransition: true) + self.backgroundNode.displaysAsynchronously = false + self.backgroundNode.contentMode = .scaleAspectFill + + self.effectNode = ShimmerEffectForegroundView(size: 240.0) + + self.borderNode = ASDisplayNode() + self.borderEffectNode = ShimmerEffectForegroundNode(size: 320.0) + + super.init() + + self.clipsToBounds = true + self.layer.cornerRadius = backgroundCornerRadius + + self.addSubnode(self.backgroundNode) + self.addSubview(self.effectNode) + self.addSubnode(self.borderNode) + self.borderNode.addSubnode(self.borderEffectNode) + + self.backgroundNode.setSignal(peerAvatarCompleteImage(account: account, peer: EnginePeer(peer), size: CGSize(width: 250.0, height: 250.0), round: false, font: Font.regular(16.0), drawLetters: false, fullSize: false, blurred: true)) + + self.effectNode.layer.compositingFilter = "screenBlendMode" + self.borderEffectNode.layer.compositingFilter = "screenBlendMode" + + let borderMaskView = UIView() + borderMaskView.layer.borderWidth = 1.0 + borderMaskView.layer.borderColor = UIColor.white.cgColor + borderMaskView.layer.cornerRadius = backgroundCornerRadius + self.borderMaskView = borderMaskView + + if let size = self.currentSize { + borderMaskView.frame = CGRect(origin: CGPoint(), size: size) + } + self.borderNode.view.mask = borderMaskView + + if #available(iOS 13.0, *) { + borderMaskView.layer.cornerCurve = .continuous + self.layer.cornerCurve = .continuous + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { + self.effectNode.updateAbsoluteRect(rect, within: containerSize) + self.borderEffectNode.updateAbsoluteRect(rect, within: containerSize) + } + + public func update(shimmeringColor: UIColor, shimmering: Bool, size: CGSize, transition: ContainedViewLayoutTransition) { + if let currentShimmeringColor = self.currentShimmeringColor, currentShimmeringColor.isEqual(shimmeringColor) && self.currentSize == size && self.currentShimmering == shimmering { + return + } + + let firstTime = self.currentShimmering == nil + self.currentShimmeringColor = shimmeringColor + self.currentShimmering = shimmering + self.currentSize = size + + let transition: ContainedViewLayoutTransition = firstTime ? .immediate : (transition.isAnimated ? transition : .animated(duration: 0.45, curve: .easeInOut)) + transition.updateAlpha(view: self.effectNode, alpha: shimmering ? 1.0 : 0.0) + transition.updateAlpha(node: self.borderNode, alpha: shimmering ? 1.0 : 0.0) + + let bounds = CGRect(origin: CGPoint(), size: size) + + self.effectNode.update(foregroundColor: shimmeringColor.withAlphaComponent(0.3)) + transition.updateFrame(view: self.effectNode, frame: bounds) + + self.borderEffectNode.update(foregroundColor: shimmeringColor.withAlphaComponent(0.45)) + transition.updateFrame(node: self.borderEffectNode, frame: bounds) + + transition.updateFrame(node: self.backgroundNode, frame: bounds) + transition.updateFrame(node: self.borderNode, frame: bounds) + if let borderMaskView = self.borderMaskView { + transition.updateFrame(view: borderMaskView, frame: bounds) + } + } +} diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index e51a3e7fe82..e4de90c1055 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -111,6 +111,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { private var groupCallDisposable: Disposable? private var callController: CallController? + private var callViewController: CallViewController? public let hasOngoingCall = ValuePromise(false) private let callState = Promise(nil) @@ -661,26 +662,46 @@ public final class SharedAccountContextImpl: SharedAccountContext { self.callDisposable = (callManager.currentCallSignal |> deliverOnMainQueue).start(next: { [weak self] call in - if let strongSelf = self { - if call !== strongSelf.callController?.call { - strongSelf.callController?.dismiss() - strongSelf.callController = nil - strongSelf.hasOngoingCall.set(false) - - if let call = call { - mainWindow.hostView.containerView.endEditing(true) - let callController = CallController(sharedContext: strongSelf, account: call.context.account, call: call, easyDebugAccess: !GlobalExperimentalSettings.isAppStoreBuild) + guard let strongSelf = self else { + return + } + if call !== (strongSelf.callViewController?.call ?? strongSelf.callController?.call) { + strongSelf.callController?.dismiss() + strongSelf.callController = nil + + strongSelf.callViewController?.dismiss() + strongSelf.callViewController = nil + + strongSelf.hasOngoingCall.set(false) + + if let call = call { + mainWindow.hostView.containerView.endEditing(true) + + if call.isVideoPossible && call.isOutgoing && !call.isVideo { + let callViewController = CallViewController(sharedContext: strongSelf, + accountContext: call.context, + account: call.context.account, + call: call, + easyDebugAccess: !GlobalExperimentalSettings.isAppStoreBuild) + strongSelf.callViewController = callViewController + strongSelf.mainWindow?.present(callViewController, on: .calls) + } else { + let callController = CallController(sharedContext: strongSelf, + account: call.context.account, + call: call, + easyDebugAccess: !GlobalExperimentalSettings.isAppStoreBuild) strongSelf.callController = callController strongSelf.mainWindow?.present(callController, on: .calls) - strongSelf.callState.set(call.state - |> map(Optional.init)) - strongSelf.hasOngoingCall.set(true) - setNotificationCall(call) - } else { - strongSelf.callState.set(.single(nil)) - strongSelf.hasOngoingCall.set(false) - setNotificationCall(nil) } + + strongSelf.callState.set(call.state + |> map(Optional.init)) + strongSelf.hasOngoingCall.set(true) + setNotificationCall(call) + } else { + strongSelf.callState.set(.single(nil)) + strongSelf.hasOngoingCall.set(false) + setNotificationCall(nil) } } }) @@ -803,6 +824,13 @@ public final class SharedAccountContextImpl: SharedAccountContext { (mainWindow.viewController as? NavigationController)?.pushViewController(groupCallController) } } + } else if let callViewController = strongSelf.callViewController { + mainWindow.hostView.containerView.endEditing(true) + if callViewController.view.superview == nil { + mainWindow.present(callViewController, on: .calls) + } else { + callViewController.expandFromPipIfPossible() + } } } } else { @@ -1120,6 +1148,11 @@ public final class SharedAccountContextImpl: SharedAccountContext { mainWindow.hostView.containerView.endEditing(true) (mainWindow.viewController as? NavigationController)?.pushViewController(groupCallController) } + } else if let callViewController = self.callViewController { + if callViewController.view.superview == nil { + mainWindow.hostView.containerView.endEditing(true) + mainWindow.present(callViewController, on: .calls) + } } } From afe9355bac582c6f16b2652d9123c3bebdfdae0c Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Thu, 2 Mar 2023 21:06:29 +0300 Subject: [PATCH 02/11] TELEGRAM-[added marks to code] --- .../CallControllerView.swift | 3953 +++++++++-------- .../CallViewController.swift | 120 +- 2 files changed, 2014 insertions(+), 2059 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift index c18a6c03c0f..1214a968855 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -20,123 +20,460 @@ import DeviceAccess import ContextUI import GradientBackground -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))) -} +final class CallControllerView: ViewControllerTracingNodeView { -private func interpolate(from: CGFloat, to: CGFloat, value: CGFloat) -> CGFloat { - return (1.0 - value) * from + value * to -} + private enum VideoNodeCorner { + case topLeft + case topRight + case bottomLeft + case bottomRight + } -private final class CallVideoView: UIView, PreviewVideoView { + private enum UIState { + case ringing + case active + case weakSignal + case video + } - private let videoTransformContainer: UIView - private let videoView: PresentationCallVideoView + private enum PictureInPictureGestureState { + case none + case collapsing(didSelectCorner: Bool) + case dragging(initialPosition: CGPoint, draggingPosition: CGPoint) + } + + 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)? + + 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 effectView: UIVisualEffectView? - private let videoPausedNode: ImmediateTextNode + private let sharedContext: SharedAccountContext + private let accountContext: AccountContext + private let account: Account - private var isBlurred: Bool = false - private var currentCornerRadius: CGFloat = 0.0 + private let statusBar: StatusBar - private let isReadyUpdated: () -> Void - private(set) var isReady: Bool = false - private var isReadyTimer: SwiftSignalKit.Timer? + 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 readyPromise = ValuePromise(false) - var ready: Signal { - return self.readyPromise.get() - } + private let containerTransformationView: UIView + private let contentContainerView: UIView + private let videoContainerNode: PinchSourceContainerView + + private var gradientBackgroundNode: GradientBackgroundNode + private let dimNode: ASImageNode // TODO: implement - remove? - private let isFlippedUpdated: (CallVideoView) -> Void + private var candidateIncomingVideoNodeValue: CallVideoView? + private var incomingVideoNodeValue: CallVideoView? + private var incomingVideoViewRequested: Bool = false + private var candidateOutgoingVideoNodeValue: CallVideoView? + private var outgoingVideoNodeValue: CallVideoView? + private var outgoingVideoViewRequested: Bool = false - private(set) var currentOrientation: PresentationCallVideoView.Orientation - private(set) var currentAspect: CGFloat = 0.0 + private var removedMinimizedVideoNodeValue: CallVideoView? + private var removedExpandedVideoNodeValue: CallVideoView? - private var previousVideoHeight: CGFloat? + private var isRequestingVideo: Bool = false + private var animateRequestedVideoOnce: Bool = false - init(videoView: PresentationCallVideoView, disabledText: String?, assumeReadyAfterTimeout: Bool, isReadyUpdated: @escaping () -> Void, orientationUpdated: @escaping () -> Void, isFlippedUpdated: @escaping (CallVideoView) -> Void) { - self.isReadyUpdated = isReadyUpdated - self.isFlippedUpdated = isFlippedUpdated + private var hiddenUIForActiveVideoCallOnce: Bool = false + private var hideUIForActiveVideoCallTimer: SwiftSignalKit.Timer? + + private var displayedCameraConfirmation: Bool = false + private var displayedCameraTooltip: Bool = false - self.videoTransformContainer = UIView() - self.videoView = videoView - videoView.view.clipsToBounds = true - videoView.view.backgroundColor = .black + private var expandedVideoNode: CallVideoView? + private var minimizedVideoNode: CallVideoView? + 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 avatarNode: AvatarNode + private let audioLevelView: VoiceBlobView + private let statusNode: CallControllerStatusView + private let toastNode: CallControllerToastContainerNode + private let buttonsNode: CallControllerButtonsNode + private var keyPreviewNode: CallControllerKeyPreviewView? + + 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 uiState: UIState? + private var buttonsTerminationMode: CallControllerButtonsMode? + private var debugTapCounter: (Double, Int) = (0.0, 0) + private var minimizedVideoInitialPosition: CGPoint? + private var minimizedVideoDraggingPosition: CGPoint? + private var displayedVersionOutdatedAlert: Bool = false + private var shouldStayHiddenUntilConnection: Bool = false + private var audioOutputState: ([AudioSessionOutput], currentOutput: AudioSessionOutput?)? + private var callState: PresentationCallState? + 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 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? + + private var hasVideoNodes: Bool { + return self.expandedVideoNode != nil || self.minimizedVideoNode != nil + } + + // MARK: - Initialization + + init(sharedContext: SharedAccountContext, + accountContext: AccountContext, + account: Account, + presentationData: PresentationData, + statusBar: StatusBar, + debugInfo: Signal<(String, String), NoError>, + shouldStayHiddenUntilConnection: Bool = false, + easyDebugAccess: Bool, + call: PresentationCall) { + self.sharedContext = sharedContext + self.accountContext = accountContext + self.account = account + self.presentationData = presentationData + self.statusBar = statusBar + self.debugInfo = debugInfo + self.shouldStayHiddenUntilConnection = shouldStayHiddenUntilConnection + self.easyDebugAccess = easyDebugAccess + self.call = call - self.currentOrientation = videoView.getOrientation() - self.currentAspect = videoView.getAspect() + self.containerTransformationView = UIView() + self.containerTransformationView.clipsToBounds = true - self.videoPausedNode = ImmediateTextNode() - self.videoPausedNode.alpha = 0.0 - self.videoPausedNode.maximumNumberOfLines = 3 + self.contentContainerView = UIView() - super.init(frame: CGRect.zero) + self.videoContainerNode = PinchSourceContainerView() + + self.gradientBackgroundNode = createGradientBackgroundNode() + + self.dimNode = ASImageNode() + self.dimNode.contentMode = .scaleToFill + self.dimNode.isUserInteractionEnabled = false + self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.3) - self.backgroundColor = .black - self.clipsToBounds = true + self.backButtonArrowNode = ASImageNode() + self.backButtonArrowNode.displayWithoutProcessing = true + self.backButtonArrowNode.displaysAsynchronously = false + self.backButtonArrowNode.image = NavigationBarTheme.generateBackArrowImage(color: .white) + self.backButtonNode = HighlightableButtonNode() + + let avatarWidth: CGFloat = 136.0 + let avatarFrame = CGRect(x: 0, y: 0, width: avatarWidth, height: avatarWidth) + let avatarFont = avatarPlaceholderFont(size: floor(avatarWidth * 16.0 / 37.0)) + self.avatarNode = AvatarNode(font: avatarFont) + self.avatarNode.frame = avatarFrame + self.avatarNode.cornerRadius = avatarWidth / 2.0 + self.avatarNode.clipsToBounds = true + self.audioLevelView = VoiceBlobView(frame: avatarFrame, + maxLevel: 4, + smallBlobRange: (1.05, 0.15), + mediumBlobRange: (1.12, 1.47), + bigBlobRange: (1.17, 1.6) + ) + self.audioLevelView.setColor(UIColor(rgb: 0xFFFFFF)) + + self.statusNode = CallControllerStatusView() - if #available(iOS 13.0, *) { - self.layer.cornerCurve = .continuous - } + self.buttonsNode = CallControllerButtonsNode(strings: self.presentationData.strings) + self.toastNode = CallControllerToastContainerNode(strings: self.presentationData.strings) + self.keyButtonNode = CallControllerKeyButton() + self.keyButtonNode.accessibilityElementsHidden = false - self.videoTransformContainer.addSubview(self.videoView.view) - self.addSubview(self.videoTransformContainer) + super.init(frame: CGRect.zero) - if let disabledText = disabledText { - self.videoPausedNode.attributedText = NSAttributedString(string: disabledText, font: Font.regular(17.0), textColor: .white) - self.addSubnode(self.videoPausedNode) - } + self.contentContainerView.backgroundColor = .black - self.videoView.setOnFirstFrameReceived { [weak self] aspectRatio in - Queue.mainQueue().async { - guard let strongSelf = self else { - return - } - if !strongSelf.isReady { - strongSelf.isReady = true - strongSelf.readyPromise.set(true) - strongSelf.isReadyTimer?.invalidate() - strongSelf.isReadyUpdated() + self.addSubview(self.containerTransformationView) + self.containerTransformationView.addSubview(self.contentContainerView) + + 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.videoView.setOnOrientationUpdated { [weak self] orientation, aspect in - Queue.mainQueue().async { - guard let strongSelf = self else { - return + self.contentContainerView.addSubnode(self.gradientBackgroundNode) + self.contentContainerView.addSubview(self.videoContainerNode) + self.contentContainerView.addSubnode(self.dimNode) + self.contentContainerView.addSubview(self.audioLevelView) + self.contentContainerView.addSubnode(self.avatarNode) + self.contentContainerView.addSubview(self.statusNode) + self.contentContainerView.addSubnode(self.buttonsNode) + self.contentContainerView.addSubnode(self.toastNode) + self.contentContainerView.addSubnode(self.keyButtonNode) + self.contentContainerView.addSubnode(self.backButtonArrowNode) + self.contentContainerView.addSubnode(self.backButtonNode) + + 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.addGestureRecognizer(panRecognizer) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.addGestureRecognizer(tapRecognizer) + + 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 strongSelf.currentOrientation != orientation || strongSelf.currentAspect != aspect { - strongSelf.currentOrientation = orientation - strongSelf.currentAspect = aspect - orientationUpdated() + + 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 = CallVideoView(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 = VoiceChatCameraPreviewViewController(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.videoView.setOnIsMirroredUpdated { [weak self] _ in - Queue.mainQueue().async { - guard let strongSelf = self else { - return + 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.isFlippedUpdated(strongSelf) } + strongSelf.cancelScheduledUIHiding() } - 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.readyPromise.set(true) - strongSelf.isReadyUpdated() + self.keyButtonNode.addTarget(self, action: #selector(self.keyPressed), forControlEvents: .touchUpInside) + + self.backButtonNode.addTarget(self, action: #selector(self.backPressed), forControlEvents: .touchUpInside) + + if shouldStayHiddenUntilConnection { + self.contentContainerView.alpha = 0.0 + Queue.mainQueue().after(3.0, { [weak self] in + self?.contentContainerView.alpha = 1.0 + self?.animateIn() + }) + } else if call.isVideo && call.isOutgoing { + self.contentContainerView.alpha = 0.0 + Queue.mainQueue().after(1.0, { [weak self] in + self?.contentContainerView.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)) } - }, queue: .mainQueue()) + } + }) + + self.videoContainerNode.activate = { [weak self] sourceNode in + guard let strongSelf = self else { + return + } + let pinchController = PinchViewController(sourceNode: sourceNode, getContentAreaInScreenSpace: { + return UIScreen.main.bounds + }) + strongSelf.sharedContext.mainWindow?.presentInGlobalOverlay(pinchController) + strongSelf.isVideoPinched = true + + strongSelf.videoContainerNode.contentView.clipsToBounds = true + strongSelf.videoContainerNode.backgroundColor = .black + + if let (layout, navigationBarHeight) = strongSelf.validLayout { + strongSelf.videoContainerNode.contentView.layer.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.contentView.layer.cornerRadius = 0.0 + + if let (layout, navigationBarHeight) = strongSelf.validLayout { + strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) + } } - self.isReadyTimer?.start() } required init?(coder: NSCoder) { @@ -144,752 +481,86 @@ private final class CallVideoView: UIView, PreviewVideoView { } deinit { - self.isReadyTimer?.invalidate() + if let orientationDidChangeObserver = self.orientationDidChangeObserver { + NotificationCenter.default.removeObserver(orientationDidChangeObserver) + } } - - 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) + + // MARK: - Overrides + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.debugNode != nil { + return super.hitTest(point, with: event) } - - 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 - }) + if self.containerTransformationView.frame.contains(point) { + return self.containerTransformationView.hitTest(self.convert(point, to: self.containerTransformationView), with: event) + } + return nil } + + // MARK: - Public - func updateLayout(size: CGSize, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition) { - self.updateLayout(size: size, cornerRadius: self.currentCornerRadius, isOutgoing: true, deviceOrientation: .portrait, isCompactLayout: false, transition: transition) + func displayCameraTooltip() { + guard self.pictureInPictureTransitionFraction.isZero, let location = self.buttonsNode.videoButtonFrame().flatMap({ frame -> CGRect in + return self.buttonsNode.view.convert(frame, to: self) + }) else { + return + } + + self.present?(TooltipScreen(account: self.account, 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) + })) } - func updateLayout(size: CGSize, cornerRadius: CGFloat, isOutgoing: Bool, deviceOrientation: UIDeviceOrientation, isCompactLayout: Bool, transition: ContainedViewLayoutTransition) { - self.currentCornerRadius = cornerRadius - - var rotationAngle: CGFloat - if false && 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: - rotationAngle = CGFloat.pi - case .rotation270: - 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 * 3.0 / 2.0) < 0.01 { - rotationAngle = -CGFloat.pi / 2.0 - } - if abs(rotationAngle - (-CGFloat.pi)) < 0.01 { - rotationAngle = -CGFloat.pi + 0.001 + func updatePeer(accountPeer: Peer, peer: Peer, hasOther: Bool) { + if !arePeersEqual(self.peer, peer) { + self.peer = peer + if PeerReference(peer) != nil && !peer.profileImageRepresentations.isEmpty { + self.dimNode.isHidden = false + } else { + self.dimNode.isHidden = true } - } - - 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 + + self.avatarNode.setPeer(context: self.accountContext, + account: self.account, + theme: presentationData.theme, + peer: EnginePeer(peer), + overrideImage: nil, + clipStyle: .none, + synchronousLoad: false, + displayDimensions: self.avatarNode.bounds.size) + + setUIState(.ringing) - 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) + 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) } } - } - - 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(view: self.videoTransformContainer, from: scaleDifference) - } - } - self.previousVideoHeight = rotatedVideoHeight - transition.updatePosition(view: self.videoTransformContainer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) - transition.updateTransformRotation(view: self.videoTransformContainer, 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.hasScheduledUnblur { - self.hasScheduledUnblur = false - } - 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.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) + + if let (layout, navigationBarHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) } - } 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() - }) } } - private var hasScheduledUnblur = false - func flip(withBackground: Bool) { - if withBackground { - self.backgroundColor = .black - } - UIView.transition(with: withBackground ? self.videoTransformContainer : self, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: { - UIView.performWithoutAnimation { - self.updateIsBlurred(isBlurred: true, light: false, animated: false) - } - }) { finished in - self.backgroundColor = nil - self.hasScheduledUnblur = true - Queue.mainQueue().after(0.5) { - if self.hasScheduledUnblur { - self.updateIsBlurred(isBlurred: false) - } - } + func updateAudioOutputs(availableOutputs: [AudioSessionOutput], currentOutput: AudioSessionOutput?) { + if self.audioOutputState?.0 != availableOutputs || self.audioOutputState?.1 != currentOutput { + self.audioOutputState = (availableOutputs, currentOutput) + self.updateButtonsMode() + + self.setupAudioOutputs() } } -} - -final class CallControllerView: ViewControllerTracingNodeView { - - private enum VideoNodeCorner { - case topLeft - case topRight - case bottomLeft - case bottomRight - } - private enum UIState { - case ringing - case active - case weakSignal - case video - } - - private let sharedContext: SharedAccountContext - private let accountContext: AccountContext - 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 audioLevelDisposable = MetaDisposable() - - private let containerTransformationView: UIView - private let contentContainerView: UIView - private let videoContainerNode: PinchSourceContainerView - - private var gradientBackgroundNode: GradientBackgroundNode - private let dimNode: ASImageNode // TODO: implement - remove? - - private var candidateIncomingVideoNodeValue: CallVideoView? - private var incomingVideoNodeValue: CallVideoView? - private var incomingVideoViewRequested: Bool = false - private var candidateOutgoingVideoNodeValue: CallVideoView? - private var outgoingVideoNodeValue: CallVideoView? - private var outgoingVideoViewRequested: Bool = false - - private var removedMinimizedVideoNodeValue: CallVideoView? - private var removedExpandedVideoNodeValue: CallVideoView? - - 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: CallVideoView? - private var minimizedVideoNode: CallVideoView? - 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 avatarNode: AvatarNode - private let audioLevelView: VoiceBlobView - private let statusNode: CallControllerStatusView - private let toastNode: CallControllerToastContainerNode - private let buttonsNode: CallControllerButtonsNode - private var keyPreviewNode: CallControllerKeyPreviewView? - - 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 - - private var uiState: UIState? - - 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, - accountContext: AccountContext, - account: Account, - presentationData: PresentationData, - statusBar: StatusBar, - debugInfo: Signal<(String, String), NoError>, - shouldStayHiddenUntilConnection: Bool = false, - easyDebugAccess: Bool, - call: PresentationCall) { - self.sharedContext = sharedContext - self.accountContext = accountContext - self.account = account - self.presentationData = presentationData - self.statusBar = statusBar - self.debugInfo = debugInfo - self.shouldStayHiddenUntilConnection = shouldStayHiddenUntilConnection - self.easyDebugAccess = easyDebugAccess - self.call = call - - self.containerTransformationView = UIView() - self.containerTransformationView.clipsToBounds = true - - self.contentContainerView = UIView() - - self.videoContainerNode = PinchSourceContainerView() - - self.gradientBackgroundNode = createGradientBackgroundNode() - - 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() - - let avatarWidth: CGFloat = 136.0 - let avatarFrame = CGRect(x: 0, y: 0, width: avatarWidth, height: avatarWidth) - let avatarFont = avatarPlaceholderFont(size: floor(avatarWidth * 16.0 / 37.0)) - self.avatarNode = AvatarNode(font: avatarFont) - self.avatarNode.frame = avatarFrame - self.avatarNode.cornerRadius = avatarWidth / 2.0 - self.avatarNode.clipsToBounds = true - self.audioLevelView = VoiceBlobView(frame: avatarFrame, - maxLevel: 4, - smallBlobRange: (1.05, 0.15), - mediumBlobRange: (1.12, 1.47), - bigBlobRange: (1.17, 1.6) - ) - self.audioLevelView.setColor(UIColor(rgb: 0xFFFFFF)) - - self.statusNode = CallControllerStatusView() - - self.buttonsNode = CallControllerButtonsNode(strings: self.presentationData.strings) - self.toastNode = CallControllerToastContainerNode(strings: self.presentationData.strings) - self.keyButtonNode = CallControllerKeyButton() - self.keyButtonNode.accessibilityElementsHidden = false - - super.init(frame: CGRect.zero) - - self.contentContainerView.backgroundColor = .black - - self.addSubview(self.containerTransformationView) - self.containerTransformationView.addSubview(self.contentContainerView) - - 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.contentContainerView.addSubnode(self.gradientBackgroundNode) - self.contentContainerView.addSubview(self.videoContainerNode) - self.contentContainerView.addSubnode(self.dimNode) - self.contentContainerView.addSubview(self.audioLevelView) - self.contentContainerView.addSubnode(self.avatarNode) - self.contentContainerView.addSubview(self.statusNode) - self.contentContainerView.addSubnode(self.buttonsNode) - self.contentContainerView.addSubnode(self.toastNode) - self.contentContainerView.addSubnode(self.keyButtonNode) - self.contentContainerView.addSubnode(self.backButtonArrowNode) - self.contentContainerView.addSubnode(self.backButtonNode) - - 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.addGestureRecognizer(panRecognizer) - - let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) - self.addGestureRecognizer(tapRecognizer) - - 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 = CallVideoView(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 = VoiceChatCameraPreviewViewController(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.contentContainerView.alpha = 0.0 - Queue.mainQueue().after(3.0, { [weak self] in - self?.contentContainerView.alpha = 1.0 - self?.animateIn() - }) - } else if call.isVideo && call.isOutgoing { - self.contentContainerView.alpha = 0.0 - Queue.mainQueue().after(1.0, { [weak self] in - self?.contentContainerView.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 = PinchViewController(sourceNode: sourceNode, getContentAreaInScreenSpace: { - return UIScreen.main.bounds - }) - strongSelf.sharedContext.mainWindow?.presentInGlobalOverlay(pinchController) - strongSelf.isVideoPinched = true - - strongSelf.videoContainerNode.contentView.clipsToBounds = true - strongSelf.videoContainerNode.backgroundColor = .black - - if let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.videoContainerNode.contentView.layer.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.contentView.layer.cornerRadius = 0.0 - - if let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - } - - self.audioLevelDisposable.set((call.audioLevel - |> deliverOnMainQueue).start(next: { [weak self] value in - guard let strongSelf = self, !strongSelf.audioLevelView.isHidden else { - return - } - strongSelf.audioLevelView.updateLevel(CGFloat(value) * 2.0) - })) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - 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) - }) else { - return - } - - self.present?(TooltipScreen(account: self.account, 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) - })) - } - - func updatePeer(accountPeer: Peer, peer: Peer, hasOther: Bool) { - if !arePeersEqual(self.peer, peer) { - self.peer = peer - if PeerReference(peer) != nil && !peer.profileImageRepresentations.isEmpty { - self.dimNode.isHidden = false - } else { - self.dimNode.isHidden = true - } - - self.avatarNode.setPeer(context: self.accountContext, - account: self.account, - theme: presentationData.theme, - peer: EnginePeer(peer), - overrideImage: nil, - clipStyle: .none, - synchronousLoad: false, - displayDimensions: self.avatarNode.bounds.size) - - setUIState(.ringing) - - 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 updateAudioLevel(_ audionLevel: CGFloat) { + if !audioLevelView.isHidden { + audioLevelView.updateLevel(audionLevel) + } } func updateCallState(_ callState: PresentationCallState) { @@ -929,650 +600,377 @@ final class CallControllerView: ViewControllerTracingNodeView { strongSelf.updateDimVisibility() strongSelf.maybeScheduleUIHidingForActiveVideoCall() - - if strongSelf.hasVideoNodes { - strongSelf.setUIState(.video) - } - } - - let incomingVideoNode = CallVideoView(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 - } - if hasVideoNodes { - setUIState(.video) - } else { - setUIState(.active) - } - } - 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.contentView.insertSubview(outgoingVideoNode, aboveSubview: expandedVideoNode) - } else { - strongSelf.expandedVideoNode = outgoingVideoNode - strongSelf.videoContainerNode.contentView.addSubview(outgoingVideoNode) - } - strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) - - strongSelf.updateDimVisibility() - strongSelf.maybeScheduleUIHidingForActiveVideoCall() - - if strongSelf.hasVideoNodes { - strongSelf.setUIState(.video) - } - } - - let outgoingVideoNode = CallVideoView(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 - } - if hasVideoNodes { - setUIState(.video) - } else { - setUIState(.active) - } - } - 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)! - 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 let statusReceptionActual = statusReception { - setUIState(statusReceptionActual > 1 ? .active : .weakSignal) - } else { - setUIState(.active) - } - } else { - setUIState(.active) - } - } - if self.shouldStayHiddenUntilConnection { - switch callState.state { - case .connecting, .active: - self.contentContainerView.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 strongSelf.hasVideoNodes { + strongSelf.setUIState(.video) + } } - if case .low = callState.remoteBatteryLevel { - toastContent.insert(.battery) + + let incomingVideoNode = CallVideoView(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() } } - } 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 - } + case .inactive: + self.candidateIncomingVideoNodeValue = nil + if let incomingVideoNodeValue = self.incomingVideoNodeValue { + if self.minimizedVideoNode == incomingVideoNodeValue { + self.minimizedVideoNode = nil + self.removedMinimizedVideoNodeValue = incomingVideoNodeValue } - if updated, let (layout, navigationBarHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) + if self.expandedVideoNode == incomingVideoNodeValue { + self.expandedVideoNode = nil + self.removedExpandedVideoNodeValue = incomingVideoNodeValue + + if let minimizedVideoNode = self.minimizedVideoNode { + self.expandedVideoNode = minimizedVideoNode + self.minimizedVideoNode = nil + } + if hasVideoNodes { + setUIState(.video) + } else { + setUIState(.active) + } } - strongSelf.hideUIForActiveVideoCallTimer = nil + self.incomingVideoNodeValue = nil + self.incomingVideoViewRequested = false } - }, 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 + 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 } - mode = .bluetooth(type) - } - if availableOutputs.count <= 1 { - mode = .none + + 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.contentView.insertSubview(outgoingVideoNode, aboveSubview: expandedVideoNode) + } else { + strongSelf.expandedVideoNode = outgoingVideoNode + strongSelf.videoContainerNode.contentView.addSubview(outgoingVideoNode) + } + strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) + + strongSelf.updateDimVisibility() + strongSelf.maybeScheduleUIHidingForActiveVideoCall() + + if strongSelf.hasVideoNodes { + strongSelf.setUIState(.video) + } + } + + let outgoingVideoNode = CallVideoView(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() + } + } + }) } - } - 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 + 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 + } + if hasVideoNodes { + setUIState(.video) + } else { + setUIState(.active) + } + } + self.outgoingVideoNodeValue = nil + self.outgoingVideoViewRequested = false } } - 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 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) } } - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: transition) - } - } - - func animateIn() { - if !self.contentContainerView.alpha.isZero { - var bounds = self.bounds - bounds.origin = CGPoint() - self.bounds = bounds - self.layer.removeAnimation(forKey: "bounds") - self.statusBar.layer.removeAnimation(forKey: "opacity") - self.contentContainerView.layer.removeAnimation(forKey: "opacity") - self.contentContainerView.layer.removeAnimation(forKey: "scale") - self.statusBar.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - if !self.shouldStayHiddenUntilConnection { - self.contentContainerView.layer.animateScale(from: 1.04, to: 1.0, duration: 0.3) - self.contentContainerView.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.contentContainerView.alpha > 0.0 { - self.contentContainerView.layer.allowsGroupOpacity = true - self.contentContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in - self?.contentContainerView.layer.allowsGroupOpacity = false - }) - self.contentContainerView.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 + 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 { - aspect = 4.0 / 3.0 + statusValue = .text(string: self.presentationData.strings.Call_StatusRequesting, displayLogo: false) } - - switch minimizedVideoNode.currentOrientation { - case .rotation90, .rotation270: - rotationCount += 1 - default: - break + 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) } - - var mappedDeviceOrientation = self.deviceOrientation - if case .regular = layout.metrics.widthClass, case .regular = layout.metrics.heightClass { - mappedDeviceOrientation = .portrait + case .ringing: + var text: String + if self.call.isVideo { + text = self.presentationData.strings.Call_IncomingVideoCall + } else { + text = self.presentationData.strings.Call_IncomingVoiceCall } - - switch mappedDeviceOrientation { - case .landscapeLeft, .landscapeRight: - rotationCount += 1 - default: - break + 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) + } } - if rotationCount % 2 != 0 { - aspect = 1.0 / aspect + 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 let statusReceptionActual = statusReception { + setUIState(statusReceptionActual > 1 ? .active : .weakSignal) + } else { + setUIState(.active) + } + } else { + setUIState(.active) } + } + if self.shouldStayHiddenUntilConnection { + switch callState.state { + case .connecting, .active: + self.contentContainerView.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() } - - 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 + 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) } - return CGRect(origin: CGPoint(x: previewVideoX, y: previewVideoY), size: previewVideoSize) + let hasIncomingVideoNode = self.incomingVideoNodeValue != nil && self.expandedVideoNode === self.incomingVideoNodeValue + self.videoContainerNode.isPinchGestureEnabled = hasIncomingVideoNode } - 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) + func animateIn() { + if !self.contentContainerView.alpha.isZero { + var bounds = self.bounds + bounds.origin = CGPoint() + self.bounds = bounds + self.layer.removeAnimation(forKey: "bounds") + self.statusBar.layer.removeAnimation(forKey: "opacity") + self.contentContainerView.layer.removeAnimation(forKey: "opacity") + self.contentContainerView.layer.removeAnimation(forKey: "scale") + self.statusBar.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + if !self.shouldStayHiddenUntilConnection { + self.contentContainerView.layer.animateScale(from: 1.04, to: 1.0, duration: 0.3) + self.contentContainerView.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.contentContainerView.alpha > 0.0 { + self.contentContainerView.layer.allowsGroupOpacity = true + self.contentContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in + self?.contentContainerView.layer.allowsGroupOpacity = false + }) + self.contentContainerView.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)) } - return containerPictureInPictureFrame } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { @@ -1585,455 +983,871 @@ final class CallControllerView: ViewControllerTracingNodeView { isCompactLayout = false } - if !self.hasVideoNodes { - self.isUIHidden = 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) + } + + 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(view: self.containerTransformationView, frame: containerFrame) + transition.updateSublayerTransformScale(view: self.containerTransformationView, scale: min(1.0, containerFrame.width / layout.size.width * 1.01)) + transition.updateCornerRadius(layer: self.containerTransformationView.layer, cornerRadius: self.pictureInPictureTransitionFraction * 10.0) + + transition.updateFrame(view: self.contentContainerView, 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(view: 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(view: keyPreviewNode, frame: containerFullScreenFrame) + keyPreviewNode.updateLayout(size: layout.size, transition: .immediate) + } + + transition.updateFrame(node: gradientBackgroundNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + gradientBackgroundNode.updateLayout(size: layout.size, transition: transition, extendAnimation: false, backwards: false, completion: {}) + + 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 topOffset: CGFloat = layout.safeInsets.top + // TODO: implement - some magic here +// 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 +// } +// } + + topOffset += 174 + + let avatarFrame = CGRect(origin: CGPoint(x: (layout.size.width - avatarNode.bounds.width) / 2.0, y: topOffset), + size: self.avatarNode.bounds.size) + transition.updateFrame(node: self.avatarNode, frame: avatarFrame) + transition.updateFrame(view: self.audioLevelView, frame: avatarFrame) + + topOffset += self.avatarNode.bounds.size.height + 40 + + let statusHeight = self.statusNode.updateLayout(constrainedWidth: layout.size.width, transition: transition) + transition.updateFrame(view: self.statusNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topOffset), size: CGSize(width: layout.size.width, height: statusHeight))) + transition.updateAlpha(view: 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?.removeFromSuperview() + }) + } else { + removedMinimizedVideoNodeValue.removeFromSuperview() + } + } + + if let expandedVideoNode = self.expandedVideoNode { + transition.updateAlpha(view: 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(view: expandedVideoNode, frame: fullscreenVideoFrame, completion: { [weak removedExpandedVideoNodeValue] _ in + removedExpandedVideoNodeValue?.removeFromSuperview() + }) + } else { + expandedVideoTransition.updateFrame(view: 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) + } + + 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?.removeFromSuperview() + }) + } else { + removedExpandedVideoNodeValue.removeFromSuperview() + } + } + } + - var isUIHidden = self.isUIHidden - switch self.callState?.state { - case .terminated, .terminating: - isUIHidden = false - default: - break + if let minimizedVideoNode = self.minimizedVideoNode { + transition.updateAlpha(view: 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.contentContainerView.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(view: 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 } - 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 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) - let previousVideoButtonFrame = self.buttonsNode.videoButtonFrame().flatMap { frame -> CGRect in - return self.buttonsNode.view.convert(frame, to: self) + if let debugNode = self.debugNode { + transition.updateFrame(node: debugNode, frame: CGRect(origin: CGPoint(), size: layout.size)) } - 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) + 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 { - buttonsHeight = 0.0 + requestedAspect = 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 + if self.currentRequestedAspect != requestedAspect { + self.currentRequestedAspect = requestedAspect + if !self.sharedContext.immediateExperimentalUISettings.disableVideoAspectScaling { + self.call.setRequestedVideoAspect(Float(requestedAspect)) + } } - - 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(view: self.containerTransformationView, frame: containerFrame) - transition.updateSublayerTransformScale(view: self.containerTransformationView, scale: min(1.0, containerFrame.width / layout.size.width * 1.01)) - transition.updateCornerRadius(layer: self.containerTransformationView.layer, cornerRadius: self.pictureInPictureTransitionFraction * 10.0) - - transition.updateFrame(view: self.contentContainerView, 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(view: 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) - + } + +} + +// MARK: - Interface Callbacks + +private extension CallControllerView { + + @objc func keyPressed() { + if self.keyPreviewNode == nil, let keyText = self.keyTextData?.1, let peer = self.peer { + let keyPreviewNode = CallControllerKeyPreviewView(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.contentContainerView.insertSubview(keyPreviewNode, belowSubview: 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 { - transition.updateFrame(view: keyPreviewNode, frame: containerFullScreenFrame) - keyPreviewNode.updateLayout(size: layout.size, transition: .immediate) + self.keyPreviewNode = nil + keyPreviewNode.animateOut(to: self.keyButtonNode.frame, toNode: self.keyButtonNode, completion: { [weak self, weak keyPreviewNode] in + self?.keyButtonNode.isHidden = false + keyPreviewNode?.removeFromSuperview() + }) + 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?() + } + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + if !self.pictureInPictureTransitionFraction.isZero { + self.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.snapshotView(afterScreenUpdates: false) + copyView?.frame = minimizedVideoNode.frame + self.expandedVideoNode = minimizedVideoNode + self.minimizedVideoNode = expandedVideoNode + if let superview = expandedVideoNode.superview { + superview.insertSubview(expandedVideoNode, aboveSubview: 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() + } + } + } + } + } } + } - transition.updateFrame(node: gradientBackgroundNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - gradientBackgroundNode.updateLayout(size: layout.size, transition: transition, extendAnimation: false, backwards: false, completion: {}) - - 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)) + private func presentDebugNode() { + guard self.debugNode == nil else { + return } - 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 topOffset: CGFloat = layout.safeInsets.top - // TODO: implement - some magic here -// 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 -// } -// } - - topOffset += 174 - let avatarFrame = CGRect(origin: CGPoint(x: (layout.size.width - avatarNode.bounds.width) / 2.0, y: topOffset), - size: self.avatarNode.bounds.size) - transition.updateFrame(node: self.avatarNode, frame: avatarFrame) - transition.updateFrame(view: self.audioLevelView, frame: avatarFrame) + self.forceReportRating = true - topOffset += self.avatarNode.bounds.size.height + 40 - - let statusHeight = self.statusNode.updateLayout(constrainedWidth: layout.size.width, transition: transition) - transition.updateFrame(view: self.statusNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topOffset), size: CGSize(width: layout.size.width, height: statusHeight))) - transition.updateAlpha(view: 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?.removeFromSuperview() - }) - } else { - removedMinimizedVideoNodeValue.removeFromSuperview() + let debugNode = CallDebugNode(signal: self.debugInfo) + debugNode.dismiss = { [weak self] in + if let strongSelf = self { + strongSelf.debugNode?.removeFromSupernode() + strongSelf.debugNode = nil } } - - if let expandedVideoNode = self.expandedVideoNode { - transition.updateAlpha(view: 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(view: expandedVideoNode, frame: fullscreenVideoFrame, completion: { [weak removedExpandedVideoNodeValue] _ in - removedExpandedVideoNodeValue?.removeFromSuperview() - }) - } else { - expandedVideoTransition.updateFrame(view: 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) - } - - if let previousVideoButtonFrame = previousVideoButtonFrame, let videoButtonFrame = videoButtonFrame { - expandedVideoNode.animateRadialMask(from: previousVideoButtonFrame, to: videoButtonFrame) - } + self.addSubnode(debugNode) + self.debugNode = debugNode + + if let (layout, navigationBarHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) + } + } + + @objc private func panGesture(_ recognizer: CallPanGestureRecognizer) { + switch recognizer.state { + case .began: + guard let location = recognizer.firstLocation else { + return } - } - } 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?.removeFromSuperview() - }) + if self.pictureInPictureTransitionFraction.isZero, let expandedVideoNode = self.expandedVideoNode, let minimizedVideoNode = self.minimizedVideoNode, minimizedVideoNode.frame.contains(location), expandedVideoNode.frame != minimizedVideoNode.frame { + self.minimizedVideoInitialPosition = minimizedVideoNode.center + } else if self.hasVideoNodes { + self.minimizedVideoInitialPosition = nil + if !self.pictureInPictureTransitionFraction.isZero { + self.pictureInPictureGestureState = .dragging(initialPosition: self.containerTransformationView.center, draggingPosition: self.containerTransformationView.center) + } else { + self.pictureInPictureGestureState = .collapsing(didSelectCorner: false) + } } else { - removedExpandedVideoNodeValue.removeFromSuperview() + self.pictureInPictureGestureState = .none } - } - } - - - if let minimizedVideoNode = self.minimizedVideoNode { - transition.updateAlpha(view: 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.contentContainerView.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 + self.dismissAllTooltips?() + case .changed: + if let minimizedVideoNode = self.minimizedVideoNode, let minimizedVideoInitialPosition = self.minimizedVideoInitialPosition { + let translation = recognizer.translation(in: self) + let minimizedVideoDraggingPosition = CGPoint(x: minimizedVideoInitialPosition.x + translation.x, y: minimizedVideoInitialPosition.y + translation.y) + self.minimizedVideoDraggingPosition = minimizedVideoDraggingPosition + minimizedVideoNode.center = minimizedVideoDraggingPosition + } else { + switch self.pictureInPictureGestureState { + case .none: + let offset = recognizer.translation(in: self).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) + 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) + draggingPosition.x = initialPosition.x + translation.x + draggingPosition.y = initialPosition.y + translation.y + self.pictureInPictureGestureState = .dragging(initialPosition: initialPosition, draggingPosition: draggingPosition) + self.containerTransformationView.center = draggingPosition + } } - minimizedVideoTransition.updateFrame(view: 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) + 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)) + + 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).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).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) + 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)) + + let containerFrame = self.calculatePictureInPictureContainerRect(layout: layout, navigationHeight: navigationHeight) + self.containerTransformationView.frame = containerFrame + containerTransformationView.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) + } + } } - } - - 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 = CallControllerKeyPreviewView(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() + +} + +// MARK: - Private + +private extension CallControllerView { + + 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) } - }) - - self.contentContainerView.insertSubview(keyPreviewNode, belowSubview: 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?.removeFromSuperview() - }) - 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 func setUIState(_ state: UIState) { + guard uiState != state else { + return + } + let isNewStateAllowed: Bool + switch uiState { + case .ringing: isNewStateAllowed = true + case .active: isNewStateAllowed = true + case .weakSignal: isNewStateAllowed = state == .ringing || state == .active + case .video: isNewStateAllowed = true + case .none: isNewStateAllowed = true + } + guard isNewStateAllowed else { + return + } + uiState = state + switch state { + case .ringing: + let colors = [UIColor(rgb: 0xAC65D4), UIColor(rgb: 0x7261DA), UIColor(rgb: 0x5295D6), UIColor(rgb: 0x616AD5)] + self.gradientBackgroundNode.updateColors(colors: colors) + avatarNode.isHidden = false + audioLevelView.isHidden = false + audioLevelView.startAnimating() + case .active: + let colors = [UIColor(rgb: 0x53A6DE), UIColor(rgb: 0x398D6F), UIColor(rgb: 0xBAC05D), UIColor(rgb: 0x3C9C8F)] + self.gradientBackgroundNode.updateColors(colors: colors) + avatarNode.isHidden = false + audioLevelView.isHidden = false + audioLevelView.startAnimating() + case .weakSignal: + let colors = [UIColor(rgb: 0xC94986), UIColor(rgb: 0xFF7E46), UIColor(rgb: 0xB84498), UIColor(rgb: 0xF4992E)] + self.gradientBackgroundNode.updateColors(colors: colors) + case .video: + avatarNode.isHidden = true + audioLevelView.isHidden = true + audioLevelView.stopAnimating(duration: 0.5) } } - - 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.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.snapshotView(afterScreenUpdates: false) - copyView?.frame = minimizedVideoNode.frame - self.expandedVideoNode = minimizedVideoNode - self.minimizedVideoNode = expandedVideoNode - if let superview = expandedVideoNode.superview { - superview.insertSubview(expandedVideoNode, aboveSubview: 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)) - } + + 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) } - } 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 case .muted = callState.remoteAudioState { + toastContent.insert(.microphone) } - if updated, let (layout, navigationBarHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) + if case .low = callState.remoteBatteryLevel { + toastContent.insert(.battery) } } } 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() - } - } + 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 setUIState(_ state: UIState) { - guard uiState != state else { + private func cancelScheduledUIHiding() { + self.hideUIForActiveVideoCallTimer?.invalidate() + self.hideUIForActiveVideoCallTimer = nil + } + + private func updateButtonsMode(transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .spring)) { + guard let callState = self.callState else { return } - let isNewStateAllowed: Bool - switch uiState { - case .ringing: isNewStateAllowed = true - case .active: isNewStateAllowed = true - case .weakSignal: isNewStateAllowed = state == .ringing || state == .active - case .video: isNewStateAllowed = true - case .none: isNewStateAllowed = true + + 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 + } } - guard isNewStateAllowed else { - return + 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 + } } - uiState = state - switch state { + + switch callState.state { case .ringing: - let colors = [UIColor(rgb: 0xAC65D4), UIColor(rgb: 0x7261DA), UIColor(rgb: 0x5295D6), UIColor(rgb: 0x616AD5)] - self.gradientBackgroundNode.updateColors(colors: colors) - avatarNode.isHidden = false - audioLevelView.isHidden = false - audioLevelView.startAnimating() - case .active: - let colors = [UIColor(rgb: 0x53A6DE), UIColor(rgb: 0x398D6F), UIColor(rgb: 0xBAC05D), UIColor(rgb: 0x3C9C8F)] - self.gradientBackgroundNode.updateColors(colors: colors) - avatarNode.isHidden = false - audioLevelView.isHidden = false - audioLevelView.startAnimating() - case .weakSignal: - let colors = [UIColor(rgb: 0xC94986), UIColor(rgb: 0xFF7E46), UIColor(rgb: 0xB84498), UIColor(rgb: 0xF4992E)] - self.gradientBackgroundNode.updateColors(colors: colors) - case .video: - avatarNode.isHidden = true - audioLevelView.isHidden = true - audioLevelView.stopAnimating(duration: 0.5) + 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) + } } - } - - private func presentDebugNode() { - guard self.debugNode == nil else { - return + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: transition) } - - self.forceReportRating = true - - let debugNode = CallDebugNode(signal: self.debugInfo) - debugNode.dismiss = { [weak self] in - if let strongSelf = self { - strongSelf.debugNode?.removeFromSupernode() - strongSelf.debugNode = nil + } + + 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)) } - self.addSubnode(debugNode) - self.debugNode = debugNode - - if let (layout, navigationBarHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) + 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 } - - private var minimizedVideoInitialPosition: CGPoint? - private var minimizedVideoDraggingPosition: CGPoint? - + private func nodeLocationForPosition(layout: ContainerViewLayout, position: CGPoint, velocity: CGPoint) -> VideoNodeCorner { let layoutInsets = UIEdgeInsets() var result = CGPoint() @@ -2047,21 +1861,21 @@ final class CallControllerView: ViewControllerTracingNodeView { } 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 @@ -2120,163 +1934,356 @@ final class CallControllerView: ViewControllerTracingNodeView { result.x = 0.0 result.y = 0.0 } - else if (!shouldHide) { - shouldHide = true + 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 + } + } + } + + private func areUserActionsDisabledNow() -> Bool { + return CACurrentMediaTime() < self.disableActionsUntilTimestamp + } + +} + +// MARK: - CallVideoView + +private final class CallVideoView: UIView, PreviewVideoView { + + private let videoTransformContainer: UIView + 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 readyPromise = ValuePromise(false) + var ready: Signal { + return self.readyPromise.get() + } + + private let isFlippedUpdated: (CallVideoView) -> 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 (CallVideoView) -> Void) { + self.isReadyUpdated = isReadyUpdated + self.isFlippedUpdated = isFlippedUpdated + + self.videoTransformContainer = UIView() + 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(frame: CGRect.zero) + + self.backgroundColor = .black + self.clipsToBounds = true + + if #available(iOS 13.0, *) { + self.layer.cornerCurve = .continuous + } + + self.videoTransformContainer.addSubview(self.videoView.view) + self.addSubview(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.readyPromise.set(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.readyPromise.set(true) + strongSelf.isReadyUpdated() + } + }, queue: .mainQueue()) + } + self.isReadyTimer?.start() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.isReadyTimer?.invalidate() + } + + 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, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition) { + self.updateLayout(size: size, cornerRadius: self.currentCornerRadius, isOutgoing: true, deviceOrientation: .portrait, isCompactLayout: false, transition: transition) + } + + func updateLayout(size: CGSize, cornerRadius: CGFloat, isOutgoing: Bool, deviceOrientation: UIDeviceOrientation, isCompactLayout: Bool, transition: ContainedViewLayoutTransition) { + self.currentCornerRadius = cornerRadius + + var rotationAngle: CGFloat + if false && 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: + rotationAngle = CGFloat.pi + case .rotation270: + 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 * 3.0 / 2.0) < 0.01 { + rotationAngle = -CGFloat.pi / 2.0 + } + if abs(rotationAngle - (-CGFloat.pi)) < 0.01 { + 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) } } } - - if result.x.isZero { - if result.y.isZero { - return .topLeft - } else { - return .bottomLeft - } - } else { - if result.y.isZero { - return .topRight - } else { - return .bottomRight + + 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(view: self.videoTransformContainer, from: scaleDifference) } } + self.previousVideoHeight = rotatedVideoHeight + transition.updatePosition(view: self.videoTransformContainer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + transition.updateTransformRotation(view: self.videoTransformContainer, 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) } - - @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.center - } else if self.hasVideoNodes { - self.minimizedVideoInitialPosition = nil - if !self.pictureInPictureTransitionFraction.isZero { - self.pictureInPictureGestureState = .dragging(initialPosition: self.containerTransformationView.center, draggingPosition: self.containerTransformationView.center) - } 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) - let minimizedVideoDraggingPosition = CGPoint(x: minimizedVideoInitialPosition.x + translation.x, y: minimizedVideoInitialPosition.y + translation.y) - self.minimizedVideoDraggingPosition = minimizedVideoDraggingPosition - minimizedVideoNode.center = minimizedVideoDraggingPosition - } else { - switch self.pictureInPictureGestureState { - case .none: - let offset = recognizer.translation(in: self).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) - 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) - draggingPosition.x = initialPosition.x + translation.x - draggingPosition.y = initialPosition.y + translation.y - self.pictureInPictureGestureState = .dragging(initialPosition: initialPosition, draggingPosition: draggingPosition) - self.containerTransformationView.center = 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)) - - 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).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).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) - 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)) - - let containerFrame = self.calculatePictureInPictureContainerRect(layout: layout, navigationHeight: navigationHeight) - self.containerTransformationView.frame = containerFrame - containerTransformationView.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 + + func updateIsBlurred(isBlurred: Bool, light: Bool = false, animated: Bool = true) { + if self.hasScheduledUnblur { + self.hasScheduledUnblur = false + } + 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.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() + }) } } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if self.debugNode != nil { - return super.hitTest(point, with: event) + + private var hasScheduledUnblur = false + func flip(withBackground: Bool) { + if withBackground { + self.backgroundColor = .black } - if self.containerTransformationView.frame.contains(point) { - return self.containerTransformationView.hitTest(self.convert(point, to: self.containerTransformationView), with: event) + UIView.transition(with: withBackground ? self.videoTransformContainer : self, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: { + UIView.performWithoutAnimation { + self.updateIsBlurred(isBlurred: true, light: false, animated: false) + } + }) { finished in + self.backgroundColor = nil + self.hasScheduledUnblur = true + Queue.mainQueue().after(0.5) { + if self.hasScheduledUnblur { + self.updateIsBlurred(isBlurred: false) + } + } } - return nil } } + +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 +} diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift index ceb7622df67..93ddae1f46e 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift @@ -30,6 +30,7 @@ public final class CallViewController: ViewController { private var presentationData: PresentationData private var peerDisposable: Disposable? + private let audioLevelDisposable = MetaDisposable() private var disposable: Disposable? private var callMutedDisposable: Disposable? private var audioOutputStateDisposable: Disposable? @@ -63,31 +64,6 @@ public final class CallViewController: ViewController { self.statusBar.ignoreInCall = true self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .portrait, compactSize: .portrait) - - self.disposable = (call.state - |> deliverOnMainQueue).start(next: { [weak self] callState in - self?.callStateUpdated(callState) - }) - - self.callMutedDisposable = (call.isMuted - |> deliverOnMainQueue).start(next: { [weak self] value in - if let strongSelf = self { - strongSelf.isMuted = value - if strongSelf.isNodeLoaded { - strongSelf.callControllerView.isMuted = value - } - } - }) - - self.audioOutputStateDisposable = (call.audioOutputState - |> deliverOnMainQueue).start(next: { [weak self] state in - if let strongSelf = self { - strongSelf.audioOutputState = state - if strongSelf.isNodeLoaded { - strongSelf.callControllerView.updateAudioOutputs(availableOutputs: state.0, currentOutput: state.1) - } - } - }) } required public init(coder aDecoder: NSCoder) { @@ -96,6 +72,7 @@ public final class CallViewController: ViewController { deinit { self.peerDisposable?.dispose() + self.audioLevelDisposable.dispose() self.disposable?.dispose() self.callMutedDisposable?.dispose() self.audioOutputStateDisposable?.dispose() @@ -146,14 +123,6 @@ public final class CallViewController: ViewController { self.callControllerView.animateIn() } self.idleTimerExtensionDisposable.set(self.sharedContext.applicationBindings.pushIdleTimerExtension()) - - // TODO: implement - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.seconds(5), execute: { - print("===============================================VIEWS:==========================================") - let rootContainerViewLocal: UIView = self.rootContainerView - print(rootContainerViewLocal) - print(rootContainerViewLocal.subviews) - }) } override public func viewDidDisappear(_ animated: Bool) { @@ -382,6 +351,38 @@ private extension CallViewController { } } }) + self.disposable = (call.state + |> deliverOnMainQueue).start(next: { [weak self] callState in + self?.callStateUpdated(callState) + }) + + self.callMutedDisposable = (call.isMuted + |> deliverOnMainQueue).start(next: { [weak self] value in + if let strongSelf = self { + strongSelf.isMuted = value + if strongSelf.isNodeLoaded { + strongSelf.callControllerView.isMuted = value + } + } + }) + + self.audioOutputStateDisposable = (call.audioOutputState + |> deliverOnMainQueue).start(next: { [weak self] state in + if let strongSelf = self { + strongSelf.audioOutputState = state + if strongSelf.isNodeLoaded { + strongSelf.callControllerView.updateAudioOutputs(availableOutputs: state.0, currentOutput: state.1) + } + } + }) + self.audioLevelDisposable.set((call.audioLevel + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let strongSelf = self else { + return + } + strongSelf.callControllerView.updateAudioLevel(CGFloat(value) * 2.0) + + })) } private func callStateUpdated(_ callState: PresentationCallState) { @@ -390,57 +391,4 @@ private extension CallViewController { } } - // private var containerTransformationView: UIView! // CallControllerNode: containerTransformationNode: ASDisplayNode - // private var contentContainerView: UIView! // CallControllerNode: containerNode: ASDisplayNode - // private var videoContainerView: UIView! // CallControllerNode: videoContainerNode: PinchSourceContainerNode - // private var backButtonArrowView: UIImageView! // CallControllerNode: backButtonArrowNode: ASImageNode - // private var backButton: UIButton! // CallControllerNode: backButtonNode: HighlightableButtonNode - -// private func setupUI() { -// let rootContainerViewLocal = UIView(frame: self.displayNode.view.bounds) -// rootContainerView = rootContainerViewLocal -// rootContainerViewLocal.translatesAutoresizingMaskIntoConstraints = false -// rootContainerViewLocal.backgroundColor = UIColor.clear -// displayNode.view.addSubview(rootContainerViewLocal) -// -// let containerTransformationViewLocal = UIView(frame: rootContainerView.bounds) -// containerTransformationView = containerTransformationViewLocal -// containerTransformationViewLocal.clipsToBounds = true -// containerTransformationViewLocal.backgroundColor = UIColor.clear -// rootContainerView.addSubview(containerTransformationViewLocal) -// -// let contentContainerViewLocal = UIView(frame: containerTransformationView.bounds) -// contentContainerView = contentContainerViewLocal -// contentContainerViewLocal.backgroundColor = UIColor.black -// containerTransformationView.addSubview(contentContainerViewLocal) -// -// backButtonArrowView = UIImageView(image: nil) -// backButtonArrowView.image = NavigationBarTheme.generateBackArrowImage(color: .white) -// contentContainerView.addSubview(backButtonArrowView) -// -// backButton = UIButton(frame: CGRectZero) -// self.backButton.addTarget(self, action: #selector(self.backPressed), for: .touchUpInside) -// self.backButton.setTitle(presentationData.strings.Common_Back, for: .normal) -//// self.backButton.setTitle(presentationData.strings.Common_Back, with: Font.regular(17.0), with: .white, for: []) -// self.backButton.accessibilityLabel = presentationData.strings.Call_VoiceOver_Minimize -// self.backButton.accessibilityTraits = [.button] -//// self.backButton.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) -//// } -//// } -//// } -// contentContainerView.addSubview(backButton) -// } - } From 9d0ac12f0a37381bd7de86f9f41c11d7a9fbbd25 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Thu, 2 Mar 2023 21:22:58 +0300 Subject: [PATCH 03/11] TELEGRAM-[imroved states handling] --- .../Sources/CallViewController/CallControllerView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift index 1214a968855..9de488d9614 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -1544,8 +1544,8 @@ private extension CallControllerView { switch uiState { case .ringing: isNewStateAllowed = true case .active: isNewStateAllowed = true - case .weakSignal: isNewStateAllowed = state == .ringing || state == .active - case .video: isNewStateAllowed = true + case .weakSignal: isNewStateAllowed = true + case .video: isNewStateAllowed = !hasVideoNodes case .none: isNewStateAllowed = true } guard isNewStateAllowed else { From 5b3622016efe1e26db36e5b92ac5bb029d75d486 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 3 Mar 2023 21:49:05 +0300 Subject: [PATCH 04/11] TELEGRAM-[implemented buttons layout] --- .../CallControllerButtonsView.swift | 141 ++++++++---------- .../CallControllerView.swift | 44 +++--- .../CallViewController.swift | 1 + 3 files changed, 87 insertions(+), 99 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift index 6c434aef066..d62b9af091f 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift @@ -127,14 +127,9 @@ final class CallControllerButtonsView: UIView { } } - let minSmallButtonSideInset: CGFloat = width > 320.0 ? 34.0 : 16.0 - let maxSmallButtonSpacing: CGFloat = 34.0 - let smallButtonSize: CGFloat = 60.0 - let topBottomSpacing: CGFloat = 84.0 - - let maxLargeButtonSpacing: CGFloat = 115.0 - let largeButtonSize: CGFloat = 72.0 - let minLargeButtonSideInset: CGFloat = minSmallButtonSideInset - 6.0 + let minButtonSideInset: CGFloat = width > 320.0 ? 30.0 : 16.0 + let maxButtonSpacing: CGFloat = 36.0 + let buttonSize: CGFloat = 56.0 struct PlacedButton { let button: ButtonDescription @@ -173,7 +168,6 @@ final class CallControllerButtonsView: UIView { var buttons: [PlacedButton] = [] switch mappedState { case .incomingRinging, .outgoingRinging: - var topButtons: [ButtonDescription] = [] var bottomButtons: [ButtonDescription] = [] let soundOutput: ButtonDescription.SoundOutput @@ -210,33 +204,30 @@ final class CallControllerButtonsView: UIView { isScreencastActive = false isCameraInitializing = videoState.isInitializingCamera } - topButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: false, isLoading: isCameraInitializing, isScreencast: isScreencastActive)) if !videoState.hasVideo { - topButtons.append(.mute(self.isMuted)) - topButtons.append(.soundOutput(soundOutput)) + bottomButtons.append(.soundOutput(soundOutput)) + bottomButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, + isEnabled: false, + isLoading: isCameraInitializing, + isScreencast: isScreencastActive)) + bottomButtons.append(.mute(self.isMuted)) } else { + if !isScreencastActive { + bottomButtons.append(.switchCamera(isCameraActive && !isCameraInitializing)) + } + bottomButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, + isEnabled: false, + isLoading: isCameraInitializing, + isScreencast: isScreencastActive)) if hasAudioRouteMenu { - topButtons.append(.soundOutput(soundOutput)) + bottomButtons.append(.soundOutput(soundOutput)) } else { - topButtons.append(.mute(self.isMuted)) - } - if !isScreencastActive { - topButtons.append(.switchCamera(isCameraActive && !isCameraInitializing)) + bottomButtons.append(.mute(self.isMuted)) } } } else { - topButtons.append(.mute(self.isMuted)) - topButtons.append(.soundOutput(soundOutput)) - } - - let topButtonsContentWidth = CGFloat(topButtons.count) * largeButtonSize - let topButtonsAvailableSpacingWidth = width - topButtonsContentWidth - minSmallButtonSideInset * 2.0 - let topButtonsSpacing = min(maxSmallButtonSpacing, topButtonsAvailableSpacingWidth / CGFloat(topButtons.count - 1)) - let topButtonsWidth = CGFloat(topButtons.count) * largeButtonSize + CGFloat(topButtons.count - 1) * topButtonsSpacing - var topButtonsLeftOffset = floor((width - topButtonsWidth) / 2.0) - for button in topButtons { - buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: topButtonsLeftOffset, y: 0.0), size: CGSize(width: largeButtonSize, height: largeButtonSize)))) - topButtonsLeftOffset += largeButtonSize + topButtonsSpacing + bottomButtons.append(.soundOutput(soundOutput)) + bottomButtons.append(.mute(self.isMuted)) } if case .incomingRinging = mappedState { @@ -246,17 +237,19 @@ final class CallControllerButtonsView: UIView { bottomButtons.append(.end(.outgoing)) } - let bottomButtonsContentWidth = CGFloat(bottomButtons.count) * largeButtonSize - let bottomButtonsAvailableSpacingWidth = width - bottomButtonsContentWidth - minLargeButtonSideInset * 2.0 - let bottomButtonsSpacing = min(maxLargeButtonSpacing, bottomButtonsAvailableSpacingWidth / CGFloat(bottomButtons.count - 1)) - let bottomButtonsWidth = CGFloat(bottomButtons.count) * largeButtonSize + CGFloat(bottomButtons.count - 1) * bottomButtonsSpacing + let bottomButtonsContentWidth = CGFloat(bottomButtons.count) * buttonSize + let bottomButtonsAvailableSpacingWidth = width - bottomButtonsContentWidth - minButtonSideInset * 2.0 + let bottomButtonsSpacing = min(maxButtonSpacing, bottomButtonsAvailableSpacingWidth / CGFloat(bottomButtons.count - 1)) + let bottomButtonsWidth = CGFloat(bottomButtons.count) * buttonSize + CGFloat(bottomButtons.count - 1) * bottomButtonsSpacing var bottomButtonsLeftOffset = floor((width - bottomButtonsWidth) / 2.0) for button in bottomButtons { - buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: bottomButtonsLeftOffset, y: largeButtonSize + topBottomSpacing), size: CGSize(width: largeButtonSize, height: largeButtonSize)))) - bottomButtonsLeftOffset += largeButtonSize + bottomButtonsSpacing + let frame = CGRect(origin: CGPoint(x: bottomButtonsLeftOffset, y: 0), + size: CGSize(width: buttonSize, height: buttonSize)) + buttons.append(PlacedButton(button: button, frame: frame)) + bottomButtonsLeftOffset += buttonSize + bottomButtonsSpacing } - height = largeButtonSize + topBottomSpacing + largeButtonSize + max(bottomInset + 32.0, 46.0) + height = buttonSize + max(bottomInset + 52.0, 66.0) case .active: if videoState.hasVideo { let isCameraActive: Bool @@ -275,7 +268,7 @@ final class CallControllerButtonsView: UIView { isCameraInitializing = videoState.isInitializingCamera } - var topButtons: [ButtonDescription] = [] + var bottomButtons: [ButtonDescription] = [] let soundOutput: ButtonDescription.SoundOutput switch speakerMode { @@ -297,31 +290,30 @@ final class CallControllerButtonsView: UIView { soundOutput = .airpodsMax } } - - topButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: isCameraEnabled, isLoading: isCameraInitializing, isScreencast: isScreencastActive)) - if hasAudioRouteMenu { - topButtons.append(.soundOutput(soundOutput)) + + if videoState.isCameraActive && !isScreencastActive { + bottomButtons.append(.switchCamera(isCameraActive && !isCameraInitializing)) } else { - topButtons.append(.mute(isMuted)) + bottomButtons.append(.soundOutput(soundOutput)) } - if !isScreencastActive { - topButtons.append(.switchCamera(isCameraActive && !isCameraInitializing)) - } - topButtons.append(.end(.end)) + bottomButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: isCameraEnabled, isLoading: isCameraInitializing, isScreencast: isScreencastActive)) + bottomButtons.append(.mute(isMuted)) + bottomButtons.append(.end(.end)) - let topButtonsContentWidth = CGFloat(topButtons.count) * smallButtonSize - let topButtonsAvailableSpacingWidth = width - topButtonsContentWidth - minSmallButtonSideInset * 2.0 - let topButtonsSpacing = min(maxSmallButtonSpacing, topButtonsAvailableSpacingWidth / CGFloat(topButtons.count - 1)) - let topButtonsWidth = CGFloat(topButtons.count) * smallButtonSize + CGFloat(topButtons.count - 1) * topButtonsSpacing - var topButtonsLeftOffset = floor((width - topButtonsWidth) / 2.0) - for button in topButtons { - buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: topButtonsLeftOffset, y: 0.0), size: CGSize(width: smallButtonSize, height: smallButtonSize)))) - topButtonsLeftOffset += smallButtonSize + topButtonsSpacing + let bottomButtonsContentWidth = CGFloat(bottomButtons.count) * buttonSize + let bottomButtonsAvailableSpacingWidth = width - bottomButtonsContentWidth - minButtonSideInset * 2.0 + let bottomButtonsSpacing = min(maxButtonSpacing, bottomButtonsAvailableSpacingWidth / CGFloat(bottomButtons.count - 1)) + let bottomButtonsWidth = CGFloat(bottomButtons.count) * buttonSize + CGFloat(bottomButtons.count - 1) * bottomButtonsSpacing + var bottomButtonsLeftOffset = floor((width - bottomButtonsWidth) / 2.0) + for button in bottomButtons { + let frame = CGRect(origin: CGPoint(x: bottomButtonsLeftOffset, y: 0), + size: CGSize(width: buttonSize, height: buttonSize)) + buttons.append(PlacedButton(button: button, frame: frame)) + bottomButtonsLeftOffset += buttonSize + bottomButtonsSpacing } - - height = smallButtonSize + max(bottomInset + 19.0, 46.0) + + height = buttonSize + max(bottomInset + 52.0, 66.0) } else { - var topButtons: [ButtonDescription] = [] var bottomButtons: [ButtonDescription] = [] let isCameraActive: Bool @@ -360,34 +352,25 @@ final class CallControllerButtonsView: UIView { soundOutput = .airpodsMax } } - - topButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: isCameraEnabled, isLoading: isCameraInitializing, isScreencast: isScreencastActive)) - topButtons.append(.mute(self.isMuted)) - topButtons.append(.soundOutput(soundOutput)) - - let topButtonsContentWidth = CGFloat(topButtons.count) * largeButtonSize - let topButtonsAvailableSpacingWidth = width - topButtonsContentWidth - minSmallButtonSideInset * 2.0 - let topButtonsSpacing = min(maxSmallButtonSpacing, topButtonsAvailableSpacingWidth / CGFloat(topButtons.count - 1)) - let topButtonsWidth = CGFloat(topButtons.count) * largeButtonSize + CGFloat(topButtons.count - 1) * topButtonsSpacing - var topButtonsLeftOffset = floor((width - topButtonsWidth) / 2.0) - for button in topButtons { - buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: topButtonsLeftOffset, y: 0.0), size: CGSize(width: largeButtonSize, height: largeButtonSize)))) - topButtonsLeftOffset += largeButtonSize + topButtonsSpacing - } - + + bottomButtons.append(.soundOutput(soundOutput)) + bottomButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: isCameraEnabled, isLoading: isCameraInitializing, isScreencast: isScreencastActive)) + bottomButtons.append(.mute(self.isMuted)) bottomButtons.append(.end(.outgoing)) - let bottomButtonsContentWidth = CGFloat(bottomButtons.count) * largeButtonSize - let bottomButtonsAvailableSpacingWidth = width - bottomButtonsContentWidth - minLargeButtonSideInset * 2.0 - let bottomButtonsSpacing = min(maxLargeButtonSpacing, bottomButtonsAvailableSpacingWidth / CGFloat(bottomButtons.count - 1)) - let bottomButtonsWidth = CGFloat(bottomButtons.count) * largeButtonSize + CGFloat(bottomButtons.count - 1) * bottomButtonsSpacing + let bottomButtonsContentWidth = CGFloat(bottomButtons.count) * buttonSize + let bottomButtonsAvailableSpacingWidth = width - bottomButtonsContentWidth - minButtonSideInset * 2.0 + let bottomButtonsSpacing = min(maxButtonSpacing, bottomButtonsAvailableSpacingWidth / CGFloat(bottomButtons.count - 1)) + let bottomButtonsWidth = CGFloat(bottomButtons.count) * buttonSize + CGFloat(bottomButtons.count - 1) * bottomButtonsSpacing var bottomButtonsLeftOffset = floor((width - bottomButtonsWidth) / 2.0) for button in bottomButtons { - buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: bottomButtonsLeftOffset, y: largeButtonSize + topBottomSpacing), size: CGSize(width: largeButtonSize, height: largeButtonSize)))) - bottomButtonsLeftOffset += largeButtonSize + bottomButtonsSpacing + let frame = CGRect(origin: CGPoint(x: bottomButtonsLeftOffset, y: 0), + size: CGSize(width: buttonSize, height: buttonSize)) + buttons.append(PlacedButton(button: button, frame: frame)) + bottomButtonsLeftOffset += buttonSize + bottomButtonsSpacing } - height = largeButtonSize + topBottomSpacing + largeButtonSize + max(bottomInset + 32.0, 46.0) + height = buttonSize + max(bottomInset + 52.0, 66.0) } } diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift index 9de488d9614..3d6c518dd13 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -56,7 +56,7 @@ final class CallControllerView: ViewControllerTracingNodeView { var isMuted: Bool = false { didSet { - self.buttonsNode.isMuted = self.isMuted + self.buttonsView.isMuted = self.isMuted self.updateToastContent() if let (layout, navigationBarHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) @@ -115,7 +115,7 @@ final class CallControllerView: ViewControllerTracingNodeView { private let audioLevelView: VoiceBlobView private let statusNode: CallControllerStatusView private let toastNode: CallControllerToastContainerNode - private let buttonsNode: CallControllerButtonsNode + private let buttonsView: CallControllerButtonsView private var keyPreviewNode: CallControllerKeyPreviewView? private var debugNode: CallDebugNode? @@ -210,7 +210,7 @@ final class CallControllerView: ViewControllerTracingNodeView { self.statusNode = CallControllerStatusView() - self.buttonsNode = CallControllerButtonsNode(strings: self.presentationData.strings) + self.buttonsView = CallControllerButtonsView(strings: self.presentationData.strings) self.toastNode = CallControllerToastContainerNode(strings: self.presentationData.strings) self.keyButtonNode = CallControllerKeyButton() self.keyButtonNode.accessibilityElementsHidden = false @@ -247,7 +247,7 @@ final class CallControllerView: ViewControllerTracingNodeView { self.contentContainerView.addSubview(self.audioLevelView) self.contentContainerView.addSubnode(self.avatarNode) self.contentContainerView.addSubview(self.statusNode) - self.contentContainerView.addSubnode(self.buttonsNode) + self.contentContainerView.addSubview(self.buttonsView) self.contentContainerView.addSubnode(self.toastNode) self.contentContainerView.addSubnode(self.keyButtonNode) self.contentContainerView.addSubnode(self.backButtonArrowNode) @@ -268,12 +268,12 @@ final class CallControllerView: ViewControllerTracingNodeView { let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) self.addGestureRecognizer(tapRecognizer) - self.buttonsNode.mute = { [weak self] in + self.buttonsView.mute = { [weak self] in self?.toggleMute?() self?.cancelScheduledUIHiding() } - self.buttonsNode.speaker = { [weak self] in + self.buttonsView.speaker = { [weak self] in guard let strongSelf = self else { return } @@ -281,7 +281,7 @@ final class CallControllerView: ViewControllerTracingNodeView { strongSelf.cancelScheduledUIHiding() } - self.buttonsNode.acceptOrEnd = { [weak self] in + self.buttonsView.acceptOrEnd = { [weak self] in guard let strongSelf = self, let callState = strongSelf.callState else { return } @@ -298,11 +298,11 @@ final class CallControllerView: ViewControllerTracingNodeView { } } - self.buttonsNode.decline = { [weak self] in + self.buttonsView.decline = { [weak self] in self?.endCall?() } - self.buttonsNode.toggleVideo = { [weak self] in + self.buttonsView.toggleVideo = { [weak self] in guard let strongSelf = self, let callState = strongSelf.callState else { return } @@ -393,7 +393,7 @@ final class CallControllerView: ViewControllerTracingNodeView { } } - self.buttonsNode.rotateCamera = { [weak self] in + self.buttonsView.rotateCamera = { [weak self] in guard let strongSelf = self, !strongSelf.areUserActionsDisabledNow() else { return } @@ -501,8 +501,8 @@ final class CallControllerView: ViewControllerTracingNodeView { // MARK: - Public func displayCameraTooltip() { - guard self.pictureInPictureTransitionFraction.isZero, let location = self.buttonsNode.videoButtonFrame().flatMap({ frame -> CGRect in - return self.buttonsNode.view.convert(frame, to: self) + guard self.pictureInPictureTransitionFraction.isZero, let location = self.buttonsView.videoButtonFrame().flatMap({ frame -> CGRect in + return self.buttonsView.convert(frame, to: self) }) else { return } @@ -1001,13 +1001,13 @@ final class CallControllerView: ViewControllerTracingNodeView { 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) + let previousVideoButtonFrame = self.buttonsView.videoButtonFrame().flatMap { frame -> CGRect in + return self.buttonsView.convert(frame, to: self) } 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) + buttonsHeight = self.buttonsView.updateLayout(strings: self.presentationData.strings, mode: buttonsMode, constrainedWidth: layout.size.width, bottomInset: layout.intrinsicInsets.bottom, transition: transition) } else { buttonsHeight = 0.0 } @@ -1101,8 +1101,8 @@ final class CallControllerView: ViewControllerTracingNodeView { transition.updateAlpha(view: 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) + transition.updateFrame(view: self.buttonsView, frame: CGRect(origin: CGPoint(x: 0.0, y: buttonsOriginY), size: CGSize(width: layout.size.width, height: buttonsHeight))) + transition.updateAlpha(view: self.buttonsView, alpha: overlayAlpha) let fullscreenVideoFrame = containerFullScreenFrame let previewVideoFrame = self.calculatePreviewVideoRect(layout: layout, navigationHeight: navigationBarHeight) @@ -1143,8 +1143,8 @@ final class CallControllerView: ViewControllerTracingNodeView { 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) + let videoButtonFrame = self.buttonsView.videoButtonFrame().flatMap { frame -> CGRect in + return self.buttonsView.convert(frame, to: self) } if let previousVideoButtonFrame = previousVideoButtonFrame, let videoButtonFrame = videoButtonFrame { @@ -1559,15 +1559,19 @@ private extension CallControllerView { avatarNode.isHidden = false audioLevelView.isHidden = false audioLevelView.startAnimating() + updateAudioLevel(1.0) case .active: let colors = [UIColor(rgb: 0x53A6DE), UIColor(rgb: 0x398D6F), UIColor(rgb: 0xBAC05D), UIColor(rgb: 0x3C9C8F)] self.gradientBackgroundNode.updateColors(colors: colors) avatarNode.isHidden = false audioLevelView.isHidden = false audioLevelView.startAnimating() + updateAudioLevel(1.0) case .weakSignal: let colors = [UIColor(rgb: 0xC94986), UIColor(rgb: 0xFF7E46), UIColor(rgb: 0xB84498), UIColor(rgb: 0xF4992E)] self.gradientBackgroundNode.updateColors(colors: colors) + audioLevelView.startAnimating() + updateAudioLevel(1.0) case .video: avatarNode.isHidden = true audioLevelView.isHidden = true @@ -1739,7 +1743,7 @@ private extension CallControllerView { } private func calculatePreviewVideoRect(layout: ContainerViewLayout, navigationHeight: CGFloat) -> CGRect { - let buttonsHeight: CGFloat = self.buttonsNode.bounds.height + let buttonsHeight: CGFloat = self.buttonsView.bounds.height let toastHeight: CGFloat = self.toastNode.bounds.height let toastInset = (toastHeight > 0.0 ? toastHeight + 22.0 : 0.0) diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift index 93ddae1f46e..23c2cee4f1a 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallViewController.swift @@ -273,6 +273,7 @@ private extension CallViewController { Queue.mainQueue().after(0.5, { let window = strongSelf.window + // TODO: implement let controller = callRatingController(sharedContext: strongSelf.sharedContext, account: strongSelf.account, callId: callId, userInitiated: false, isVideo: isVideo, present: { c, a in if let window = window { c.presentationArguments = a From 48afa4a8eee5996d4eb4a3c43c3a882ddd9eeaab Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Fri, 3 Mar 2023 23:06:26 +0300 Subject: [PATCH 05/11] TELEGRAM-[improved video preview layout] --- .../Sources/CallControllerButton.swift | 2 +- .../CallControllerButtonsView.swift | 2 +- .../CallControllerView.swift | 20 ++++++++--------- .../Call/CallEndButton.imageset/Contents.json | 21 ++++++++++++++++++ .../Call/CallEndButton.imageset/end.png | Bin 0 -> 768 bytes 5 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallEndButton.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallEndButton.imageset/end.png diff --git a/submodules/TelegramCallsUI/Sources/CallControllerButton.swift b/submodules/TelegramCallsUI/Sources/CallControllerButton.swift index eb83d754584..c1c3689baae 100644 --- a/submodules/TelegramCallsUI/Sources/CallControllerButton.swift +++ b/submodules/TelegramCallsUI/Sources/CallControllerButton.swift @@ -280,7 +280,7 @@ final class CallControllerButtonItemNode: HighlightTrackingButtonNode { case .accept: image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallAcceptButton"), color: imageColor) case .end: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallDeclineButton"), color: imageColor) + image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallEndButton"), color: imageColor) case .cancel: image = generateImage(CGSize(width: 28.0, height: 28.0), opaque: false, rotatedContext: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift index d62b9af091f..be3d21b42a3 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift @@ -411,7 +411,7 @@ final class CallControllerButtonsView: UIView { ) switch type { case .outgoing: - buttonText = "" + buttonText = strings.Call_End case .decline: buttonText = strings.Call_Decline case .end: diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift index 3d6c518dd13..65f8ef127d6 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -1750,14 +1750,14 @@ private extension CallControllerView { 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 + cleanInsets.bottom = max(layout.intrinsicInsets.bottom, 10.0) + toastInset + cleanInsets.left = 10.0 + cleanInsets.right = 10.0 fullInsets.top += 44.0 + 8.0 - fullInsets.bottom = buttonsHeight + 22.0 + toastInset - fullInsets.left = 20.0 - fullInsets.right = 20.0 + fullInsets.bottom = buttonsHeight + 12.0 + toastInset + fullInsets.left = 10.0 + fullInsets.right = 10.0 var insets: UIEdgeInsets = self.isUIHidden ? cleanInsets : fullInsets @@ -1768,19 +1768,19 @@ private extension CallControllerView { 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) + let previewVideoSide = interpolate(from: 300.0, to: 240.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 + aspect = 138.0 / 240.0 //3.0 / 4.0 } else { if aspect < 1.0 { - aspect = 3.0 / 4.0 + aspect = 138.0 / 240.0 //3.0 / 4.0 } else { - aspect = 4.0 / 3.0 + aspect = 240.0 / 138.0 // 4.0 / 3.0 } switch minimizedVideoNode.currentOrientation { diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallEndButton.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/CallEndButton.imageset/Contents.json new file mode 100644 index 00000000000..a3de35ab831 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/CallEndButton.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "end.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallEndButton.imageset/end.png b/submodules/TelegramUI/Images.xcassets/Call/CallEndButton.imageset/end.png new file mode 100644 index 0000000000000000000000000000000000000000..d6e8e41b6122767cafa42050f6835e9cba33b1dd GIT binary patch literal 768 zcmeAS@N?(olHy`uVBq!ia0vp^D?pfo4M>)FFWmy9I14-?iy0UcEkKyjb(&!UP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di3`|o!T^vIy7~kHxnES{<#Npz{un9pyO~Hn$ z4(y(qFCVO3+q(Yjrfrh5%@12nn%e*4ab)q)8x0yjodiI}!2l~U!N*^o*IM0M_SlH$ z@Y0e6*I!pjUS`_od))G0*{uKJQdeJ>?w#|x>F??E?(WLo&!5kg&gK4j_ICWA_~40p z9iP*5=Kc)*WvpkBzOUYTyTHrRV?VEctUdVB`&_11e`B4#jehMC?Zt#;mQGD!U6Sz#l=&X~HHz69m+~ZIq`7sAtPa`gBaz?c-2Ca#Hyl zkH!&C-s%>SM@yE?a%{D@>D;@)Y0jIM6Av6YsVK+cJ4YrtdC8+E7S?WBey?sQ1{DT{ zAN4qT(oV}o%kSG)hb52hEI2-YU*q+ZZuPYvCb)z>%vIaOm49V&=#LeX*BqMedP1w_ zZr_?L7BQt%e$&HT6N4_xIktMNOgulqX^G9Fjs~YCIfW?}ib2;Z+zb?hrd6bAC7fxUF*8lEvBxoGPN(__8K-AAEgZNRw-_i}XJkH%o;8&#D&~V&dRp@|@m)`E zMrK5<30^ZftI^!8O7Dre?iGpGOKQH}lz16ob@{P=caQeDzq9)K6D9U}8(p7icWS=m z^eX+iFAXm&-MF;(bDHy#cg~xmcN{hU8n7{Gw>K*3>#lc_UdPu(->W(Ey(s>&?OkvZU?Pxk7#MDSl@`-+;F#Wh;uc7)r>mdKI;Vst E0Ek#TQvd(} literal 0 HcmV?d00001 From fa92b516153945d7d53e801b023000b1b191cda1 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Mar 2023 07:49:36 +0300 Subject: [PATCH 06/11] TELEGRAM-[added new icons] --- .../Sources/CallControllerButton.swift | 31 ++++++++++++------ .../CallControllerButtonsView.swift | 2 +- .../Contents.json | 21 ++++++++++++ .../airpods@3x.png | Bin 0 -> 1412 bytes .../Contents.json | 21 ++++++++++++ .../airpodspro.png | Bin 0 -> 1677 bytes .../Contents.json | 21 ++++++++++++ .../airpodspromax@3x.png | Bin 0 -> 2411 bytes .../Contents.json | 21 ++++++++++++ .../bluetooth@3x.png | Bin 0 -> 1216 bytes .../Contents.json | 21 ++++++++++++ .../CallCameraButtonNew.imageset/video@3x.png | Bin 0 -> 1079 bytes .../Contents.json | 2 +- .../end@3x.png} | Bin .../CallMuteButtonNew.imageset/Contents.json | 21 ++++++++++++ .../CallMuteButtonNew.imageset/mute@3x.png | Bin 0 -> 1387 bytes .../Contents.json | 21 ++++++++++++ .../speaker@3x.png | Bin 0 -> 1725 bytes .../Contents.json | 21 ++++++++++++ .../flip@3x.png | Bin 0 -> 1749 bytes 20 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallAirpodsButtonNew.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallAirpodsButtonNew.imageset/airpods@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallAirpodsProButtonNew.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallAirpodsProButtonNew.imageset/airpodspro.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallAirpodsProMaxButtonNew.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallAirpodsProMaxButtonNew.imageset/airpodspromax@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallBluetoothButtonNew.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallBluetoothButtonNew.imageset/bluetooth@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallCameraButtonNew.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallCameraButtonNew.imageset/video@3x.png rename submodules/TelegramUI/Images.xcassets/Call/{CallEndButton.imageset => CallDeclineButtonNew.imageset}/Contents.json (89%) rename submodules/TelegramUI/Images.xcassets/Call/{CallEndButton.imageset/end.png => CallDeclineButtonNew.imageset/end@3x.png} (100%) create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallMuteButtonNew.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallMuteButtonNew.imageset/mute@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallSpeakerButtonNew.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallSpeakerButtonNew.imageset/speaker@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallSwitchCameraButtonNew.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/CallSwitchCameraButtonNew.imageset/flip@3x.png diff --git a/submodules/TelegramCallsUI/Sources/CallControllerButton.swift b/submodules/TelegramCallsUI/Sources/CallControllerButton.swift index c1c3689baae..3b549e115fc 100644 --- a/submodules/TelegramCallsUI/Sources/CallControllerButton.swift +++ b/submodules/TelegramCallsUI/Sources/CallControllerButton.swift @@ -73,13 +73,15 @@ final class CallControllerButtonItemNode: HighlightTrackingButtonNode { let textNode: ImmediateTextNode private let largeButtonSize: CGFloat + private let useNewIcons: Bool private var size: CGSize? private(set) var currentContent: Content? private(set) var currentText: String = "" - init(largeButtonSize: CGFloat = 72.0) { + init(largeButtonSize: CGFloat = 72.0, useNewIcons: Bool = false) { self.largeButtonSize = largeButtonSize + self.useNewIcons = useNewIcons self.wrapperNode = ASDisplayNode() self.contentContainer = ASDisplayNode() @@ -260,27 +262,36 @@ final class CallControllerButtonItemNode: HighlightTrackingButtonNode { case .cameraOff, .cameraOn: image = nil case .camera: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallCameraButton"), color: imageColor) + let bundleImageName = useNewIcons ? "Call/CallCameraButtonNew" : "Call/CallCameraButton" + image = generateTintedImage(image: UIImage(bundleImageName: bundleImageName), color: imageColor) case .mute: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallMuteButton"), color: imageColor) + let bundleImageName = useNewIcons ? "Call/CallMuteButtonNew" : "Call/CallMuteButton" + image = generateTintedImage(image: UIImage(bundleImageName: bundleImageName), color: imageColor) case .flipCamera: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallSwitchCameraButton"), color: imageColor) + let bundleImageName = useNewIcons ? "Call/CallSwitchCameraButtonNew" : "Call/CallSwitchCameraButton" + image = generateTintedImage(image: UIImage(bundleImageName: bundleImageName), color: imageColor) case .bluetooth: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallBluetoothButton"), color: imageColor) + let bundleImageName = useNewIcons ? "Call/CallBluetoothButtonNew" : "Call/CallBluetoothButton" + image = generateTintedImage(image: UIImage(bundleImageName: bundleImageName), color: imageColor) case .speaker: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), color: imageColor) + let bundleImageName = useNewIcons ? "Call/CallSpeakerButtonNew" : "Call/CallSpeakerButton" + image = generateTintedImage(image: UIImage(bundleImageName: bundleImageName), color: imageColor) case .airpods: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallAirpodsButton"), color: imageColor) + let bundleImageName = useNewIcons ? "Call/CallAirpodsButtonNew" : "Call/CallAirpodsButton" + image = generateTintedImage(image: UIImage(bundleImageName: bundleImageName), color: imageColor) case .airpodsPro: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallAirpodsProButton"), color: imageColor) + let bundleImageName = useNewIcons ? "Call/CallAirpodsProButtonNew" : "Call/CallAirpodsProButton" + image = generateTintedImage(image: UIImage(bundleImageName: bundleImageName), color: imageColor) case .airpodsMax: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallAirpodsMaxButton"), color: imageColor) + let bundleImageName = useNewIcons ? "Call/CallAirpodsProMaxButtonNew" : "Call/CallAirpodsMaxButton" + image = generateTintedImage(image: UIImage(bundleImageName: bundleImageName), color: imageColor) case .headphones: image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallHeadphonesButton"), color: imageColor) case .accept: image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallAcceptButton"), color: imageColor) case .end: - image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallEndButton"), color: imageColor) + let bundleImageName = useNewIcons ? "Call/CallDeclineButtonNew" : "Call/CallDeclineButton" + image = generateTintedImage(image: UIImage(bundleImageName: bundleImageName), color: imageColor) case .cancel: image = generateImage(CGSize(width: 28.0, height: 28.0), opaque: false, rotatedContext: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift index be3d21b42a3..09aff915fae 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerButtonsView.swift @@ -384,7 +384,7 @@ final class CallControllerButtonsView: UIView { if let current = self.buttonNodes[button.button.key] { buttonNode = current } else { - buttonNode = CallControllerButtonItemNode() + buttonNode = CallControllerButtonItemNode(largeButtonSize: buttonSize, useNewIcons: true) self.buttonNodes[button.button.key] = buttonNode self.addSubnode(buttonNode) buttonNode.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside) diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsButtonNew.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsButtonNew.imageset/Contents.json new file mode 100644 index 00000000000..4a8d4c87737 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsButtonNew.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "airpods@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsButtonNew.imageset/airpods@3x.png b/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsButtonNew.imageset/airpods@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..447362db47fdf653a05edf1446fb2a258f89e209 GIT binary patch literal 1412 zcmb_c`#;kQ82-xr+KI|>OUj9IS0 zEhs5$_5^qpd6Sf5VE*v{V03H?GN+4<1aF!$@!l?u0I^SBxGChqpzcrrXvkNUT#^R> zc^y}0s85oNh_?{SvHiJosk4<)Z)-}raRMq-=PJ*;c_wByYZ;fEV#%RRaF82A%3bPD zJr;K=Rs6o}1>OMm24^@V+F7am@|NGfS3a{`Y4gMnmmk&4U&%KRZGNj{^aA@!5+qAWJI;1_MIjcJuY;t863DgpF#7+X_JON z^E~!M7+BRzNQ_YE7M8RxlZ-D(u#a_VBadLA!_^&| zHrz}9K2hxUql(HcO$(VcD-`Z1I8&XV&#?|^5LG$g!J`Eg3&uh528_rD?^@OPlL%Pa z>QiO_xG+@xd2iGEvx1O%ev966Ha@(cs)rDiTsbhR`-Nx|R4nXpZ?N3h1$nRfz}5oD zVtVz&rn2is7vy5E#p4&sBU>2*NhyZ56y#$5$6Cr{6>sX>ywn( zW6U5ILHsXv^*yZVZyjUs>M3%gYEAZ>+EPzbM#a^V6T&DPQ$E@(f3BDrMdwa9#YZWw zh`cRGhHEKh{RSk)U)@9niVe0_*%(V%=fZQimg$*3xos=%1=GWo+G@q?$tU(h z>~77Oa6jry*;!)FTJ%3|l%~;ZMc^+@kGh8gNcxRD>iWnBBOR7aIXXhxY_oBbDYgzC zZwuWcLLuPUaq6>eb?X%RWp9>HLm35|wbkrLMWmAJulJ&%A)1ifyG~*EVT~`LyzI&D zG%0SXqownad)^c|ZsAqriBn8zf%WTEyThSWO2)yZ)@SW;SE6oq5(~qT%?$imN6CfDgwfkQ+RX{UHiAc!+7CqoIFE$;ky{4I<`~qxYr~u zG3lhzpjI#e!^WO_ejz!1QvTI2fN@)ND}K{{$RPR!fmZO6J#88{@g#>Buu-bZt^+6e zWnB0v(qc`*(umsL_WIM>-7|{Zo9t&7^uFS0po<{##$s;acix%g_M&50(G%SNsPIUwi}oh?aL9)y{Ehds6CYg*)9g3t{}2=Gix^~ z?P_(HHBQkNO}ik&S&jlkb9YhCNHxW}jcX@_~fhE}tJJ$(1w)B>FKwmX$OJdN*R z_aWtWy6!KB>B^iQR5C62_0;|UVFX~0B9Ay>6LpRWW*is_1!&nBDK{}W$SHrn6+}&T ztPcxvg-r~~Linya8@C;tM(DGIWOMuSG6i$G7N&HYMJ~ss4SJ@R^ z9Z$Js-6e)lk`D}Zs?PB=`?8K&QIKLEn&TfP&Hk(0Xzg#eqbzUbn6M1eKBp|}|L|Md c4F0(+*-r5+T|RV}jo#XGJ?-I4bPUe?4~K<~O8@`> literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsProButtonNew.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsProButtonNew.imageset/Contents.json new file mode 100644 index 00000000000..24645e5a98f --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsProButtonNew.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "airpodspro.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsProButtonNew.imageset/airpodspro.png b/submodules/TelegramUI/Images.xcassets/Call/CallAirpodsProButtonNew.imageset/airpodspro.png new file mode 100644 index 0000000000000000000000000000000000000000..42c5319e08393c89115c6b7ccef37338998c6f79 GIT binary patch literal 1677 zcma)7`#%#38(;1*-5f8uY;sP~QFg*X)8-v>OElYD3bEc(Gom>(_vWSCij~&9BGelu z$8anQ5vAt1%~r}bGr2U?*jzW)4*$XVem>vl`#jI*`+UAXKDj7g4_$2|Z2$nE>*a|= zZ!3L=S{mCm-n){yExU=H=i&f>LykM#X0i>5X_IW87MQqz2HUQf}4B==4<|>I(gN zA7i{!`#S&GjjM+yIE(SW$9|l$^!T<*c2P@UJ&l!sdLYb&JBE$G#{*lbq@%du;SoF*D5B1w2G3!#Bag4l$ z2kSpB>WwD1+JDqAnQVF>qkY8%8932iVGGiM)H%l_ukJ|br%|N>DC0eyVq9H}6 z`5N|Veqr)oMr_SR68_zz{wCxL4dGPtuqsprlwh~aE6vG%TeZ`EcicP%)jQLOp2ssk{s&_0-_=3s%|2#C zh{#qFJs~P*p-H#z^MlE!Cr^ zPNR4!MZ}RImH0_9?c+vw0Y-HxDELv}ClsMaeX(!-R*KB2|+H4RZWeR`b1>CPx5-zS>r~&$Ak#fQ`Ibtm%_EfY5<=T9EFeT7&|kWwXgHinq`DUn32Y=zo(Ry8nsZf*2b zRR~RB!B)K5M;F?)5dRnV=v3?N-*7o0s8rO0$9!it6DH%P+vk=1G~wi`=<3k9#xC4z zQU%qsrl=K?$wpG!z=W&7hxr74EN(f(51dniJL?Sp{=>g6pxTX^nDJJu&1Y9rL7u-~ ztP-)yLCOMt|D9pFw^;4m@?bN50{mz3C7JCM>($->Jnrqt-E7|A3Fo_qxNY6z$8={( zc(naR8cROcEa+&Hrx_LG+^%y+EiE;c+~V;md$;tzByTO@nDrkNnT0+$zuCD&1#d!jF!w%AvFy`V1h6Gp8{1Yqe_lOpTe zW922I%4<`C#JV}}dse@8@i)M}x}=w3xaWKFbcT3PbXl=tEc)FTl2EOckoB$NoqIl% z(iqhi6k~w9n{$&l04zGot|te4O$@?z1iw#IAWA}&GFQ#7MiFFEE%j5W&+3OhZ%ALM znBK-Cnqu)!_C9qoxo&m;&}&O^^6J$L5@zlS>pdwu?--QSn-B+)orrK)u^BcWaRAOC z^ev4eZ%)HEV^(kI%wDt=3t=3*y_suW!)SGL#>MN$+N1p?{l0<7R8rLFSbbMQah!!Jb8m;v7@!;(Rw`NxQ~$1_1#n3*l^FhC=D(j>s@P%NtzuIc`^CC|2;)J}?CD*U%sVVW4aZpJPv^ zgYpH;#Wt&aABu|uOX}Il+4t$s)(HGi|=d>o4hGBG;?wi)b7r5zQx9pS^ vFi^=|`o8|(KMQsjRew39zwB#3b&M!1W5t_04ZxL3wvQM z+>8AZ!aLNapjw!KK~^qU0N|wlUWmZb&)*P25v;wX8KAW9_?%FP`IufY1pq2u91z?T z7a9Q87N!mnB0R3iv*W)M-tog_&j7lQ997j$9zFEEi7{agjr01ryuPOB){NS!6?qZibN>72(;-Ax#37YtgR2L$fVHnQM@L5?g!-f$ zpth_gU;1%`m8W?Cyz9^m#g=nT=k8+k6%K=tVKpF;d%-xDucanmW%Jh?_Mm=#FiAbZ z$w^HIj>af5YH1Zmi*naoKYyLE#b!?oPNANwkqS1HZot=37pe?82h%OB-nA% zY@=nVPv*WU5*SnaQTSB~>}! z+A;5$a)XoC#+3AUb@=IKL8nfl##j2&exB)-bibO`M!Ue`x+{}s635zkC%!JYoJQ>a*=xRQ zA#rZXhnzzKw=y$R6{FApTZJCR54tk}{u{&n%?dilJ z^9I%Lk=$}6>w}Mq8_wuK=W|lDOT?rs7kpG(`Xev*sDyc%Z)P@}5AV32RCv&D@nASh zc8A}UN4LAP4?Y8yXMxdHeg5%EG&?Aj=h4D_oZF;4Fz`e=j#Ge_R z6(_+ys^B@TuQi{~50{xCq=fcI-+PgK+7*F6t*l!;CL7?i z&!T8i2{~EQ6?ODLIu&hv?t<>{ls1e-*fMeq?En?e#Of@)x<3>abXF*zZnVrG2@9bp zL7aMnGHjE@cmCC68H74A@9CJPvIW!lrs%jqn}9UH@kd?kn#3EdV1kL+|BYbJqM+fA z!jLm}h`XeXO4<75KtDgf?EL|G3m=S2f0lKbXA(NlN5)$Q1l+M+V-rW5-((eUxAI+K~646TYLbf?lzo zGS-7KBQ6i9ByYRRRq+`w)l3)Kw;fNkjmv&IG>Z6bKj|Hmao5+6yrwjGN_m;XEYmJLE#a(i{l>%B}Fj z(@=e$vYVsm?yb_mM{!u0#XBl%Bp$6dK}wNAe1go94}Fo>^fL~{Kzs1;e&1X3jx9g+ zdIuL9%v|nHjBV@DiJr0Ym~l-320|0GnNptdoA-uOw92I-EnczC_C1=4u~>spA8s$o zXR%@`|4b0m?6F}}Flmnu>?#JA_psJLqgVs+K`i01h(6i0@2jx+ca*J1BPzRPfo17mMIbV^ z*vsiz3pjD9?Tq~N4vcheK9k$w6%xetl=87h1g(pFNcNpY_wU!cEK zPbZ?*uSJ)v13?f`D{e;YrG^FXG2iEO`{~c|j4Ot=)xzCbm)(}$%b-?kc-sBgN>)m1 zcHP^Aqp4h5^VFamhfU`m(Kn@0Lw8A4G%rkEB^_tyIkJ+ZKjEviI`?6^ch{;3Pg`)EAt{1@h;WNlyFq zM3U#2@1TDH@((C6?BZB3+#kxm+-ul6l7SqHr`wCx0!cQ)f{7WoJKUYivvBp5_zawH z78XS)Y3f*F$IZPl&^mCwPRP{mkLe=GRJy!>L8t*4v0Svr5W2wmf;~g#YxP3@z}-CB zu&EP}y}mcK6jG9N`?L|};-C}B?kmns}(%@=h)>i z7GySs4X17A-OPBv?7bl*2G_1u&C-WoSUWFqm~qgpLqxSQ#ESQ z8NLq!G)BC$a}HoGvM4T}*@q<=ZfV*N0yRdYatYT{o~h!P_dZf^Bv^D41H%Xe@AL&) zgZwneX(C4~2ogp9&;@AA>1myD&(q27@S-dQ)Y?$m7q#k_Yh84NQod%ke=`g1g0YmJ zA1qo+BI80E)3#I6(T!3oH{S?;XS8bZH(gxktWELSBGQUHU2oWG;0yVzFIGuq>EM4} zDll6EKPP@6^97v*fev`?u2ONT(Y{0)8{0?;Hj_YK-Od?$8WM6Y`b7+Q8b4zZ1r}5} nEPq{&Ach|J?~jFW;vT6CfY8EKzx(jZr7!S literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallBluetoothButtonNew.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/CallBluetoothButtonNew.imageset/Contents.json new file mode 100644 index 00000000000..956585fdbdb --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/CallBluetoothButtonNew.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bluetooth@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallBluetoothButtonNew.imageset/bluetooth@3x.png b/submodules/TelegramUI/Images.xcassets/Call/CallBluetoothButtonNew.imageset/bluetooth@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..d7e4e6a0590ec7a409ee26ec8cf1b2fd5133be10 GIT binary patch literal 1216 zcmaiz`&ZHj7{W%_l8%FEXsi0IyGJL7|z z^eP*SI(zy~{&;dd`ALPa&G6JR5bMt_-i0&WdOB&Ay1{J+J~v=rs8;f*9W9RL@)HxM z8QMBP*11(RUSD8e`kGg=G`rcT!Cv=M%}krRJs)lqqI>2}e%gaF_er~W%@Z;F+D3m& z52s{-gLqOGQ=PnNEG|N!Z&1}Q>S8q<3hMajAk|6_{6}mP$^#fO1Owb~G*T#Pd@_i2IKzibtUODKbzOlZ4EtX6d|74h!rd19N3 z5ex2=IlK7i%N)<@;28HY%g>a6RduSF8?NMp5OmwGe0vd7>T-?eli_|U0a16@?e^>5Mm4sZ$xPPF6FU#6;88(E#ALR#o} ztLXBZB;DzOIV9BZHcx3*NG8LQTXOn2Uuut&w9J52DL(oFXVc^qHY}OzB@{OmmFkN^ zl!A9unG;QAD7dqaltNWZY3FH@=#s(wu4arQApO3Q!qQ!nt>53IU}Un-r)_6qf~g2~ z7@H7j?U>?!a8sj&uDl9F_O@QrGfU5kJ$(6^n|Hx3y@Uvo$XOo_mcRN4w7La|0`5d% ztIA`Ka0UxB$Q+7remFw&7#rm`dLdjFNc~}p2+@|%k^ee&9R}m;D(qFpTgvI&h#R#; zwTo#p?zS^-1sa-n-# za%6&mKwK#Y!ZM*l5DKBq*|iN6Jj!Ko*JeOP-05!^djq_jtvou-iua7^K68a_oa1#c zj-^1O=Nw~ap;L;|D0=;Lo?@W}A9*rpDF+eh4GK~n&gR3Bo--UT-(o%EDoyYn__wy| z2C5^c|ERkbh{?hkEi!3gLHM=Zsl6a~`UEjhCRpK_`Bh%ZW4_YF(oASJYHlDODRYos6+= za4f4Z49(JufI}=iaUi#DsGq>3@qB0Mn;RO3iOkkuc`CQ71o{oX&8{_@EMYzXS`2ah z%-9i)8P_40yZ=YCwDjATcGx$Og_1+0 HczXK(XxT!+ literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallCameraButtonNew.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/CallCameraButtonNew.imageset/Contents.json new file mode 100644 index 00000000000..3e5ecf1dbff --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/CallCameraButtonNew.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "video@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallCameraButtonNew.imageset/video@3x.png b/submodules/TelegramUI/Images.xcassets/Call/CallCameraButtonNew.imageset/video@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..938c5b305e748fd6b7a60b98c871c7cb89e6f882 GIT binary patch literal 1079 zcmeAS@N?(olHy`uVBq!ia0vp^D?pfo4M>)FFWmy9I14-?iy0UcEkKyjb(&!UP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di49sUdT^vIy7~kG~UwFk$g8jkunKk{vj#Ic6 zv;;VMJy-#>BA9Up(;F@)c16)6Aq_!}bGW9&svPDu+gJbSMy&1mGktG1zL|ab@1J#B zcHQm;T0j`AIOA!OCee2P(zC=Y{-D>($`*G&ezE-R*DIgCzYpGRWocO{p0l>IVOhYu zWoIv6nYXk|D)n{nZjZM{ty}qTO*{0{WNUrY=PAt((1P+dr;2 z?0S4!`;t0e#l3wqdFQ)Z?Rr^wu2<=dj_eb0=ild&(q87<$-R?%Ss~cEdTmo(N7?G< zy{|)V*7eQwQMB2eKHXjQzRikfN|_Sx1xhdOyZmyeT#kgh)j6{_-z>PoE{Fa(9MRW2 zFPkfkuhK3&K6hjF#p7R(uQ)!5>6gMclOAzr%Q?5@)-THEy&mFN-}&@K68~X~Qy-~^;%BnZe@LQc|)_!WY=KNS#}ADtd0sv_ms@f4;M}{NjSFV~(F!6zO*ibE`n_?Vx#$+ZO0_*Vmmk-h*bt^6x@Yy4 zN;&QY-TG7dYkIO)EvVIT-+$Kc@*J=JMGun~cdx&5<*vDO5Tov8KXHf4HuHVkLd4^j z&0T*Xnm5yz@vr){#r?KxTEAqq$G={jV9HuDy*Y08_l?W9PS|(*Q+Su0zvu-c*)K7l zWxlU`I$>TuFl23mO&RC5g-k2>zGQy3*Sjy}pDs<%H-GcZj4SMR=pQrDg=NzX7mHll zERo;)t^CiA)L%bcMb>O{75OAP*=Dud>d%5-4fV1v96z({Wl@pqU(K!mgQ}xHJ>oRa ziaDVz|0FCtwK}NE)q2}2wpYui-iUhh>U`_xZLhRm^oDrqFZ!SwQdThY&`z$rmEXdi ze`(ql;Ca4vb7JMT?KZLx1Lp5BdARvO+|{y=uiHLym5L@$zwdg}PVdRATd(ssX5?hg z;&`zuu|_!m-yXj>p4Huj9Q)!t(9x{(`&r z2_EE4@>BwAZ|?Zx<@KA`5>4=p1Azb+mDG;hb_m`y)#Au_EJ)C2CfhtT!`)B1gFvrTOt)B8sjB~ksyXpz0uP?KEUq0zk=NeG_5#zk`ejjsgzPuZu0863rdsa5oaWq+Uj40zg7`z%4#nvQ=hs9qshBk(|>o^!D?txx)ioIec87EeY?4<>3jhF1tLe3h$`ng%Xg49Sc013XYW*REVuSqCy?Q zfy>Sw+ub^x{v>rEsvx5k-5l9N4BVkC*=5|GqPQOKEQfZ|I{ONp#YJ2`aW7h$dEy&K zjuAH5e8)}^1Q3pvY4`!$B!9Zwp3DP5n)U7kg@;=Zz*PdAaBxH*cv36#fQ6t*$tR{xyUmI(4CNrxQ15 z`=W1({Hn?)jaty7Vp_^|yYyrmTUM4O0YNn+YaQ|6`E_c<1srlt1Y9{5(67+%U;86p zr~4;eQN@RzZqE>0smtv4eAy(T)gi$6Rc;G^+M*s|QQZB*kIb)Gzjjx@{Xlu=;KLX* z?ZL2?FxlfDVS^~Hes)*gQX$4faLO{c=Vgm`$m?Yw&wj@8s-guMusf?*Ny+UZM)2{v zmF>qJ1<=9c>tQXgq`vH08&>P$iGvD!-`|+x2Qz+$U!R#bn$(7>VpP3Bud#%DznZy< z|c9jsGV zZkxx7^@KcalNhX;cm95JBUDm>7h%3+s-PkieQ$K>%`k(F>gI(m*}54H_{43v@=_XR zhA=5De9WUSfh!Mfp0sIZk%nDXl6v|_?ER8^&Bid592 zR`0Aj@1l#$BZLGU8b#|lZ=p?*rRVGq*gfaF-?{hPd%t_mJ?Ez5+?|zR8ZZC=lw7cm zo-(fZh)*FhALCl~R7TJ!tS+dBuTNUeU-CW}kO&%p3A!lA}|u$rJgJhw#Y+xrmfT6*Tk6qWraQqAY1o zX#!|F;|nTr#`<0o1_+iuw<=o0>IA2I0AB&g1ew79vA#y*s@&3|4*^D!BEiVWNKcaY zc4vI0QJXOfOg*(KCdwaQT9(^jCKwSN+D)p4<~y1u>aW6tLkfXoQfo-~(CoT7L})Bz zsMtyP($-U~Ty;CiXj1;0kZli<8t%N#&vZBA9q0vVgfEq(d^f$}o&R1#f;gx)42H%WcxuJgfOm7T z4?0+P<@VZ$j*k~!z$MhJm^{&3kNzwKN* zK?L79{_7wNb*oC-b+_(j;a6PobHPHLt?S3$cdS7&vvd^Dm z=rI+ZSxwv;8`wCC-c{wgk`9`F;LIe%+*M(jL=t{|eY(;po3@6!`^R&AkmZZfTUH_q z?)BSiTk&TCLddNcsYpX`C5iN;L^PJLyI*^sA}QloSa5%OioT>Y?i=*-lh!+D3uo)A zIXxC4UuLwbb5ic~nctWr$LD{qmIwKDw^8hp0~U!_$*m6_KSwEpZqP>hqx*}u zc%e*+aAQvqQ?Pm!PX?6aWJw(IUv0vWHw z+Z?cC-;iAuS#b8X{XYG`UEN1- zJ{FOSnI^e5WCOvE7M&H{+48f?%Kn*F{?EYgVqXU=qZr1S@<(+2)k?k7yQqUX<0cbW zz_>*ZTYWfH$3ft$hu4C3eBzlRJb^p#t*Fuk`J{Cryotv!4gDyu1*%Eg--oL|TxKh{ zvhL`D^f6mHT>qy(m9M`eE}^_Cb|%N&lP8Mf&zG2cr*(O7T=P>k`fM!m46O#zF>KZe z)4fNF^XcWbNL#O)t4b?^rm0=A9BdT^nI|1Xe_Z?k<+?mdXk|HM`k7WTE8a2+E~iGk zZ39u{!})ZfQAXG7L*c`;l6m;|cbi!m#p{e0Oi+CcX^GyxuIbO`<%N!`?>gG}1$LU= zODzv6ZY+}%>s#|ZuKaxC8wt~NrQ$K7{Q|5c%vn4C(za)$0cbr{z0>8nN^Izjb6eSo z$R|Tv90O^?Z>4Dl9b?D0V`j&onzLpZfIo6=8txFqhRQ8{c=A9eU))B-Q3jX(Ot73f pZ;C>x@3c$_Um*X_UzgS7?wCi-Aub(#)uQmR!^O$nvBo|y`Cl}lDwY5M literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallSwitchCameraButtonNew.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/CallSwitchCameraButtonNew.imageset/Contents.json new file mode 100644 index 00000000000..0bafea41c72 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/CallSwitchCameraButtonNew.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "flip@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/CallSwitchCameraButtonNew.imageset/flip@3x.png b/submodules/TelegramUI/Images.xcassets/Call/CallSwitchCameraButtonNew.imageset/flip@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b380cd42a299a7be6416fe8aabb87ae92178b3e2 GIT binary patch literal 1749 zcmb7E2{#)E7fozUm`HWgqPC%iQS+%Sv`SP+Mk(6HQXx}IVyPhZZHl&9t#t@rN;_yn zZK+f=tyqeQswI|$7GavPhx98IOOQ{0!n||tyXU=g?mPFKd(L~{>f#8ISCIz*01yfE00>+AmM1&4C{M_#b=heOqlQ251NY zP)-|DD*0hzwEsiFU(58B6%kV6VW#T}j?E`0*w`Q3|2O6QgADyz%t`g>y8*cfq~1td zj{ycMm*EI(2B&%5iKg;e=%ETPT|jx8T6;tLAL%m+spa>ncRFAYD3ssj_0w9FloaP0 zLeSd%mU8HdRx233qvI%qroB0C^Dej!9{V#)2!L2FppYj(ReZLqf0sVu3y`Ux!IbT| zy!UuX$)YreqF7_BXP_er9TZy-*QWeJa(g&9a>{LV8V(V??6RTeSs60DE%5`*p6~T( z271fRFXRo*#Y;y$KKBfTw9xW&>UAXnRnkYL*6l$3m?ACAJWy7LarEiCUI!Tyxm@A#RLzIPWY`gQMQXenoRqP>??c$ov;gy_C* zdP;}as+$`987d~cvMmdiFzd9d=UVeQal8TajW*}T-A)$kQsU7zW?|}aeu==LU@yt( z_lv7mvGt8P3EJ&!Nc>b~PDuUIv=zH6h4!rqyvoLBCcOf){dr9JXmo3k;EsO_MPxtp zNc2x~pS73fK=kh>ydzr^SM%h_!$M3Lqqx~TnlTPhHN``%mH8)QhVCX*J{MlBYaE;Z zz;@?PY(@=i)CKaqq=$*83j=!;BxBYug6E}7IpcsWzeJ0~-ub|V;|DhOf#0L)*hv`1s@beyb^$2|u zBKGNeN|0z?LOlZ9dY`253PFdMuHq}qvvtDOm?Vs#<%y8w>+e?_74RM|4uo$8kLFdg z(lUB?&GfTUvbOG7$q&6SJQMaYQC#0uaMo%vPCJYB-|L$r+HBx*VrSH$@J^hG_fD#c z6jnR(rm-4q-fA%<;$rO+K@D{6)e5zjttsn_qjk|+$Qy0MpE3XRSC4_o!=`7Fd}f^U zLMr|?O%G~HNg~wN^Uj_>MPOt$p3%WOOj;CL2bY#i6-5qg^pHznu`BiQgs@H;gzD=t z6USSf8*JRjnD7=so!TYIll%56wX%m!>qK)b3ahvDb%k>03Sp;asoZ?E?5>;{k}q)x zG<46ZMx*{QYBr&-3~lVy{W_-70n^~|S}j>S48AU9iT_3kPsxil(HN~!#X0=}uWOrJ z(kIUTxu1erXWlBp{Av}5)F25uxWmSuG&Dv}NSV@vWy((HYkaugd-~0KO*7lS4SX2< zl8%#Ev|AlB;l2oO4yrB`6&`uG5u?XyJ$m6sTjj8B{Ea-9xeW?U3)W@r8B=+9t7>nH zG55qhJZ0T2Ni%0Si@F*6a;^iNU4P37twt5}Kc(uv{bWw`^*_xhZnavfCR8s-gl=0? zv1Epi?0B!cfo=oGFm5-YkFGaz^dJ(ls9pN22dclJd~Z2`c%OpTxfNUf_35)b(%qSa znn=yR?OvV$zQrgN$SyImZMlr>$^miH&b;eRaYgQHvuWw3V>S*)q`Bt1WcK9LgVyGq zQ+>ubzM$W29Jrj)N$XK6N)Yg(I4=wp*Aff<%CsgJeuDc}@i#SpxtiraEzP-@%ns#b z@&(w2GyBRtOy#e+=^UT-9?Meh_N5#wI_b(-c#;sISicntHxR}}4r(2uPmOxF%~q@$ z1a`p{eHmD z-mIu$VFY2!NmiS}_TGxfPVvT@HM*{9Tk=$#BCP_X{4L?K-4 J8|<*@{|7d9K=l9s literal 0 HcmV?d00001 From 13c6493f1ba3d7b26d582b7059ab6be0023a49d3 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Mar 2023 10:04:55 +0300 Subject: [PATCH 07/11] TELEGRAM-[implemented status view animation] --- .../CallControllerStatusView.swift | 6 +-- .../CallControllerView.swift | 43 ++++++++----------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerStatusView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerStatusView.swift index 929a6e339ba..e0f347ec278 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerStatusView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerStatusView.swift @@ -190,12 +190,12 @@ final class CallControllerStatusView: UIView { self.titleNode.frame = CGRect(origin: CGPoint(x: floor((constrainedWidth - titleLayout.size.width) / 2.0), y: 0.0), size: titleLayout.size) self.statusContainerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: titleLayout.size.height + spacing), size: CGSize(width: constrainedWidth, height: statusLayout.size.height)) - self.statusNode.frame = CGRect(origin: CGPoint(x: floor((constrainedWidth - statusMeasureLayout.size.width) / 2.0) + statusOffset, y: 0.0), size: statusLayout.size) - self.receptionNode.frame = CGRect(origin: CGPoint(x: self.statusNode.frame.minX - receptionNodeSize.width, y: 9.0), size: receptionNodeSize) + self.statusNode.frame = CGRect(origin: CGPoint(x: floor((constrainedWidth - statusMeasureLayout.size.width) / 2.0) + statusOffset, y: -5.0), size: statusLayout.size) + self.receptionNode.frame = CGRect(origin: CGPoint(x: self.statusNode.frame.minX - receptionNodeSize.width, y: 4.0), size: receptionNodeSize) self.logoNode.isHidden = !statusDisplayLogo if let image = self.logoNode.image, let firstLineRect = statusMeasureLayout.linesRects().first { let firstLineOffset = floor((statusMeasureLayout.size.width - firstLineRect.width) / 2.0) - self.logoNode.frame = CGRect(origin: CGPoint(x: self.statusNode.frame.minX + firstLineOffset - image.size.width - 7.0, y: 5.0), size: image.size) + self.logoNode.frame = CGRect(origin: CGPoint(x: self.statusNode.frame.minX + firstLineOffset - image.size.width - 7.0, y: 0.0), size: image.size) } self.titleActivateAreaNode.frame = self.titleNode.frame diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift index 65f8ef127d6..81318c516a7 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -1061,33 +1061,15 @@ final class CallControllerView: ViewControllerTracingNodeView { 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.backButtonArrowNode, frame: CGRect(origin: CGPoint(x: 10.0, y: topOriginY + 25.0), size: image.size)) } - transition.updateFrame(node: self.backButtonNode, frame: CGRect(origin: CGPoint(x: 29.0, y: topOriginY + 11.0), size: backSize)) + transition.updateFrame(node: self.backButtonNode, frame: CGRect(origin: CGPoint(x: 29.0, y: topOriginY + 25.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 topOffset: CGFloat = layout.safeInsets.top - // TODO: implement - some magic here -// 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 -// } -// } - - topOffset += 174 + var topOffset: CGFloat = layout.safeInsets.top + 174 let avatarFrame = CGRect(origin: CGPoint(x: (layout.size.width - avatarNode.bounds.width) / 2.0, y: topOffset), size: self.avatarNode.bounds.size) @@ -1095,9 +1077,20 @@ final class CallControllerView: ViewControllerTracingNodeView { transition.updateFrame(view: self.audioLevelView, frame: avatarFrame) topOffset += self.avatarNode.bounds.size.height + 40 - + let statusHeight = self.statusNode.updateLayout(constrainedWidth: layout.size.width, transition: transition) - transition.updateFrame(view: self.statusNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topOffset), size: CGSize(width: layout.size.width, height: statusHeight))) + let statusFrame: CGRect + if hasVideoNodes { + let statusDefaultOriginY = layout.safeInsets.top + 45 + let statusCollapsedOriginY: CGFloat = -20 + let statusOriginY = interpolate(from: statusCollapsedOriginY, to: statusDefaultOriginY, value: uiDisplayTransition) + statusFrame = CGRect(origin: CGPoint(x: 0.0, y: statusOriginY), + size: CGSize(width: layout.size.width, height: statusHeight)) + } else { + statusFrame = CGRect(origin: CGPoint(x: 0.0, y: topOffset), + size: CGSize(width: layout.size.width, height: statusHeight)) + } + transition.updateFrame(view: self.statusNode, frame: statusFrame) transition.updateAlpha(view: 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))) @@ -1198,7 +1191,7 @@ final class CallControllerView: ViewControllerTracingNodeView { } 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.updateFrame(node: self.keyButtonNode, frame: CGRect(origin: CGPoint(x: layout.size.width - keyTextSize.width - 10.0, y: topOriginY + 21.0), size: keyTextSize)) transition.updateAlpha(node: self.keyButtonNode, alpha: overlayAlpha) if let debugNode = self.debugNode { @@ -1637,7 +1630,6 @@ private extension CallControllerView { self.dimNode.image = image } } - self.statusNode.setVisible(visible || self.keyPreviewNode != nil, transition: transition) } private func maybeScheduleUIHidingForActiveVideoCall() { @@ -1645,6 +1637,7 @@ private extension CallControllerView { return } + // TODO: implement let timer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in if let strongSelf = self { var updated = false From df48945c1b5f9ff60f55584d6c0a2b96fbb6ddff Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Mar 2023 18:52:08 +0300 Subject: [PATCH 08/11] TELEGRAM-[implemented outgoing video flow] --- .../CallControllerView.swift | 435 +++++++++++------- 1 file changed, 273 insertions(+), 162 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift index 81318c516a7..f45318202d7 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -5,6 +5,7 @@ import UIKit import Display import Postbox import TelegramCore +import SolidRoundedButtonNode import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences @@ -34,6 +35,7 @@ final class CallControllerView: ViewControllerTracingNodeView { case active case weakSignal case video + case videoPreview } private enum PictureInPictureGestureState { @@ -86,9 +88,15 @@ final class CallControllerView: ViewControllerTracingNodeView { private var candidateIncomingVideoNodeValue: CallVideoView? private var incomingVideoNodeValue: CallVideoView? + private var outgoingVideoView: CallVideoView? + private var outgoingVideoPreviewContainer: UIView? + private var removedOutgoingVideoPreviewContainer: UIView? + private var candidateOutgoingVideoPreviewView: CallVideoView? + private var outgoingVideoPreviewView: CallVideoView? + private var cancelOutgoingVideoPreviewButtonNode: HighlightableButtonNode? + private var doneOutgoingVideoPreviewButton: SolidRoundedButtonNode? + private var incomingVideoViewRequested: Bool = false - private var candidateOutgoingVideoNodeValue: CallVideoView? - private var outgoingVideoNodeValue: CallVideoView? private var outgoingVideoViewRequested: Bool = false private var removedMinimizedVideoNodeValue: CallVideoView? @@ -96,6 +104,7 @@ final class CallControllerView: ViewControllerTracingNodeView { private var isRequestingVideo: Bool = false private var animateRequestedVideoOnce: Bool = false + private var animateOutgoingVideoPreviewViewOnce: Bool = false private var hiddenUIForActiveVideoCallOnce: Bool = false private var hideUIForActiveVideoCallTimer: SwiftSignalKit.Timer? @@ -318,7 +327,7 @@ final class CallControllerView: ViewControllerTracingNodeView { if isScreencastActive { (strongSelf.call as! PresentationCallImpl).disableScreencast() - } else if strongSelf.outgoingVideoNodeValue == nil { + } else if strongSelf.outgoingVideoView == nil && strongSelf.outgoingVideoPreviewContainer == nil { DeviceAccess.authorizeAccess(to: .camera(.videoCall), onlyCheck: true, presentationData: strongSelf.presentationData, present: { [weak self] c, a in if let strongSelf = self { strongSelf.present?(c) @@ -329,57 +338,129 @@ final class CallControllerView: ViewControllerTracingNodeView { 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 + let delayUntilInitialized = strongSelf.isRequestingVideo + strongSelf.call.makeOutgoingVideoView(completion: { [weak self] (presentationCallVideoView) 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 = CallVideoView(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 { + + if let presentationCallVideoViewActual = presentationCallVideoView { + presentationCallVideoViewActual.view.backgroundColor = .black + presentationCallVideoViewActual.view.clipsToBounds = true + + let applyNode: () -> Void = { + guard let strongSelf = self, + let outgoingVideoPreviewViewActual = strongSelf.candidateOutgoingVideoPreviewView else { return } - updateLayoutImpl?(layout, navigationBarHeight) - }, isFlippedUpdated: { _ in - guard let strongSelf = self, let (layout, navigationBarHeight) = strongSelf.validLayout else { - return + let outgoingVideoPreviewContainer = UIView() + strongSelf.outgoingVideoPreviewContainer = outgoingVideoPreviewContainer + strongSelf.contentContainerView.addSubview(outgoingVideoPreviewContainer) + + strongSelf.candidateOutgoingVideoPreviewView = nil + strongSelf.animateOutgoingVideoPreviewViewOnce = true + strongSelf.outgoingVideoPreviewView = outgoingVideoPreviewViewActual + outgoingVideoPreviewContainer.addSubview(outgoingVideoPreviewViewActual) + + let cancelOutgoingVideoPreviewButtonNode = HighlightableButtonNode() + strongSelf.cancelOutgoingVideoPreviewButtonNode = cancelOutgoingVideoPreviewButtonNode + cancelOutgoingVideoPreviewButtonNode.setTitle(presentationData.strings.Common_Cancel, with: Font.regular(17.0), with: .white, for: []) + cancelOutgoingVideoPreviewButtonNode.accessibilityLabel = presentationData.strings.Call_VoiceOver_Minimize + cancelOutgoingVideoPreviewButtonNode.accessibilityTraits = [.button] + cancelOutgoingVideoPreviewButtonNode.hitTestSlop = UIEdgeInsets(top: -8.0, left: -20.0, bottom: -8.0, right: -8.0) + cancelOutgoingVideoPreviewButtonNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self, let buttonNode = strongSelf.cancelOutgoingVideoPreviewButtonNode { + if highlighted { + buttonNode.layer.removeAnimation(forKey: "opacity") + buttonNode.alpha = 0.4 + } else { + buttonNode.alpha = 1.0 + buttonNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } } - updateLayoutImpl?(layout, navigationBarHeight) - }) - - let controller = VoiceChatCameraPreviewViewController(sharedContext: strongSelf.sharedContext, cameraNode: outgoingVideoNode, shareCamera: { _, _ in - proceed() - }, switchCamera: { [weak self] in - Queue.mainQueue().after(0.1) { - self?.call.switchVideoCamera() + cancelOutgoingVideoPreviewButtonNode.addTarget(self, + action: #selector(strongSelf.cancelOutgoingVideoPreviewPressed), + forControlEvents: .touchUpInside) + outgoingVideoPreviewContainer.addSubnode(cancelOutgoingVideoPreviewButtonNode) + + let theme = SolidRoundedButtonTheme(backgroundColor: UIColor(rgb: 0xffffff), + foregroundColor: UIColor(rgb: 0x4f5352)) + let doneOutgoingVideoPreviewButton = SolidRoundedButtonNode(theme: theme, + font: .bold, + height: 48.0, + cornerRadius: 24.0, + gloss: false) + strongSelf.doneOutgoingVideoPreviewButton = doneOutgoingVideoPreviewButton + doneOutgoingVideoPreviewButton.title = presentationData.strings.VoiceChat_VideoPreviewContinue + outgoingVideoPreviewContainer.addSubnode(doneOutgoingVideoPreviewButton) + doneOutgoingVideoPreviewButton.pressed = { [weak self] in + guard let strongSelf = self, let outgoingVideoPreviewView = strongSelf.outgoingVideoPreviewView else { + return + } + strongSelf.outgoingVideoPreviewContainer?.removeFromSuperview() + strongSelf.outgoingVideoPreviewContainer = nil + strongSelf.outgoingVideoView = outgoingVideoPreviewView + if let expandedVideoNode = strongSelf.expandedVideoNode { + strongSelf.minimizedVideoNode = outgoingVideoPreviewView + strongSelf.videoContainerNode.contentView.insertSubview(outgoingVideoPreviewView, aboveSubview: expandedVideoNode) + } else { + strongSelf.expandedVideoNode = outgoingVideoPreviewView + strongSelf.videoContainerNode.contentView.addSubview(outgoingVideoPreviewView) + } + strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) + + strongSelf.updateDimVisibility() + strongSelf.maybeScheduleUIHidingForActiveVideoCall() + + if strongSelf.hasVideoNodes { + strongSelf.setUIState(.video) + } + + strongSelf.displayedCameraConfirmation = true + switch callState.videoState { + case .inactive: + strongSelf.isRequestingVideo = true + strongSelf.updateButtonsMode() + default: + break + } + strongSelf.call.requestVideo() } - }) - strongSelf.present?(controller) - - updateLayoutImpl = { [weak controller] layout, navigationBarHeight in - controller?.containerLayoutUpdated(layout, transition: .immediate) + + strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) + + strongSelf.setUIState(.videoPreview) + } + + let outgoingVideoPreviewView = CallVideoView( + videoView: presentationCallVideoViewActual, + 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 + } + }) + + strongSelf.candidateOutgoingVideoPreviewView = outgoingVideoPreviewView + strongSelf.setupAudioOutputs() + + if !delayUntilInitialized { + applyNode() } } }) @@ -398,11 +479,11 @@ final class CallControllerView: ViewControllerTracingNodeView { return } strongSelf.disableActionsUntilTimestamp = CACurrentMediaTime() + 1.0 - if let outgoingVideoNode = strongSelf.outgoingVideoNodeValue { + if let outgoingVideoNode = strongSelf.outgoingVideoView { outgoingVideoNode.flip(withBackground: outgoingVideoNode !== strongSelf.minimizedVideoNode) } strongSelf.call.switchVideoCamera() - if let _ = strongSelf.outgoingVideoNodeValue { + if let _ = strongSelf.outgoingVideoView { if let (layout, navigationBarHeight) = strongSelf.validLayout { strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) } @@ -645,11 +726,7 @@ final class CallControllerView: ViewControllerTracingNodeView { self.expandedVideoNode = minimizedVideoNode self.minimizedVideoNode = nil } - if hasVideoNodes { - setUIState(.video) - } else { - setUIState(.active) - } + setUIState(hasVideoNodes ? .video : .active) } self.incomingVideoNodeValue = nil self.incomingVideoViewRequested = false @@ -660,99 +737,19 @@ final class CallControllerView: ViewControllerTracingNodeView { 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.contentView.insertSubview(outgoingVideoNode, aboveSubview: expandedVideoNode) - } else { - strongSelf.expandedVideoNode = outgoingVideoNode - strongSelf.videoContainerNode.contentView.addSubview(outgoingVideoNode) - } - strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) - - strongSelf.updateDimVisibility() - strongSelf.maybeScheduleUIHidingForActiveVideoCall() - - if strongSelf.hasVideoNodes { - strongSelf.setUIState(.video) - } - } - - let outgoingVideoNode = CallVideoView(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() - } - } - }) + self.isRequestingVideo = false + self.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) } + default: - self.candidateOutgoingVideoNodeValue = nil - if let outgoingVideoNodeValue = self.outgoingVideoNodeValue { - if self.minimizedVideoNode == outgoingVideoNodeValue { + if let outgoingVideoView = self.outgoingVideoView { + if self.minimizedVideoNode == outgoingVideoView { self.minimizedVideoNode = nil - self.removedMinimizedVideoNodeValue = outgoingVideoNodeValue + self.removedMinimizedVideoNodeValue = outgoingVideoView } - if self.expandedVideoNode == self.outgoingVideoNodeValue { + if self.expandedVideoNode == self.outgoingVideoView { self.expandedVideoNode = nil - self.removedExpandedVideoNodeValue = outgoingVideoNodeValue + self.removedExpandedVideoNodeValue = outgoingVideoView if let minimizedVideoNode = self.minimizedVideoNode { self.expandedVideoNode = minimizedVideoNode @@ -764,7 +761,7 @@ final class CallControllerView: ViewControllerTracingNodeView { setUIState(.active) } } - self.outgoingVideoNodeValue = nil + self.outgoingVideoView = nil self.outgoingVideoViewRequested = false } } @@ -1112,6 +1109,62 @@ final class CallControllerView: ViewControllerTracingNodeView { removedMinimizedVideoNodeValue.removeFromSuperview() } } + + if let container = outgoingVideoPreviewContainer { + transition.updateFrame(view: container, frame: CGRect(origin: CGPoint(), size: layout.size)) + + if let cancelOutgoingVideoPreviewButtonNodeActual = cancelOutgoingVideoPreviewButtonNode { + let transition: ContainedViewLayoutTransition = .immediate + let size = cancelOutgoingVideoPreviewButtonNodeActual.measure(CGSize(width: 320.0, height: 100.0)) + transition.updateAlpha(node: cancelOutgoingVideoPreviewButtonNodeActual, alpha: 1.0) + transition.updateFrame(node: cancelOutgoingVideoPreviewButtonNodeActual, + frame: CGRect(origin: CGPoint(x: 29.0, y: topOriginY + 25.0), size: size)) + } + + if let doneOutgoingVideoPreviewButtonActual = doneOutgoingVideoPreviewButton { + let transition: ContainedViewLayoutTransition = .immediate + let buttonInset: CGFloat = 16.0 + let buttonMaxWidth: CGFloat = 360.0 + let buttonWidth = min(buttonMaxWidth, layout.size.width - buttonInset * 2.0) + let doneButtonHeight = doneOutgoingVideoPreviewButtonActual.updateLayout(width: buttonWidth, transition: transition) + transition.updateFrame(node: doneOutgoingVideoPreviewButtonActual, frame: CGRect(x: floorToScreenPixels((layout.size.width - buttonWidth) / 2.0), y: layout.size.height - doneButtonHeight - buttonInset, width: buttonWidth, height: doneButtonHeight)) + } + + if let outgoingVideoPreviewViewActual = outgoingVideoPreviewView { + var outgoingVideoPreviewVideoTransition: ContainedViewLayoutTransition = transition + if outgoingVideoPreviewViewActual.frame.isEmpty { + outgoingVideoPreviewVideoTransition = .immediate + } + outgoingVideoPreviewVideoTransition.updateAlpha(view: outgoingVideoPreviewViewActual, alpha: 1.0) + outgoingVideoPreviewVideoTransition.updateFrame(view: outgoingVideoPreviewViewActual, frame: fullscreenVideoFrame) + outgoingVideoPreviewViewActual.updateLayout(size: outgoingVideoPreviewViewActual.frame.size, + cornerRadius: 0.0, + isOutgoing: true, + deviceOrientation: mappedDeviceOrientation, + isCompactLayout: isCompactLayout, + transition: outgoingVideoPreviewVideoTransition) + } + if animateOutgoingVideoPreviewViewOnce { + animateOutgoingVideoPreviewViewOnce = false + let videoButtonFrame = self.buttonsView.videoButtonFrame().flatMap { frame -> CGRect in + return self.buttonsView.convert(frame, to: self) + } + if let previousVideoButtonFrame = previousVideoButtonFrame, let videoButtonFrame = videoButtonFrame { + animateRadialMask(view: container, from: previousVideoButtonFrame, to: videoButtonFrame) + } + } + } else if let removedOutgoingVideoPreviewContainerActual = self.removedOutgoingVideoPreviewContainer { + self.removedOutgoingVideoPreviewContainer = nil + + if transition.isAnimated { + removedOutgoingVideoPreviewContainerActual.layer.animateScale(from: 1.0, to: 0.1, duration: 0.3, removeOnCompletion: false) + removedOutgoingVideoPreviewContainerActual.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak removedOutgoingVideoPreviewContainerActual] _ in + removedOutgoingVideoPreviewContainerActual?.removeFromSuperview() + }) + } else { + removedOutgoingVideoPreviewContainerActual.removeFromSuperview() + } + } if let expandedVideoNode = self.expandedVideoNode { transition.updateAlpha(view: expandedVideoNode, alpha: 1.0) @@ -1131,11 +1184,11 @@ final class CallControllerView: ViewControllerTracingNodeView { expandedVideoTransition.updateFrame(view: expandedVideoNode, frame: fullscreenVideoFrame) } - expandedVideoNode.updateLayout(size: expandedVideoNode.frame.size, cornerRadius: 0.0, isOutgoing: expandedVideoNode === self.outgoingVideoNodeValue, deviceOrientation: mappedDeviceOrientation, isCompactLayout: isCompactLayout, transition: expandedVideoTransition) + expandedVideoNode.updateLayout(size: expandedVideoNode.frame.size, cornerRadius: 0.0, isOutgoing: expandedVideoNode === self.outgoingVideoView, deviceOrientation: mappedDeviceOrientation, isCompactLayout: isCompactLayout, transition: expandedVideoTransition) if self.animateRequestedVideoOnce { self.animateRequestedVideoOnce = false - if expandedVideoNode === self.outgoingVideoNodeValue { + if expandedVideoNode === self.outgoingVideoView { let videoButtonFrame = self.buttonsView.videoButtonFrame().flatMap { frame -> CGRect in return self.buttonsView.convert(frame, to: self) } @@ -1181,7 +1234,7 @@ final class CallControllerView: ViewControllerTracingNodeView { self.animationForExpandedVideoSnapshotView = nil } minimizedVideoTransition.updateFrame(view: 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) + minimizedVideoNode.updateLayout(size: previewVideoFrame.size, cornerRadius: interpolate(from: 14.0, to: 24.0, value: self.pictureInPictureTransitionFraction), isOutgoing: minimizedVideoNode === self.outgoingVideoView, 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) } @@ -1282,6 +1335,18 @@ private extension CallControllerView { } } + @objc func cancelOutgoingVideoPreviewPressed() { + guard let outgoingVideoPreviewContainerActual = outgoingVideoPreviewContainer else { + return + } + removedOutgoingVideoPreviewContainer = outgoingVideoPreviewContainerActual + outgoingVideoPreviewContainer = nil + if let (layout, navigationBarHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) + } + setUIState(hasVideoNodes ? .video : .active) + } + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { if !self.pictureInPictureTransitionFraction.isZero { @@ -1515,17 +1580,22 @@ private extension CallControllerView { private extension CallControllerView { 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) - } - } + guard self.outgoingVideoView != nil || + self.incomingVideoNodeValue != nil || + self.candidateIncomingVideoNodeValue != nil || + self.outgoingVideoPreviewView != nil || + self.candidateOutgoingVideoPreviewView != nil, + let audioOutputState = self.audioOutputState, + let currentOutput = audioOutputState.currentOutput else { + return + } + switch currentOutput { + case .headphones, .speaker: + break + case let .port(port) where port.type == .bluetooth || port.type == .wired: + break + default: + self.setCurrentAudioOutput?(.speaker) } } @@ -1538,7 +1608,8 @@ private extension CallControllerView { case .ringing: isNewStateAllowed = true case .active: isNewStateAllowed = true case .weakSignal: isNewStateAllowed = true - case .video: isNewStateAllowed = !hasVideoNodes + case .video: isNewStateAllowed = !hasVideoNodes || (state == .video && outgoingVideoView == nil) + case .videoPreview: isNewStateAllowed = outgoingVideoPreviewView == nil || state == .video case .none: isNewStateAllowed = true } guard isNewStateAllowed else { @@ -1569,6 +1640,10 @@ private extension CallControllerView { avatarNode.isHidden = true audioLevelView.isHidden = true audioLevelView.stopAnimating(duration: 0.5) + case .videoPreview: + avatarNode.isHidden = true + audioLevelView.isHidden = true + audioLevelView.stopAnimating(duration: 0.5) } } @@ -1612,7 +1687,7 @@ private extension CallControllerView { } var visible = true - if case .active = callState.state, self.incomingVideoNodeValue != nil || self.outgoingVideoNodeValue != nil { + if case .active = callState.state, self.incomingVideoNodeValue != nil || self.outgoingVideoView != nil { visible = false } @@ -1633,7 +1708,7 @@ private extension CallControllerView { } private func maybeScheduleUIHidingForActiveVideoCall() { - guard let callState = self.callState, case .active = callState.state, self.incomingVideoNodeValue != nil && self.outgoingVideoNodeValue != nil, !self.hiddenUIForActiveVideoCallOnce && self.keyPreviewNode == nil else { + guard let callState = self.callState, case .active = callState.state, self.incomingVideoNodeValue != nil && self.outgoingVideoView != nil, !self.hiddenUIForActiveVideoCallOnce && self.keyPreviewNode == nil else { return } @@ -1696,7 +1771,7 @@ private extension CallControllerView { 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) + var mappedVideoState = CallControllerButtonsMode.VideoState(isAvailable: false, isCameraActive: self.outgoingVideoView != nil, isScreencastActive: false, canChangeStatus: false, hasVideo: self.outgoingVideoView != nil || self.incomingVideoNodeValue != nil, isInitializingCamera: self.isRequestingVideo) switch callState.videoState { case .notAvailable: break @@ -1767,7 +1842,7 @@ private extension CallControllerView { if let minimizedVideoNode = self.minimizedVideoNode { var aspect = minimizedVideoNode.currentAspect var rotationCount = 0 - if minimizedVideoNode === self.outgoingVideoNodeValue { + if minimizedVideoNode === self.outgoingVideoView { aspect = 138.0 / 240.0 //3.0 / 4.0 } else { if aspect < 1.0 { @@ -1956,6 +2031,42 @@ private extension CallControllerView { return CACurrentMediaTime() < self.disableActionsUntilTimestamp } + private func animateRadialMask(view: UIView, 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 + + view.layer.mask = maskLayer + + let topLeft = CGPoint(x: 0.0, y: 0.0) + let topRight = CGPoint(x: view.bounds.width, y: 0.0) + let bottomLeft = CGPoint(x: 0.0, y: view.bounds.height) + let bottomRight = CGPoint(x: view.bounds.width, y: view.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 view] _ in + view?.layer.mask = nil + }) + } + } // MARK: - CallVideoView From 5737c0e92f6e3d1e1e7028ee83d29447a27debeb Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Mar 2023 21:25:57 +0300 Subject: [PATCH 09/11] TELEGRAM-[added preview icon and preview image to video preview] --- .../CallControllerView.swift | 124 ++- ...VoiceChatCameraPreviewViewController.swift | 8 +- .../VoiceChatTileItemView.swift | 877 ------------------ 3 files changed, 112 insertions(+), 897 deletions(-) delete mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatTileItemView.swift diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift index f45318202d7..71f027ba9ec 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -13,6 +13,7 @@ import TelegramAudio import AccountContext import LocalizedPeerData import PhotoResources +import ReplayKit import CallsEmoji import TooltipUI import AlertUI @@ -94,7 +95,12 @@ final class CallControllerView: ViewControllerTracingNodeView { private var candidateOutgoingVideoPreviewView: CallVideoView? private var outgoingVideoPreviewView: CallVideoView? private var cancelOutgoingVideoPreviewButtonNode: HighlightableButtonNode? - private var doneOutgoingVideoPreviewButton: SolidRoundedButtonNode? + private var outgoingVideoPreviewDoneButton: SolidRoundedButtonNode? + private var outgoingVideoPreviewWheelNode: WheelControlNodeNew? + private var outgoingVideoPreviewBroadcastPickerView: UIView? + + private var outgoingVideoPreviewPlaceholderTextNode: ImmediateTextNode? + private var outgoingVideoPreviewPlaceholderIconNode: ASImageNode? private var incomingVideoViewRequested: Bool = false private var outgoingVideoViewRequested: Bool = false @@ -104,7 +110,7 @@ final class CallControllerView: ViewControllerTracingNodeView { private var isRequestingVideo: Bool = false private var animateRequestedVideoOnce: Bool = false - private var animateOutgoingVideoPreviewViewOnce: Bool = false + private var animateOutgoingVideoPreviewContainerOnce: Bool = false private var hiddenUIForActiveVideoCallOnce: Bool = false private var hideUIForActiveVideoCallTimer: SwiftSignalKit.Timer? @@ -156,6 +162,7 @@ final class CallControllerView: ViewControllerTracingNodeView { private var deviceOrientation: UIDeviceOrientation = .portrait private var orientationDidChangeObserver: NSObjectProtocol? private var currentRequestedAspect: CGFloat? + private var outgoingVideoPreviewWheelSelectedTabIndex: Int = 1 private var hasVideoNodes: Bool { return self.expandedVideoNode != nil || self.minimizedVideoNode != nil @@ -358,7 +365,7 @@ final class CallControllerView: ViewControllerTracingNodeView { strongSelf.contentContainerView.addSubview(outgoingVideoPreviewContainer) strongSelf.candidateOutgoingVideoPreviewView = nil - strongSelf.animateOutgoingVideoPreviewViewOnce = true + strongSelf.animateOutgoingVideoPreviewContainerOnce = true strongSelf.outgoingVideoPreviewView = outgoingVideoPreviewViewActual outgoingVideoPreviewContainer.addSubview(outgoingVideoPreviewViewActual) @@ -386,15 +393,15 @@ final class CallControllerView: ViewControllerTracingNodeView { let theme = SolidRoundedButtonTheme(backgroundColor: UIColor(rgb: 0xffffff), foregroundColor: UIColor(rgb: 0x4f5352)) - let doneOutgoingVideoPreviewButton = SolidRoundedButtonNode(theme: theme, + let outgoingVideoPreviewDoneButton = SolidRoundedButtonNode(theme: theme, font: .bold, - height: 48.0, - cornerRadius: 24.0, + height: 50.0, + cornerRadius: 10.0, gloss: false) - strongSelf.doneOutgoingVideoPreviewButton = doneOutgoingVideoPreviewButton - doneOutgoingVideoPreviewButton.title = presentationData.strings.VoiceChat_VideoPreviewContinue - outgoingVideoPreviewContainer.addSubnode(doneOutgoingVideoPreviewButton) - doneOutgoingVideoPreviewButton.pressed = { [weak self] in + strongSelf.outgoingVideoPreviewDoneButton = outgoingVideoPreviewDoneButton + outgoingVideoPreviewDoneButton.title = presentationData.strings.VoiceChat_VideoPreviewContinue + outgoingVideoPreviewContainer.addSubnode(outgoingVideoPreviewDoneButton) + outgoingVideoPreviewDoneButton.pressed = { [weak self] in guard let strongSelf = self, let outgoingVideoPreviewView = strongSelf.outgoingVideoPreviewView else { return } @@ -428,6 +435,67 @@ final class CallControllerView: ViewControllerTracingNodeView { strongSelf.call.requestVideo() } + strongSelf.outgoingVideoPreviewWheelSelectedTabIndex = 1 + let wheelNode = WheelControlNodeNew(items: [WheelControlNodeNew.Item(title: UIDevice.current.model == "iPad" ? strongSelf.presentationData.strings.VoiceChat_VideoPreviewTabletScreen : strongSelf.presentationData.strings.VoiceChat_VideoPreviewPhoneScreen), WheelControlNodeNew.Item(title: strongSelf.presentationData.strings.VoiceChat_VideoPreviewFrontCamera), WheelControlNodeNew.Item(title: strongSelf.presentationData.strings.VoiceChat_VideoPreviewBackCamera)], selectedIndex: strongSelf.outgoingVideoPreviewWheelSelectedTabIndex) + strongSelf.outgoingVideoPreviewWheelNode = wheelNode + wheelNode.selectedIndexChanged = { [weak self] index in + if let strongSelf = self { + if (index == 1 && strongSelf.outgoingVideoPreviewWheelSelectedTabIndex == 2) || (index == 2 && strongSelf.outgoingVideoPreviewWheelSelectedTabIndex == 1) { + Queue.mainQueue().after(0.1) { + strongSelf.call.switchVideoCamera() + } + strongSelf.outgoingVideoPreviewView?.flip(withBackground: false) + } + if index == 0 && [1, 2].contains(strongSelf.outgoingVideoPreviewWheelSelectedTabIndex) { + strongSelf.outgoingVideoPreviewBroadcastPickerView?.isHidden = false + strongSelf.outgoingVideoPreviewView?.updateIsBlurred(isBlurred: true, light: false, animated: true) + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) + if let placeholderTextNode = strongSelf.outgoingVideoPreviewPlaceholderTextNode { + transition.updateAlpha(node: placeholderTextNode, alpha: 1.0) + } + if let placeholderIconNode = strongSelf.outgoingVideoPreviewPlaceholderIconNode { + transition.updateAlpha(node: placeholderIconNode, alpha: 1.0) + } + } else if [1, 2].contains(index) && strongSelf.outgoingVideoPreviewWheelSelectedTabIndex == 0 { + strongSelf.outgoingVideoPreviewBroadcastPickerView?.isHidden = true + strongSelf.outgoingVideoPreviewView?.updateIsBlurred(isBlurred: false, light: false, animated: true) + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) + if let placeholderTextNode = strongSelf.outgoingVideoPreviewPlaceholderTextNode { + transition.updateAlpha(node: placeholderTextNode, alpha: 0.0) + } + if let placeholderIconNode = strongSelf.outgoingVideoPreviewPlaceholderIconNode { + transition.updateAlpha(node: placeholderIconNode, alpha: 0.0) + } + } + strongSelf.outgoingVideoPreviewWheelSelectedTabIndex = index + } + } + outgoingVideoPreviewContainer.addSubnode(wheelNode) + + if #available(iOS 12.0, *) { + let broadcastPickerView = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 50, height: 52.0)) + broadcastPickerView.alpha = 0.02 + broadcastPickerView.isHidden = true + broadcastPickerView.preferredExtension = "\(strongSelf.sharedContext.applicationBindings.appBundleId).BroadcastUpload" + broadcastPickerView.showsMicrophoneButton = false + strongSelf.outgoingVideoPreviewBroadcastPickerView = broadcastPickerView + outgoingVideoPreviewContainer.addSubview(broadcastPickerView) + } + + let outgoingVideoPreviewPlaceholderTextNode = ImmediateTextNode() + strongSelf.outgoingVideoPreviewPlaceholderTextNode = outgoingVideoPreviewPlaceholderTextNode + outgoingVideoPreviewPlaceholderTextNode.alpha = 0.0 + outgoingVideoPreviewPlaceholderTextNode.maximumNumberOfLines = 3 + outgoingVideoPreviewPlaceholderTextNode.textAlignment = .center + outgoingVideoPreviewContainer.addSubnode(outgoingVideoPreviewPlaceholderTextNode) + + let outgoingVideoPreviewPlaceholderIconNode = ASImageNode() + strongSelf.outgoingVideoPreviewPlaceholderIconNode = outgoingVideoPreviewPlaceholderIconNode + outgoingVideoPreviewPlaceholderIconNode.alpha = 0.0 + outgoingVideoPreviewPlaceholderIconNode.contentMode = .scaleAspectFit + outgoingVideoPreviewPlaceholderIconNode.displaysAsynchronously = false + outgoingVideoPreviewContainer.addSubnode(outgoingVideoPreviewPlaceholderIconNode) + strongSelf.updateButtonsMode(transition: .animated(duration: 0.4, curve: .spring)) strongSelf.setUIState(.videoPreview) @@ -1121,13 +1189,37 @@ final class CallControllerView: ViewControllerTracingNodeView { frame: CGRect(origin: CGPoint(x: 29.0, y: topOriginY + 25.0), size: size)) } - if let doneOutgoingVideoPreviewButtonActual = doneOutgoingVideoPreviewButton { + if let outgoingVideoPreviewDoneButtonActual = outgoingVideoPreviewDoneButton, + let wheelNode = outgoingVideoPreviewWheelNode { let transition: ContainedViewLayoutTransition = .immediate let buttonInset: CGFloat = 16.0 let buttonMaxWidth: CGFloat = 360.0 let buttonWidth = min(buttonMaxWidth, layout.size.width - buttonInset * 2.0) - let doneButtonHeight = doneOutgoingVideoPreviewButtonActual.updateLayout(width: buttonWidth, transition: transition) - transition.updateFrame(node: doneOutgoingVideoPreviewButtonActual, frame: CGRect(x: floorToScreenPixels((layout.size.width - buttonWidth) / 2.0), y: layout.size.height - doneButtonHeight - buttonInset, width: buttonWidth, height: doneButtonHeight)) + let doneButtonHeight = outgoingVideoPreviewDoneButtonActual.updateLayout(width: buttonWidth, transition: transition) + transition.updateFrame(node: outgoingVideoPreviewDoneButtonActual, frame: CGRect(x: floorToScreenPixels((layout.size.width - buttonWidth) / 2.0), y: layout.size.height - layout.intrinsicInsets.bottom - doneButtonHeight - buttonInset, width: buttonWidth, height: doneButtonHeight)) + outgoingVideoPreviewBroadcastPickerView?.frame = outgoingVideoPreviewDoneButtonActual.frame + + let wheelFrame = CGRect(origin: CGPoint(x: 16.0, y: layout.size.height - layout.intrinsicInsets.bottom - doneButtonHeight - buttonInset - 36.0 - 20.0), size: CGSize(width: layout.size.width - 32.0, height: 36.0)) + wheelNode.updateLayout(size: wheelFrame.size, transition: transition) + transition.updateFrame(node: wheelNode, frame: wheelFrame) + } + + if let placeholderTextNode = outgoingVideoPreviewPlaceholderTextNode, + let placeholderIconNode = outgoingVideoPreviewPlaceholderIconNode { + let isTablet: Bool + if case .regular = layout.metrics.widthClass { + isTablet = true + } else { + isTablet = false + } + placeholderTextNode.attributedText = NSAttributedString(string: presentationData.strings.VoiceChat_VideoPreviewShareScreenInfo, font: Font.semibold(16.0), textColor: .white) + placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: isTablet ? "Call/ScreenShareTablet" : "Call/ScreenSharePhone"), color: .white) + + let placeholderTextSize = placeholderTextNode.updateLayout(CGSize(width: layout.size.width - 80.0, height: 100.0)) + transition.updateFrame(node: placeholderTextNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - placeholderTextSize.width) / 2.0), y: floorToScreenPixels(layout.size.height / 2.0) + 10.0), size: placeholderTextSize)) + if let imageSize = placeholderIconNode.image?.size { + transition.updateFrame(node: placeholderIconNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - imageSize.width) / 2.0), y: floorToScreenPixels(layout.size.height / 2.0) - imageSize.height - 8.0), size: imageSize)) + } } if let outgoingVideoPreviewViewActual = outgoingVideoPreviewView { @@ -1144,8 +1236,8 @@ final class CallControllerView: ViewControllerTracingNodeView { isCompactLayout: isCompactLayout, transition: outgoingVideoPreviewVideoTransition) } - if animateOutgoingVideoPreviewViewOnce { - animateOutgoingVideoPreviewViewOnce = false + if animateOutgoingVideoPreviewContainerOnce { + animateOutgoingVideoPreviewContainerOnce = false let videoButtonFrame = self.buttonsView.videoButtonFrame().flatMap { frame -> CGRect in return self.buttonsView.convert(frame, to: self) } @@ -1609,7 +1701,7 @@ private extension CallControllerView { case .active: isNewStateAllowed = true case .weakSignal: isNewStateAllowed = true case .video: isNewStateAllowed = !hasVideoNodes || (state == .video && outgoingVideoView == nil) - case .videoPreview: isNewStateAllowed = outgoingVideoPreviewView == nil || state == .video + case .videoPreview: isNewStateAllowed = outgoingVideoPreviewContainer == nil || state == .video case .none: isNewStateAllowed = true } guard isNewStateAllowed else { diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift b/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift index 29e032b08cb..77eef6a8012 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift @@ -144,7 +144,7 @@ private class VoiceChatCameraPreviewViewControllerView: ViewControllerTracingNod private let placeholderTextNode: ImmediateTextNode private let placeholderIconNode: ASImageNode - private var wheelNode: WheelControlNode + private var wheelNode: WheelControlNodeNew private var selectedTabIndex: Int = 1 private var containerLayout: (ContainerViewLayout, CGFloat)? @@ -223,8 +223,8 @@ private class VoiceChatCameraPreviewViewControllerView: ViewControllerTracingNod self.placeholderIconNode.alpha = 0.0 self.placeholderIconNode.contentMode = .scaleAspectFit self.placeholderIconNode.displaysAsynchronously = false - - self.wheelNode = WheelControlNode(items: [WheelControlNode.Item(title: UIDevice.current.model == "iPad" ? self.presentationData.strings.VoiceChat_VideoPreviewTabletScreen : self.presentationData.strings.VoiceChat_VideoPreviewPhoneScreen), WheelControlNode.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewFrontCamera), WheelControlNode.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewBackCamera)], selectedIndex: self.selectedTabIndex) + + self.wheelNode = WheelControlNodeNew(items: [WheelControlNodeNew.Item(title: UIDevice.current.model == "iPad" ? self.presentationData.strings.VoiceChat_VideoPreviewTabletScreen : self.presentationData.strings.VoiceChat_VideoPreviewPhoneScreen), WheelControlNodeNew.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewFrontCamera), WheelControlNodeNew.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewBackCamera)], selectedIndex: self.selectedTabIndex) super.init(frame: CGRect.zero) @@ -526,7 +526,7 @@ private class VoiceChatCameraPreviewViewControllerView: ViewControllerTracingNod private let textFont = Font.with(size: 14.0, design: .camera, weight: .regular) private let selectedTextFont = Font.with(size: 14.0, design: .camera, weight: .semibold) -private class WheelControlNode: ASDisplayNode, UIGestureRecognizerDelegate { +final class WheelControlNodeNew: ASDisplayNode, UIGestureRecognizerDelegate { struct Item: Equatable { public let title: String diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatTileItemView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatTileItemView.swift deleted file mode 100644 index f85bc6aee90..00000000000 --- a/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatTileItemView.swift +++ /dev/null @@ -1,877 +0,0 @@ -import Foundation -import UIKit -import AsyncDisplayKit -import Display -import SwiftSignalKit -import Postbox -import TelegramCore -import AccountContext -import TelegramUIPreferences -import TelegramPresentationData -import AvatarNode - -private let backgroundCornerRadius: CGFloat = 11.0 -private let borderLineWidth: CGFloat = 2.0 - -private let destructiveColor: UIColor = UIColor(rgb: 0xff3b30) - -private let fadeColor = UIColor(rgb: 0x000000, alpha: 0.5) - -final class VoiceChatTileItemView: UIView { - private let context: AccountContext - - let contextSourceNode: ContextExtractedContentContainingView - private let containerNode: ContextControllerSourceView - let contentNode: UIView - let backgroundNode: ASDisplayNode - var videoContainerNode: ASDisplayNode - var videoNode: GroupVideoNode? - let infoNode: ASDisplayNode - let fadeNode: UIView - private var shimmerNode: VoiceChatTileShimmeringView? - private let titleNode: ImmediateTextNode - private var iconNode: ASImageNode? - private var animationNode: VoiceChatMicrophoneNode? - var highlightNode: VoiceChatTileHighlightView - private let statusNode: VoiceChatParticipantStatusNode - - let placeholderTextNode: ImmediateTextNode - let placeholderIconNode: ASImageNode - - private var profileNode: VoiceChatPeerProfileNode? - private var extractedRect: CGRect? - private var nonExtractedRect: CGRect? - - private var validLayout: (CGSize, CGFloat)? - var item: VoiceChatTileItem? - private var isExtracted = false - - private let audioLevelDisposable = MetaDisposable() - - private let hierarchyTrackingNode: HierarchyTrackingNode - private var isCurrentlyInHierarchy = false - - init(context: AccountContext) { - self.context = context - - self.contextSourceNode = ContextExtractedContentContainingView() - self.containerNode = ContextControllerSourceView() - - self.contentNode = UIView() - self.contentNode.clipsToBounds = true - self.contentNode.layer.cornerRadius = backgroundCornerRadius - - self.backgroundNode = ASDisplayNode() - self.backgroundNode.backgroundColor = panelBackgroundColor - - self.videoContainerNode = ASDisplayNode() - self.videoContainerNode.clipsToBounds = true - - self.infoNode = ASDisplayNode() - - self.fadeNode = UIView() - if let image = tileFadeImage { - self.fadeNode.backgroundColor = UIColor(patternImage: image) - } - - self.titleNode = ImmediateTextNode() - self.titleNode.displaysAsynchronously = false - - self.statusNode = VoiceChatParticipantStatusNode() - - self.highlightNode = VoiceChatTileHighlightView() - self.highlightNode.alpha = 0.0 - self.highlightNode.updateGlowAndGradientAnimations(type: .speaking) - - self.placeholderTextNode = ImmediateTextNode() - self.placeholderTextNode.alpha = 0.0 - self.placeholderTextNode.maximumNumberOfLines = 2 - self.placeholderTextNode.textAlignment = .center - - self.placeholderIconNode = ASImageNode() - self.placeholderIconNode.alpha = 0.0 - self.placeholderIconNode.contentMode = .scaleAspectFit - self.placeholderIconNode.displaysAsynchronously = false - - var updateInHierarchy: ((Bool) -> Void)? - self.hierarchyTrackingNode = HierarchyTrackingNode({ value in - updateInHierarchy?(value) - }) - - super.init(frame: CGRect.zero) - - self.addSubnode(self.hierarchyTrackingNode) - - self.containerNode.addSubview(self.contextSourceNode) - self.containerNode.targetViewForActivationProgress = self.contextSourceNode.contentView - self.addSubview(self.containerNode) - - self.contextSourceNode.contentView.addSubview(self.contentNode) - self.contentNode.addSubnode(self.backgroundNode) - self.contentNode.addSubnode(self.videoContainerNode) - self.contentNode.addSubview(self.fadeNode) - self.contentNode.addSubnode(self.infoNode) - self.infoNode.addSubnode(self.titleNode) - self.contentNode.addSubnode(self.placeholderTextNode) - self.contentNode.addSubnode(self.placeholderIconNode) - self.contentNode.addSubview(self.highlightNode) - - self.containerNode.shouldBegin = { [weak self] location in - guard let strongSelf = self, let item = strongSelf.item, item.videoReady && !item.isVideoLimit else { - return false - } - return true - } - self.containerNode.activated = { [weak self] gesture, _ in - guard let strongSelf = self, let item = strongSelf.item, let contextAction = item.contextAction, !item.isVideoLimit else { - gesture.cancel() - return - } - // TODO: implement - print(contextAction) -// contextAction(strongSelf.contextSourceNode, gesture) - } - self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in - guard let strongSelf = self, let _ = strongSelf.item else { - return - } - strongSelf.updateIsExtracted(isExtracted, transition: transition) - } - - updateInHierarchy = { [weak self] value in - if let strongSelf = self { - strongSelf.isCurrentlyInHierarchy = value - strongSelf.highlightNode.isCurrentlyInHierarchy = value - } - } - - if #available(iOS 13.0, *) { - self.contentNode.layer.cornerCurve = .continuous - } - - self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap))) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.audioLevelDisposable.dispose() - } - - @objc private func tap() { - if let item = self.item { - item.action() - } - } - - private func updateIsExtracted(_ isExtracted: Bool, transition: ContainedViewLayoutTransition) { - guard self.isExtracted != isExtracted, let extractedRect = self.extractedRect, let nonExtractedRect = self.nonExtractedRect, let item = self.item else { - return - } - self.isExtracted = isExtracted - - let springDuration: Double = 0.42 - let springDamping: CGFloat = 124.0 - if isExtracted { - let profileNode = VoiceChatPeerProfileNode(context: self.context, size: extractedRect.size, sourceSize: nonExtractedRect.size, peer: item.peer, text: item.text, customNode: self.videoContainerNode, additionalEntry: .single(nil), requestDismiss: { [weak self] in - self?.contextSourceNode.requestDismiss?() - }) - profileNode.frame = CGRect(origin: CGPoint(), size: self.bounds.size) - self.profileNode = profileNode - self.contextSourceNode.contentView.addSubnode(profileNode) - - // TODO: implement -// profileNode.animateIn(from: self, targetRect: extractedRect, transition: transition) - var appearenceTransition = transition - if transition.isAnimated { - appearenceTransition = .animated(duration: springDuration, curve: .customSpring(damping: springDamping, initialVelocity: 0.0)) - } - appearenceTransition.updateFrame(node: profileNode, frame: extractedRect) - - self.contextSourceNode.contentView.customHitTest = { [weak self] point in - if let strongSelf = self, let profileNode = strongSelf.profileNode { - if profileNode.avatarListWrapperNode.frame.contains(point) { - return profileNode.avatarListNode.view - } - } - return nil - } - - self.backgroundNode.isHidden = true - self.fadeNode.isHidden = true - self.infoNode.isHidden = true - self.highlightNode.isHidden = true - } else if let profileNode = self.profileNode { - self.profileNode = nil - - self.infoNode.isHidden = false - // TODO: implement -// profileNode.animateOut(to: self, targetRect: nonExtractedRect, transition: transition, completion: { [weak self] in -// if let strongSelf = self { -// strongSelf.backgroundNode.isHidden = false -// strongSelf.fadeNode.isHidden = false -// strongSelf.highlightNode.isHidden = false -// } -// }) - - var appearenceTransition = transition - if transition.isAnimated { - appearenceTransition = .animated(duration: 0.2, curve: .easeInOut) - } - appearenceTransition.updateFrame(node: profileNode, frame: nonExtractedRect) - - self.contextSourceNode.contentView.customHitTest = nil - } - } - - private var absoluteLocation: (CGRect, CGSize)? - func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { - self.absoluteLocation = (rect, containerSize) - if let shimmerNode = self.shimmerNode { - shimmerNode.updateAbsoluteRect(rect, within: containerSize) - } - self.updateIsEnabled() - } - - var visibility = true { - didSet { - self.updateIsEnabled() - } - } - - func updateIsEnabled() { - guard let (rect, containerSize) = self.absoluteLocation else { - return - } - let isVisibleInContainer = rect.maxY >= 0.0 && rect.minY <= containerSize.height - if let videoNode = self.videoNode, videoNode.supernode === self.videoContainerNode { - videoNode.updateIsEnabled(self.visibility && isVisibleInContainer) - } - } - - func update(size: CGSize, availableWidth: CGFloat, item: VoiceChatTileItem, transition: ContainedViewLayoutTransition) { - guard self.validLayout?.0 != size || self.validLayout?.1 != availableWidth || self.item != item else { - return - } - - self.validLayout = (size, availableWidth) - - if !item.videoReady || item.isOwnScreencast { - let shimmerNode: VoiceChatTileShimmeringView - let shimmerTransition: ContainedViewLayoutTransition - if let current = self.shimmerNode { - shimmerNode = current - shimmerTransition = transition - } else { - shimmerNode = VoiceChatTileShimmeringView(account: item.account, peer: item.peer) - self.contentNode.insertSubview(shimmerNode, aboveSubview: self.fadeNode) - self.shimmerNode = shimmerNode - - if let (rect, containerSize) = self.absoluteLocation { - shimmerNode.updateAbsoluteRect(rect, within: containerSize) - } - shimmerTransition = .immediate - } - shimmerTransition.updateFrame(view: shimmerNode, frame: CGRect(origin: CGPoint(), size: size)) - shimmerNode.update(shimmeringColor: UIColor.white, shimmering: !item.isOwnScreencast && !item.videoTimeouted && !item.isPaused, size: size, transition: shimmerTransition) - } else if let shimmerNode = self.shimmerNode { - self.shimmerNode = nil - shimmerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak shimmerNode] _ in - shimmerNode?.removeFromSuperview() - }) - } - - var nodeToAnimateIn: ASDisplayNode? - var placeholderAppeared = false - - var itemTransition = transition - if self.item != item { - let previousItem = self.item - self.item = item - - if let getAudioLevel = item.getAudioLevel { - self.audioLevelDisposable.set((getAudioLevel() - |> deliverOnMainQueue).start(next: { [weak self] value in - guard let strongSelf = self else { - return - } - strongSelf.highlightNode.updateLevel(CGFloat(value)) - })) - } - - let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) - transition.updateAlpha(view: self.highlightNode, alpha: item.speaking ? 1.0 : 0.0) - - if previousItem?.videoEndpointId != item.videoEndpointId || self.videoNode == nil { - if let current = self.videoNode { - self.videoNode = nil - current.removeFromSupernode() - } - - if let videoNode = item.getVideo(item.secondary ? .list : .tile) { - itemTransition = .immediate - self.videoNode = videoNode - self.videoContainerNode.addSubnode(videoNode) - self.updateIsEnabled() - } - } - - self.videoNode?.updateIsBlurred(isBlurred: item.isPaused, light: true) - - var showPlaceholder = false - if item.isVideoLimit { - self.placeholderTextNode.attributedText = NSAttributedString(string: item.strings.VoiceChat_VideoParticipantsLimitExceeded(String(item.videoLimit)).string, font: Font.semibold(13.0), textColor: .white) - self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/VideoUnavailable"), color: .white) - showPlaceholder = true - } else if item.isOwnScreencast { - self.placeholderTextNode.attributedText = NSAttributedString(string: item.strings.VoiceChat_YouAreSharingScreen, font: Font.semibold(13.0), textColor: .white) - self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: item.isTablet ? "Call/ScreenShareTablet" : "Call/ScreenSharePhone"), color: .white) - showPlaceholder = true - } else if item.isPaused { - self.placeholderTextNode.attributedText = NSAttributedString(string: item.strings.VoiceChat_VideoPaused, font: Font.semibold(13.0), textColor: .white) - self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/Pause"), color: .white) - showPlaceholder = true - } - - placeholderAppeared = self.placeholderTextNode.alpha.isZero && showPlaceholder - transition.updateAlpha(node: self.placeholderTextNode, alpha: showPlaceholder ? 1.0 : 0.0) - transition.updateAlpha(node: self.placeholderIconNode, alpha: showPlaceholder ? 1.0 : 0.0) - - let titleFont = Font.semibold(13.0) - let titleColor = UIColor.white - var titleAttributedString: NSAttributedString? - if item.isVideoLimit { - titleAttributedString = nil - } else if let user = item.peer as? TelegramUser { - if let firstName = user.firstName, let lastName = user.lastName, !firstName.isEmpty, !lastName.isEmpty { - let string = NSMutableAttributedString() - switch item.nameDisplayOrder { - case .firstLast: - string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor)) - string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor)) - string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor)) - case .lastFirst: - string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor)) - string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor)) - string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor)) - } - titleAttributedString = string - } else if let firstName = user.firstName, !firstName.isEmpty { - titleAttributedString = NSAttributedString(string: firstName, font: titleFont, textColor: titleColor) - } else if let lastName = user.lastName, !lastName.isEmpty { - titleAttributedString = NSAttributedString(string: lastName, font: titleFont, textColor: titleColor) - } else { - titleAttributedString = NSAttributedString(string: item.strings.User_DeletedAccount, font: titleFont, textColor: titleColor) - } - } else if let group = item.peer as? TelegramGroup { - titleAttributedString = NSAttributedString(string: group.title, font: titleFont, textColor: titleColor) - } else if let channel = item.peer as? TelegramChannel { - titleAttributedString = NSAttributedString(string: channel.title, font: titleFont, textColor: titleColor) - } - - var microphoneColor = UIColor.white - if let additionalText = item.additionalText, case let .text(_, _, color) = additionalText { - if case .destructive = color { - microphoneColor = destructiveColor - } - } - self.titleNode.attributedText = titleAttributedString - - var hadMicrophoneNode = false - var hadIconNode = false - - if case let .microphone(muted) = item.icon { - let animationNode: VoiceChatMicrophoneNode - if let current = self.animationNode { - animationNode = current - } else { - animationNode = VoiceChatMicrophoneNode() - self.animationNode = animationNode - self.infoNode.addSubnode(animationNode) - - nodeToAnimateIn = animationNode - } - animationNode.alpha = 1.0 - animationNode.update(state: VoiceChatMicrophoneNode.State(muted: muted, filled: true, color: microphoneColor), animated: true) - } else if let animationNode = self.animationNode { - hadMicrophoneNode = true - self.animationNode = nil - animationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) - animationNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false, completion: { [weak animationNode] _ in - animationNode?.removeFromSupernode() - }) - } - - if case .presentation = item.icon { - let iconNode: ASImageNode - if let current = self.iconNode { - iconNode = current - } else { - iconNode = ASImageNode() - iconNode.displaysAsynchronously = false - iconNode.contentMode = .center - self.iconNode = iconNode - self.infoNode.addSubnode(iconNode) - - nodeToAnimateIn = iconNode - } - - iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/StatusScreen"), color: .white) - } else if let iconNode = self.iconNode { - hadIconNode = true - self.iconNode = nil - iconNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) - iconNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false, completion: { [weak iconNode] _ in - iconNode?.removeFromSupernode() - }) - } - - if let node = nodeToAnimateIn, hadMicrophoneNode || hadIconNode { - node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - node.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2) - } - } - - let bounds = CGRect(origin: CGPoint(), size: size) - self.containerNode.frame = bounds - self.contextSourceNode.frame = bounds - self.contextSourceNode.contentView.frame = bounds - - transition.updateFrame(view: self.contentNode, frame: bounds) - - let extractedWidth = availableWidth - let makeStatusLayout = self.statusNode.asyncLayout() - let (statusLayout, _) = makeStatusLayout(CGSize(width: availableWidth - 30.0, height: CGFloat.greatestFiniteMagnitude), item.text, true) - - let extractedRect = CGRect(x: 0.0, y: 0.0, width: extractedWidth, height: extractedWidth + statusLayout.height + 39.0) - let nonExtractedRect = bounds - self.extractedRect = extractedRect - self.nonExtractedRect = nonExtractedRect - - self.contextSourceNode.contentRect = extractedRect - - if self.videoContainerNode.supernode === self.contentNode { - if let videoNode = self.videoNode { - itemTransition.updateFrame(node: videoNode, frame: bounds) - if videoNode.supernode === self.videoContainerNode { - videoNode.updateLayout(size: size, layoutMode: .fillOrFitToSquare, transition: itemTransition) - } - } - transition.updateFrame(node: self.videoContainerNode, frame: bounds) - } - - transition.updateFrame(node: self.backgroundNode, frame: bounds) - transition.updateFrame(view: self.highlightNode, frame: bounds) - self.highlightNode.updateLayout(size: bounds.size, transition: transition) - transition.updateFrame(node: self.infoNode, frame: bounds) - transition.updateFrame(view: self.fadeNode, frame: CGRect(x: 0.0, y: size.height - fadeHeight, width: size.width, height: fadeHeight)) - - let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - 50.0, height: size.height)) - self.titleNode.frame = CGRect(origin: CGPoint(x: 30.0, y: size.height - titleSize.height - 8.0), size: titleSize) - - var transition = transition - if nodeToAnimateIn != nil || placeholderAppeared { - transition = .immediate - } - - if let iconNode = self.iconNode, let image = iconNode.image { - transition.updateFrame(node: iconNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels(16.0 - image.size.width / 2.0), y: floorToScreenPixels(size.height - 15.0 - image.size.height / 2.0)), size: image.size)) - } - - if let animationNode = self.animationNode { - let animationSize = CGSize(width: 36.0, height: 36.0) - animationNode.bounds = CGRect(origin: CGPoint(), size: animationSize) - animationNode.transform = CATransform3DMakeScale(0.66667, 0.66667, 1.0) - transition.updatePosition(node: animationNode, position: CGPoint(x: 16.0, y: size.height - 15.0)) - } - - let placeholderTextSize = self.placeholderTextNode.updateLayout(CGSize(width: size.width - 30.0, height: 100.0)) - transition.updateFrame(node: self.placeholderTextNode, frame: CGRect(origin: CGPoint(x: floor((size.width - placeholderTextSize.width) / 2.0), y: floorToScreenPixels(size.height / 2.0) + 10.0), size: placeholderTextSize)) - if let image = self.placeholderIconNode.image { - let imageScale: CGFloat = item.isVideoLimit ? 1.0 : 0.5 - let imageSize = CGSize(width: image.size.width * imageScale, height: image.size.height * imageScale) - transition.updateFrame(node: self.placeholderIconNode, frame: CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: floorToScreenPixels(size.height / 2.0) - imageSize.height - 4.0), size: imageSize)) - } - } - - func transitionIn(from sourceNode: ASDisplayNode?) { - guard let item = self.item else { - return - } - var videoNode: GroupVideoNode? - if let sourceNode = sourceNode as? VoiceChatFullscreenParticipantItemNode, let _ = sourceNode.item { - if let sourceVideoNode = sourceNode.videoNode { - sourceNode.videoNode = nil - videoNode = sourceVideoNode - } - } - - if videoNode == nil { - videoNode = item.getVideo(item.secondary ? .list : .tile) - } - - if let videoNode = videoNode { - videoNode.alpha = 1.0 - self.videoNode = videoNode - self.videoContainerNode.addSubnode(videoNode) - - videoNode.updateLayout(size: self.bounds.size, layoutMode: .fillOrFitToSquare, transition: .immediate) - videoNode.frame = self.bounds - - self.updateIsEnabled() - } - } -} - -private let blue = UIColor(rgb: 0x007fff) -private let lightBlue = UIColor(rgb: 0x00affe) -private let green = UIColor(rgb: 0x33c659) -private let activeBlue = UIColor(rgb: 0x00a0b9) -private let purple = UIColor(rgb: 0x3252ef) -private let pink = UIColor(rgb: 0xef436c) - -class VoiceChatTileHighlightView: UIView { - enum Gradient { - case speaking - case active - case mutedForYou - case muted - } - - private var customMaskView: UIView? - private let maskLayer = CALayer() - - private let foregroundGradientLayer = CAGradientLayer() - - var isCurrentlyInHierarchy = false { - didSet { - if self.isCurrentlyInHierarchy != oldValue && self.isCurrentlyInHierarchy { - self.updateAnimations() - } - } - } - - private var audioLevel: CGFloat = 0.0 - private var presentationAudioLevel: CGFloat = 0.0 - - private var displayLinkAnimator: ConstantDisplayLinkAnimator? - - init() { - self.foregroundGradientLayer.type = .radial - self.foregroundGradientLayer.colors = [lightBlue.cgColor, blue.cgColor, blue.cgColor] - self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] - self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) - self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) - - super.init(frame: CGRect.zero) - - self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in - guard let strongSelf = self else { return } - - strongSelf.presentationAudioLevel = strongSelf.presentationAudioLevel * 0.9 + strongSelf.audioLevel * 0.1 - } - - self.layer.addSublayer(self.foregroundGradientLayer) - - let customMaskView = UIView() - customMaskView.layer.addSublayer(self.maskLayer) - self.customMaskView = customMaskView - - self.maskLayer.masksToBounds = true - self.maskLayer.cornerRadius = backgroundCornerRadius - UIScreenPixel - self.maskLayer.borderColor = UIColor.white.cgColor - self.maskLayer.borderWidth = borderLineWidth - - self.mask = self.customMaskView - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func updateAnimations() { - if !self.isCurrentlyInHierarchy { - self.foregroundGradientLayer.removeAllAnimations() - return - } - self.setupGradientAnimations() - } - - func updateLevel(_ level: CGFloat) { - self.audioLevel = level - } - - func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { - let bounds = CGRect(origin: CGPoint(), size: size) - if let maskView = self.customMaskView { - transition.updateFrame(view: maskView, frame: bounds) - } - transition.updateFrame(layer: self.maskLayer, frame: bounds) - transition.updateFrame(layer: self.foregroundGradientLayer, frame: bounds) - } - - private func setupGradientAnimations() { - if let _ = self.foregroundGradientLayer.animation(forKey: "movement") { - } else { - let previousValue = self.foregroundGradientLayer.startPoint - let newValue: CGPoint - if self.presentationAudioLevel > 0.22 { - newValue = CGPoint(x: CGFloat.random(in: 0.9 ..< 1.0), y: CGFloat.random(in: 0.15 ..< 0.35)) - } else if self.presentationAudioLevel > 0.01 { - newValue = CGPoint(x: CGFloat.random(in: 0.57 ..< 0.85), y: CGFloat.random(in: 0.15 ..< 0.45)) - } else { - newValue = CGPoint(x: CGFloat.random(in: 0.6 ..< 0.75), y: CGFloat.random(in: 0.25 ..< 0.45)) - } - self.foregroundGradientLayer.startPoint = newValue - - CATransaction.begin() - - let animation = CABasicAnimation(keyPath: "startPoint") - animation.duration = Double.random(in: 0.8 ..< 1.4) - animation.fromValue = previousValue - animation.toValue = newValue - - CATransaction.setCompletionBlock { [weak self] in - guard let strongSelf = self else { - return - } - if strongSelf.isCurrentlyInHierarchy { - strongSelf.setupGradientAnimations() - } - } - - self.foregroundGradientLayer.add(animation, forKey: "movement") - CATransaction.commit() - } - } - - private var gradient: Gradient? - func updateGlowAndGradientAnimations(type: Gradient, animated: Bool = true) { - guard self.gradient != type else { - return - } - self.gradient = type - let initialColors = self.foregroundGradientLayer.colors - let targetColors: [CGColor] - switch type { - case .speaking: - targetColors = [activeBlue.cgColor, green.cgColor, green.cgColor] - case .active: - targetColors = [lightBlue.cgColor, blue.cgColor, blue.cgColor] - case .mutedForYou: - targetColors = [pink.cgColor, destructiveColor.cgColor, destructiveColor.cgColor] - case .muted: - targetColors = [pink.cgColor, purple.cgColor, purple.cgColor] - } - self.foregroundGradientLayer.colors = targetColors - if animated { - self.foregroundGradientLayer.animate(from: initialColors as AnyObject, to: targetColors as AnyObject, keyPath: "colors", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3) - } - self.updateAnimations() - } -} - -final class ShimmerEffectForegroundView: UIView { - private var currentForegroundColor: UIColor? - private let imageNodeContainer: ASDisplayNode - private let imageNode: ASDisplayNode - - private var absoluteLocation: (CGRect, CGSize)? - private var isCurrentlyInHierarchy = false - private var shouldBeAnimating = false - - private let size: CGFloat - - init(size: CGFloat) { - self.size = size - - self.imageNodeContainer = ASDisplayNode() - self.imageNodeContainer.isLayerBacked = true - - self.imageNode = ASDisplayNode() - self.imageNode.isLayerBacked = true - self.imageNode.displaysAsynchronously = false - - super.init(frame: CGRect.zero) - - self.clipsToBounds = true - - self.imageNodeContainer.addSubnode(self.imageNode) - self.addSubnode(self.imageNodeContainer) - - self.isCurrentlyInHierarchy = true - self.updateAnimation() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(foregroundColor: UIColor) { - if let currentForegroundColor = self.currentForegroundColor, currentForegroundColor.isEqual(foregroundColor) { - return - } - self.currentForegroundColor = foregroundColor - - let image = generateImage(CGSize(width: self.size, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - - context.clip(to: CGRect(origin: CGPoint(), size: size)) - - let transparentColor = foregroundColor.withAlphaComponent(0.0).cgColor - let peakColor = foregroundColor.cgColor - - var locations: [CGFloat] = [0.0, 0.5, 1.0] - let colors: [CGColor] = [transparentColor, peakColor, transparentColor] - - let colorSpace = CGColorSpaceCreateDeviceRGB() - let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! - - context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions()) - }) - if let image = image { - self.imageNode.backgroundColor = UIColor(patternImage: image) - } - } - - func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { - if let absoluteLocation = self.absoluteLocation, absoluteLocation.0 == rect && absoluteLocation.1 == containerSize { - return - } - let sizeUpdated = self.absoluteLocation?.1 != containerSize - let frameUpdated = self.absoluteLocation?.0 != rect - self.absoluteLocation = (rect, containerSize) - - if sizeUpdated { - if self.shouldBeAnimating { - self.imageNode.layer.removeAnimation(forKey: "shimmer") - self.addImageAnimation() - } else { - self.updateAnimation() - } - } - - if frameUpdated { - self.imageNodeContainer.frame = CGRect(origin: CGPoint(x: -rect.minX, y: -rect.minY), size: containerSize) - } - } - - private func updateAnimation() { - let shouldBeAnimating = self.isCurrentlyInHierarchy && self.absoluteLocation != nil - if shouldBeAnimating != self.shouldBeAnimating { - self.shouldBeAnimating = shouldBeAnimating - if shouldBeAnimating { - self.addImageAnimation() - } else { - self.imageNode.layer.removeAnimation(forKey: "shimmer") - } - } - } - - private func addImageAnimation() { - guard let containerSize = self.absoluteLocation?.1 else { - return - } - let gradientHeight: CGFloat = self.size - self.imageNode.frame = CGRect(origin: CGPoint(x: -gradientHeight, y: 0.0), size: CGSize(width: gradientHeight, height: containerSize.height)) - let animation = self.imageNode.layer.makeAnimation(from: 0.0 as NSNumber, to: (containerSize.width + gradientHeight) as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 1.3 * 1.0, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true) - animation.repeatCount = Float.infinity - animation.beginTime = 1.0 - self.imageNode.layer.add(animation, forKey: "shimmer") - } -} - -private class VoiceChatTileShimmeringView: UIView { - private let backgroundNode: ImageNode - private let effectNode: ShimmerEffectForegroundView - - private let borderNode: ASDisplayNode - private var borderMaskView: UIView? - private let borderEffectNode: ShimmerEffectForegroundNode - - private var currentShimmeringColor: UIColor? - private var currentShimmering: Bool? - private var currentSize: CGSize? - - public init(account: Account, peer: Peer) { - self.backgroundNode = ImageNode(enableHasImage: false, enableEmpty: false, enableAnimatedTransition: true) - self.backgroundNode.displaysAsynchronously = false - self.backgroundNode.contentMode = .scaleAspectFill - - self.effectNode = ShimmerEffectForegroundView(size: 240.0) - - self.borderNode = ASDisplayNode() - self.borderEffectNode = ShimmerEffectForegroundNode(size: 320.0) - - super.init() - - self.clipsToBounds = true - self.layer.cornerRadius = backgroundCornerRadius - - self.addSubnode(self.backgroundNode) - self.addSubview(self.effectNode) - self.addSubnode(self.borderNode) - self.borderNode.addSubnode(self.borderEffectNode) - - self.backgroundNode.setSignal(peerAvatarCompleteImage(account: account, peer: EnginePeer(peer), size: CGSize(width: 250.0, height: 250.0), round: false, font: Font.regular(16.0), drawLetters: false, fullSize: false, blurred: true)) - - self.effectNode.layer.compositingFilter = "screenBlendMode" - self.borderEffectNode.layer.compositingFilter = "screenBlendMode" - - let borderMaskView = UIView() - borderMaskView.layer.borderWidth = 1.0 - borderMaskView.layer.borderColor = UIColor.white.cgColor - borderMaskView.layer.cornerRadius = backgroundCornerRadius - self.borderMaskView = borderMaskView - - if let size = self.currentSize { - borderMaskView.frame = CGRect(origin: CGPoint(), size: size) - } - self.borderNode.view.mask = borderMaskView - - if #available(iOS 13.0, *) { - borderMaskView.layer.cornerCurve = .continuous - self.layer.cornerCurve = .continuous - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { - self.effectNode.updateAbsoluteRect(rect, within: containerSize) - self.borderEffectNode.updateAbsoluteRect(rect, within: containerSize) - } - - public func update(shimmeringColor: UIColor, shimmering: Bool, size: CGSize, transition: ContainedViewLayoutTransition) { - if let currentShimmeringColor = self.currentShimmeringColor, currentShimmeringColor.isEqual(shimmeringColor) && self.currentSize == size && self.currentShimmering == shimmering { - return - } - - let firstTime = self.currentShimmering == nil - self.currentShimmeringColor = shimmeringColor - self.currentShimmering = shimmering - self.currentSize = size - - let transition: ContainedViewLayoutTransition = firstTime ? .immediate : (transition.isAnimated ? transition : .animated(duration: 0.45, curve: .easeInOut)) - transition.updateAlpha(view: self.effectNode, alpha: shimmering ? 1.0 : 0.0) - transition.updateAlpha(node: self.borderNode, alpha: shimmering ? 1.0 : 0.0) - - let bounds = CGRect(origin: CGPoint(), size: size) - - self.effectNode.update(foregroundColor: shimmeringColor.withAlphaComponent(0.3)) - transition.updateFrame(view: self.effectNode, frame: bounds) - - self.borderEffectNode.update(foregroundColor: shimmeringColor.withAlphaComponent(0.45)) - transition.updateFrame(node: self.borderEffectNode, frame: bounds) - - transition.updateFrame(node: self.backgroundNode, frame: bounds) - transition.updateFrame(node: self.borderNode, frame: bounds) - if let borderMaskView = self.borderMaskView { - transition.updateFrame(view: borderMaskView, frame: bounds) - } - } -} From 3e7e422f40d76b075b8a68d892842113a4192e61 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Mar 2023 21:58:55 +0300 Subject: [PATCH 10/11] TELEGRAM-[removed unused code] --- .../CallControllerView.swift | 2 +- ...VoiceChatCameraPreviewViewController.swift | 701 ------------------ .../WheelControlNodeNew.swift | 189 +++++ 3 files changed, 190 insertions(+), 702 deletions(-) delete mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift create mode 100644 submodules/TelegramCallsUI/Sources/CallViewController/WheelControlNodeNew.swift diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift index 71f027ba9ec..f75189806b9 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -2163,7 +2163,7 @@ private extension CallControllerView { // MARK: - CallVideoView -private final class CallVideoView: UIView, PreviewVideoView { +private final class CallVideoView: UIView { private let videoTransformContainer: UIView private let videoView: PresentationCallVideoView diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift b/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift deleted file mode 100644 index 77eef6a8012..00000000000 --- a/submodules/TelegramCallsUI/Sources/CallViewController/VoiceChatCameraPreviewViewController.swift +++ /dev/null @@ -1,701 +0,0 @@ -import Foundation -import UIKit -import Display -import Postbox -import TelegramCore -import SwiftSignalKit -import AccountContext -import TelegramPresentationData -import SolidRoundedButtonNode -import PresentationDataUtils -import UIKitRuntimeUtils -import ReplayKit - -private let accentColor: UIColor = UIColor(rgb: 0x007aff) - -protocol PreviewVideoView: UIView { - var ready: Signal { get } - - func flip(withBackground: Bool) - func updateIsBlurred(isBlurred: Bool, light: Bool, animated: Bool) - - func updateLayout(size: CGSize, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition) -} - -final class VoiceChatCameraPreviewViewController: ViewController { - - private let sharedContext: SharedAccountContext - - private var animatedIn = false - - private var controllerNode: VoiceChatCameraPreviewViewControllerView! - private let cameraNode: PreviewVideoView - private let shareCamera: (UIView, Bool) -> Void - private let switchCamera: () -> Void - - private var presentationDataDisposable: Disposable? - - init(sharedContext: SharedAccountContext, cameraNode: PreviewVideoView, shareCamera: @escaping (UIView, Bool) -> Void, switchCamera: @escaping () -> Void) { - self.sharedContext = sharedContext - self.cameraNode = cameraNode - self.shareCamera = shareCamera - self.switchCamera = switchCamera - - super.init(navigationBarPresentationData: nil) - - self.statusBar.statusBarStyle = .Ignore - - self.blocksBackgroundWhenInOverlay = true - - self.presentationDataDisposable = (sharedContext.presentationData - |> deliverOnMainQueue).start(next: { [weak self] presentationData in - if let strongSelf = self { - if !strongSelf.isNodeLoaded { - strongSelf.loadDisplayNode() - } - strongSelf.controllerNode.updatePresentationData(presentationData) - } - }) - - self.statusBar.statusBarStyle = .Ignore - } - - required init(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.presentationDataDisposable?.dispose() - } - - override public func loadDisplayNode() { - displayNode = ASDisplayNode() - displayNode.displaysAsynchronously = false - let controllerNodeLocal = VoiceChatCameraPreviewViewControllerView(controller: self, - sharedContext: self.sharedContext, - cameraNode: self.cameraNode) - controllerNode = controllerNodeLocal - displayNode.view.addSubview(controllerNodeLocal) - self.controllerNode.shareCamera = { [weak self] unmuted in - if let strongSelf = self { - strongSelf.shareCamera(strongSelf.cameraNode, unmuted) - strongSelf.dismiss() - } - } - self.controllerNode.switchCamera = { [weak self] in - self?.switchCamera() - self?.cameraNode.flip(withBackground: false) - } - self.controllerNode.dismiss = { [weak self] in - self?.presentingViewController?.dismiss(animated: false, completion: nil) - } - self.controllerNode.cancel = { [weak self] in - self?.dismiss() - } - } - - override public func loadView() { - super.loadView() - } - - override public func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - if !self.animatedIn { - self.animatedIn = true - self.controllerNode.animateIn() - } - } - - public override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - controllerNode.frame = displayNode.view.bounds - } - - override public func dismiss(completion: (() -> Void)? = nil) { - self.controllerNode.animateOut(completion: completion) - } - - override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { - super.containerLayoutUpdated(layout, transition: transition) - - self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) - } -} - -private class VoiceChatCameraPreviewViewControllerView: ViewControllerTracingNodeView, UIScrollViewDelegate { - private weak var controller: VoiceChatCameraPreviewViewController? - private let sharedContext: SharedAccountContext - private var presentationData: PresentationData - - private let cameraNode: PreviewVideoView - private let dimNode: ASDisplayNode - private let wrappingScrollNode: UIScrollView - private let contentContainerNode: UIView - private let backgroundNode: UIView - private let contentBackgroundNode: UIView - private let titleNode: ASTextNode - private let previewContainerNode: UIView - private let shimmerNode: ShimmerEffectForegroundView - private let doneButton: SolidRoundedButtonNode - private var broadcastPickerView: UIView? - private let cancelButton: HighlightableButtonNode - - private let placeholderTextNode: ImmediateTextNode - private let placeholderIconNode: ASImageNode - - private var wheelNode: WheelControlNodeNew - private var selectedTabIndex: Int = 1 - private var containerLayout: (ContainerViewLayout, CGFloat)? - - private var applicationStateDisposable: Disposable? - - private let hapticFeedback = HapticFeedback() - - private let readyDisposable = MetaDisposable() - - var shareCamera: ((Bool) -> Void)? - var switchCamera: (() -> Void)? - var dismiss: (() -> Void)? - var cancel: (() -> Void)? - - init(controller: VoiceChatCameraPreviewViewController, sharedContext: SharedAccountContext, cameraNode: PreviewVideoView) { - self.controller = controller - self.sharedContext = sharedContext - self.presentationData = sharedContext.currentPresentationData.with { $0 } - - self.cameraNode = cameraNode - - self.wrappingScrollNode = UIScrollView() - self.wrappingScrollNode.alwaysBounceVertical = true - self.wrappingScrollNode.delaysContentTouches = false - self.wrappingScrollNode.canCancelContentTouches = true - - self.dimNode = ASDisplayNode() - self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) - - self.contentContainerNode = UIView() - self.contentContainerNode.isOpaque = false - - self.backgroundNode = UIView() - self.backgroundNode.clipsToBounds = true - self.backgroundNode.layer.cornerRadius = 16.0 - - let backgroundColor = UIColor(rgb: 0x000000) - - self.contentBackgroundNode = UIView() - self.contentBackgroundNode.backgroundColor = backgroundColor - - let title = self.presentationData.strings.VoiceChat_VideoPreviewTitle - - self.titleNode = ASTextNode() - self.titleNode.attributedText = NSAttributedString(string: title, font: Font.bold(17.0), textColor: UIColor(rgb: 0xffffff)) - - self.doneButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: UIColor(rgb: 0xffffff), foregroundColor: UIColor(rgb: 0x4f5352)), font: .bold, height: 48.0, cornerRadius: 24.0, gloss: false) - self.doneButton.title = self.presentationData.strings.VoiceChat_VideoPreviewContinue - - if #available(iOS 12.0, *) { - let broadcastPickerView = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 50, height: 52.0)) - broadcastPickerView.alpha = 0.02 - broadcastPickerView.isHidden = true - broadcastPickerView.preferredExtension = "\(self.sharedContext.applicationBindings.appBundleId).BroadcastUpload" - broadcastPickerView.showsMicrophoneButton = false - self.broadcastPickerView = broadcastPickerView - } - - self.cancelButton = HighlightableButtonNode() - self.cancelButton.setAttributedTitle(NSAttributedString(string: self.presentationData.strings.Common_Cancel, font: Font.regular(17.0), textColor: UIColor(rgb: 0xffffff)), for: []) - - self.previewContainerNode = UIView() - self.previewContainerNode.clipsToBounds = true - self.previewContainerNode.layer.cornerRadius = 11.0 - self.previewContainerNode.backgroundColor = UIColor(rgb: 0x2b2b2f) - - self.shimmerNode = ShimmerEffectForegroundView(size: 200.0) - self.previewContainerNode.addSubview(self.shimmerNode) - - self.placeholderTextNode = ImmediateTextNode() - self.placeholderTextNode.alpha = 0.0 - self.placeholderTextNode.maximumNumberOfLines = 3 - self.placeholderTextNode.textAlignment = .center - - self.placeholderIconNode = ASImageNode() - self.placeholderIconNode.alpha = 0.0 - self.placeholderIconNode.contentMode = .scaleAspectFit - self.placeholderIconNode.displaysAsynchronously = false - - self.wheelNode = WheelControlNodeNew(items: [WheelControlNodeNew.Item(title: UIDevice.current.model == "iPad" ? self.presentationData.strings.VoiceChat_VideoPreviewTabletScreen : self.presentationData.strings.VoiceChat_VideoPreviewPhoneScreen), WheelControlNodeNew.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewFrontCamera), WheelControlNodeNew.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewBackCamera)], selectedIndex: self.selectedTabIndex) - - super.init(frame: CGRect.zero) - - self.backgroundColor = nil - self.isOpaque = false - - self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) - self.addSubnode(self.dimNode) - - self.wrappingScrollNode.delegate = self - self.addSubview(self.wrappingScrollNode) - - self.wrappingScrollNode.addSubview(self.backgroundNode) - self.wrappingScrollNode.addSubview(self.contentContainerNode) - - self.backgroundNode.addSubview(self.contentBackgroundNode) - self.contentContainerNode.addSubview(self.previewContainerNode) - self.contentContainerNode.addSubnode(self.titleNode) - self.contentContainerNode.addSubnode(self.doneButton) - if let broadcastPickerView = self.broadcastPickerView { - self.contentContainerNode.addSubview(broadcastPickerView) - } - self.contentContainerNode.addSubnode(self.cancelButton) - - self.previewContainerNode.addSubview(self.cameraNode) - - self.previewContainerNode.addSubnode(self.placeholderIconNode) - self.previewContainerNode.addSubnode(self.placeholderTextNode) - - self.previewContainerNode.addSubnode(self.wheelNode) - - self.wheelNode.selectedIndexChanged = { [weak self] index in - if let strongSelf = self { - if (index == 1 && strongSelf.selectedTabIndex == 2) || (index == 2 && strongSelf.selectedTabIndex == 1) { - strongSelf.switchCamera?() - } - if index == 0 && [1, 2].contains(strongSelf.selectedTabIndex) { - strongSelf.broadcastPickerView?.isHidden = false - strongSelf.cameraNode.updateIsBlurred(isBlurred: true, light: false, animated: true) - let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) - transition.updateAlpha(node: strongSelf.placeholderTextNode, alpha: 1.0) - transition.updateAlpha(node: strongSelf.placeholderIconNode, alpha: 1.0) - } else if [1, 2].contains(index) && strongSelf.selectedTabIndex == 0 { - strongSelf.broadcastPickerView?.isHidden = true - strongSelf.cameraNode.updateIsBlurred(isBlurred: false, light: false, animated: true) - let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) - transition.updateAlpha(node: strongSelf.placeholderTextNode, alpha: 0.0) - transition.updateAlpha(node: strongSelf.placeholderIconNode, alpha: 0.0) - } - strongSelf.selectedTabIndex = index - } - } - - self.doneButton.pressed = { [weak self] in - if let strongSelf = self { - strongSelf.shareCamera?(true) - } - } - self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside) - - self.readyDisposable.set(self.cameraNode.ready.start(next: { [weak self] ready in - if let strongSelf = self, ready { - Queue.mainQueue().after(0.07) { - strongSelf.shimmerNode.alpha = 0.0 - strongSelf.shimmerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) - } - } - })) - - let leftSwipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.leftSwipeGesture)) - leftSwipeGestureRecognizer.direction = .left - let rightSwipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.rightSwipeGesture)) - rightSwipeGestureRecognizer.direction = .right - - self.addGestureRecognizer(leftSwipeGestureRecognizer) - self.addGestureRecognizer(rightSwipeGestureRecognizer) - - if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { - self.wrappingScrollNode.contentInsetAdjustmentBehavior = .never - } - - hitTestImpl = { [weak self] point, event in - return self?.hitTest(point, with: event) - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.readyDisposable.dispose() - self.applicationStateDisposable?.dispose() - } - - func updatePresentationData(_ presentationData: PresentationData) { - self.presentationData = presentationData - } - - @objc func leftSwipeGesture() { - if self.selectedTabIndex < 2 { - self.wheelNode.setSelectedIndex(self.selectedTabIndex + 1, animated: true) - self.wheelNode.selectedIndexChanged(self.wheelNode.selectedIndex) - } - } - - @objc func rightSwipeGesture() { - if self.selectedTabIndex > 0 { - self.wheelNode.setSelectedIndex(self.selectedTabIndex - 1, animated: true) - self.wheelNode.selectedIndexChanged(self.wheelNode.selectedIndex) - } - } - - @objc func cancelPressed() { - self.cancel?() - } - - @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - self.cancel?() - } - } - - func animateIn() { - self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) - - let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY - let dimPosition = self.dimNode.layer.position - - let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) - let targetBounds = self.bounds - self.bounds = self.bounds.offsetBy(dx: 0.0, dy: -offset) - self.dimNode.position = CGPoint(x: dimPosition.x, y: dimPosition.y - offset) - transition.animateView({ - self.bounds = targetBounds - self.dimNode.position = dimPosition - }) - - self.applicationStateDisposable = (self.sharedContext.applicationBindings.applicationIsActive - |> filter { !$0 } - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self] _ in - guard let strongSelf = self else { - return - } - strongSelf.controller?.dismiss() - }) - } - - func animateOut(completion: (() -> Void)? = nil) { - var dimCompleted = false - var offsetCompleted = false - - let internalCompletion: () -> Void = { [weak self] in - if let strongSelf = self, dimCompleted && offsetCompleted { - strongSelf.dismiss?() - } - completion?() - } - - self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in - dimCompleted = true - internalCompletion() - }) - - let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY - let dimPosition = self.dimNode.layer.position - self.dimNode.layer.animatePosition(from: dimPosition, to: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) - self.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in - offsetCompleted = true - internalCompletion() - }) - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if self.bounds.contains(point) { - if !self.contentBackgroundNode.bounds.contains(self.convert(point, to: self.contentBackgroundNode)) { - return self.dimNode.view - } - } - return super.hitTest(point, with: event) - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - let contentOffset = scrollView.contentOffset - let additionalTopHeight = max(0.0, -contentOffset.y) - - if additionalTopHeight >= 30.0 { - self.cancel?() - } - } - - func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { - self.containerLayout = (layout, navigationBarHeight) - - let isLandscape: Bool - if layout.size.width > layout.size.height { - isLandscape = true - } else { - isLandscape = false - } - let isTablet: Bool - if case .regular = layout.metrics.widthClass { - isTablet = true - } else { - isTablet = false - } - - var insets = layout.insets(options: [.statusBar]) - insets.top = max(10.0, insets.top) - - let contentSize: CGSize - if isLandscape { - if isTablet { - contentSize = CGSize(width: 870.0, height: 690.0) - } else { - contentSize = CGSize(width: layout.size.width, height: layout.size.height) - } - } else { - if isTablet { - contentSize = CGSize(width: 600.0, height: 960.0) - } else { - contentSize = CGSize(width: layout.size.width, height: layout.size.height - insets.top - 8.0) - } - } - - let sideInset = floor((layout.size.width - contentSize.width) / 2.0) - let contentFrame: CGRect - if isTablet { - contentFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((layout.size.height - contentSize.height) / 2.0)), size: contentSize) - } else { - contentFrame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - contentSize.height), size: contentSize) - } - var backgroundFrame = contentFrame - if !isTablet { - backgroundFrame.size.height += 2000.0 - } - if backgroundFrame.minY < contentFrame.minY { - backgroundFrame.origin.y = contentFrame.minY - } - transition.updateFrame(view: self.backgroundNode, frame: backgroundFrame) - transition.updateFrame(view: self.contentBackgroundNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) - transition.updateFrame(view: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - self.wrappingScrollNode.contentSize = backgroundFrame.size - transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - - let titleSize = self.titleNode.measure(CGSize(width: contentFrame.width, height: .greatestFiniteMagnitude)) - let titleFrame = CGRect(origin: CGPoint(x: floor((contentFrame.width - titleSize.width) / 2.0), y: 20.0), size: titleSize) - transition.updateFrame(node: self.titleNode, frame: titleFrame) - - var previewSize: CGSize - var previewFrame: CGRect - let previewAspectRatio: CGFloat = 1.85 - if isLandscape { - let previewHeight = contentFrame.height - previewSize = CGSize(width: min(contentFrame.width - layout.safeInsets.left - layout.safeInsets.right, ceil(previewHeight * previewAspectRatio)), height: previewHeight) - previewFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentFrame.width - previewSize.width) / 2.0), y: 0.0), size: previewSize) - } else { - previewSize = CGSize(width: contentFrame.width, height: min(contentFrame.height, ceil(contentFrame.width * previewAspectRatio))) - previewFrame = CGRect(origin: CGPoint(), size: previewSize) - } - transition.updateFrame(view: self.previewContainerNode, frame: previewFrame) - transition.updateFrame(view: self.shimmerNode, frame: CGRect(origin: CGPoint(), size: previewFrame.size)) - self.shimmerNode.update(foregroundColor: UIColor(rgb: 0xffffff, alpha: 0.07)) - self.shimmerNode.updateAbsoluteRect(previewFrame, within: layout.size) - - let cancelButtonSize = self.cancelButton.measure(CGSize(width: (previewFrame.width - titleSize.width) / 2.0, height: .greatestFiniteMagnitude)) - let cancelButtonFrame = CGRect(origin: CGPoint(x: previewFrame.minX + 17.0, y: 20.0), size: cancelButtonSize) - transition.updateFrame(node: self.cancelButton, frame: cancelButtonFrame) - - self.cameraNode.frame = CGRect(origin: CGPoint(), size: previewSize) - self.cameraNode.updateLayout(size: previewSize, layoutMode: isLandscape ? .fillHorizontal : .fillVertical, transition: .immediate) - - self.placeholderTextNode.attributedText = NSAttributedString(string: presentationData.strings.VoiceChat_VideoPreviewShareScreenInfo, font: Font.semibold(16.0), textColor: .white) - self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: isTablet ? "Call/ScreenShareTablet" : "Call/ScreenSharePhone"), color: .white) - - let placeholderTextSize = self.placeholderTextNode.updateLayout(CGSize(width: previewSize.width - 80.0, height: 100.0)) - transition.updateFrame(node: self.placeholderTextNode, frame: CGRect(origin: CGPoint(x: floor((previewSize.width - placeholderTextSize.width) / 2.0), y: floorToScreenPixels(previewSize.height / 2.0) + 10.0), size: placeholderTextSize)) - if let imageSize = self.placeholderIconNode.image?.size { - transition.updateFrame(node: self.placeholderIconNode, frame: CGRect(origin: CGPoint(x: floor((previewSize.width - imageSize.width) / 2.0), y: floorToScreenPixels(previewSize.height / 2.0) - imageSize.height - 8.0), size: imageSize)) - } - - let buttonInset: CGFloat = 16.0 - let buttonMaxWidth: CGFloat = 360.0 - - let buttonWidth = min(buttonMaxWidth, contentFrame.width - buttonInset * 2.0) - let doneButtonHeight = self.doneButton.updateLayout(width: buttonWidth, transition: transition) - transition.updateFrame(node: self.doneButton, frame: CGRect(x: floorToScreenPixels((contentFrame.width - buttonWidth) / 2.0), y: previewFrame.maxY - doneButtonHeight - buttonInset, width: buttonWidth, height: doneButtonHeight)) - self.broadcastPickerView?.frame = self.doneButton.frame - - let wheelFrame = CGRect(origin: CGPoint(x: 16.0 + previewFrame.minX, y: previewFrame.maxY - doneButtonHeight - buttonInset - 36.0 - 20.0), size: CGSize(width: previewFrame.width - 32.0, height: 36.0)) - self.wheelNode.updateLayout(size: wheelFrame.size, transition: transition) - transition.updateFrame(node: self.wheelNode, frame: wheelFrame) - - transition.updateFrame(view: self.contentContainerNode, frame: contentFrame) - } -} - -private let textFont = Font.with(size: 14.0, design: .camera, weight: .regular) -private let selectedTextFont = Font.with(size: 14.0, design: .camera, weight: .semibold) - -final class WheelControlNodeNew: ASDisplayNode, UIGestureRecognizerDelegate { - struct Item: Equatable { - public let title: String - - public init(title: String) { - self.title = title - } - } - - private let maskNode: ASDisplayNode - private let containerNode: ASDisplayNode - private var itemNodes: [HighlightTrackingButtonNode] - - private var validLayout: CGSize? - - private var _items: [Item] - private var _selectedIndex: Int = 0 - - public var selectedIndex: Int { - get { - return self._selectedIndex - } - set { - guard newValue != self._selectedIndex else { - return - } - self._selectedIndex = newValue - if let size = self.validLayout { - self.updateLayout(size: size, transition: .immediate) - } - } - } - - public func setSelectedIndex(_ index: Int, animated: Bool) { - guard index != self._selectedIndex else { - return - } - self._selectedIndex = index - if let size = self.validLayout { - self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .easeInOut)) - } - } - - public var selectedIndexChanged: (Int) -> Void = { _ in } - - public init(items: [Item], selectedIndex: Int) { - self._items = items - self._selectedIndex = selectedIndex - - self.maskNode = ASDisplayNode() - self.maskNode.setLayerBlock({ - let maskLayer = CAGradientLayer() - maskLayer.colors = [UIColor.clear.cgColor, UIColor.white.cgColor, UIColor.white.cgColor, UIColor.clear.cgColor] - maskLayer.locations = [0.0, 0.15, 0.85, 1.0] - maskLayer.startPoint = CGPoint(x: 0.0, y: 0.0) - maskLayer.endPoint = CGPoint(x: 1.0, y: 0.0) - return maskLayer - }) - self.containerNode = ASDisplayNode() - - self.itemNodes = items.map { item in - let itemNode = HighlightTrackingButtonNode() - itemNode.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0) - itemNode.titleNode.maximumNumberOfLines = 1 - itemNode.titleNode.truncationMode = .byTruncatingTail - itemNode.accessibilityLabel = item.title - itemNode.accessibilityTraits = [.button] - itemNode.hitTestSlop = UIEdgeInsets(top: -10.0, left: -5.0, bottom: -10.0, right: -5.0) - itemNode.setTitle(item.title.uppercased(), with: textFont, with: .white, for: .normal) - itemNode.titleNode.shadowColor = UIColor.black.cgColor - itemNode.titleNode.shadowOffset = CGSize() - itemNode.titleNode.layer.shadowRadius = 2.0 - itemNode.titleNode.layer.shadowOpacity = 0.3 - itemNode.titleNode.layer.masksToBounds = false - itemNode.titleNode.layer.shouldRasterize = true - itemNode.titleNode.layer.rasterizationScale = UIScreen.main.scale - return itemNode - } - - super.init() - - self.clipsToBounds = true - - self.addSubnode(self.containerNode) - - self.itemNodes.forEach(self.containerNode.addSubnode(_:)) - self.setupButtons() - } - - override func didLoad() { - super.didLoad() - - self.view.layer.mask = self.maskNode.layer - - self.view.disablesInteractiveTransitionGestureRecognizer = true - } - - func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { - self.validLayout = size - - let bounds = CGRect(origin: CGPoint(), size: size) - - transition.updateFrame(node: self.maskNode, frame: bounds) - - let spacing: CGFloat = 15.0 - if !self.itemNodes.isEmpty { - var leftOffset: CGFloat = 0.0 - var selectedItemNode: ASDisplayNode? - for i in 0 ..< self.itemNodes.count { - let itemNode = self.itemNodes[i] - let itemSize = itemNode.measure(size) - transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: leftOffset, y: (size.height - itemSize.height) / 2.0), size: itemSize)) - - leftOffset += itemSize.width + spacing - - let isSelected = self.selectedIndex == i - if isSelected { - selectedItemNode = itemNode - } - if itemNode.isSelected != isSelected { - itemNode.isSelected = isSelected - let title = itemNode.attributedTitle(for: .normal)?.string ?? "" - itemNode.setTitle(title, with: isSelected ? selectedTextFont : textFont, with: isSelected ? UIColor(rgb: 0xffd60a) : .white, for: .normal) - if isSelected { - itemNode.accessibilityTraits.insert(.selected) - } else { - itemNode.accessibilityTraits.remove(.selected) - } - } - } - - let totalWidth = leftOffset - spacing - if let selectedItemNode = selectedItemNode { - let itemCenter = selectedItemNode.frame.center - transition.updateFrame(node: self.containerNode, frame: CGRect(x: bounds.width / 2.0 - itemCenter.x, y: 0.0, width: totalWidth, height: bounds.height)) - - for i in 0 ..< self.itemNodes.count { - let itemNode = self.itemNodes[i] - let convertedBounds = itemNode.view.convert(itemNode.bounds, to: self.view) - let position = convertedBounds.center - let offset = position.x - bounds.width / 2.0 - let angle = abs(offset / bounds.width * 0.99) - let sign: CGFloat = offset > 0 ? 1.0 : -1.0 - - var transform = CATransform3DMakeTranslation(-22.0 * angle * angle * sign, 0.0, 0.0) - transform = CATransform3DRotate(transform, angle, 0.0, sign, 0.0) - transition.animateView { - itemNode.transform = transform - } - } - } - } - } - - private func setupButtons() { - for i in 0 ..< self.itemNodes.count { - let itemNode = self.itemNodes[i] - itemNode.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside) - } - } - - @objc private func buttonPressed(_ button: HighlightTrackingButtonNode) { - guard let index = self.itemNodes.firstIndex(of: button) else { - return - } - - self._selectedIndex = index - self.selectedIndexChanged(index) - if let size = self.validLayout { - self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .slide)) - } - } -} diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/WheelControlNodeNew.swift b/submodules/TelegramCallsUI/Sources/CallViewController/WheelControlNodeNew.swift new file mode 100644 index 00000000000..9165f839b8c --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/CallViewController/WheelControlNodeNew.swift @@ -0,0 +1,189 @@ +import Foundation +import UIKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit +import AccountContext +import TelegramPresentationData +import SolidRoundedButtonNode +import PresentationDataUtils +import UIKitRuntimeUtils +import ReplayKit + +private let textFont = Font.with(size: 14.0, design: .camera, weight: .regular) +private let selectedTextFont = Font.with(size: 14.0, design: .camera, weight: .semibold) + +final class WheelControlNodeNew: ASDisplayNode, UIGestureRecognizerDelegate { + struct Item: Equatable { + public let title: String + + public init(title: String) { + self.title = title + } + } + + private let maskNode: ASDisplayNode + private let containerNode: ASDisplayNode + private var itemNodes: [HighlightTrackingButtonNode] + + private var validLayout: CGSize? + + private var _items: [Item] + private var _selectedIndex: Int = 0 + + public var selectedIndex: Int { + get { + return self._selectedIndex + } + set { + guard newValue != self._selectedIndex else { + return + } + self._selectedIndex = newValue + if let size = self.validLayout { + self.updateLayout(size: size, transition: .immediate) + } + } + } + + public func setSelectedIndex(_ index: Int, animated: Bool) { + guard index != self._selectedIndex else { + return + } + self._selectedIndex = index + if let size = self.validLayout { + self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .easeInOut)) + } + } + + public var selectedIndexChanged: (Int) -> Void = { _ in } + + public init(items: [Item], selectedIndex: Int) { + self._items = items + self._selectedIndex = selectedIndex + + self.maskNode = ASDisplayNode() + self.maskNode.setLayerBlock({ + let maskLayer = CAGradientLayer() + maskLayer.colors = [UIColor.clear.cgColor, UIColor.white.cgColor, UIColor.white.cgColor, UIColor.clear.cgColor] + maskLayer.locations = [0.0, 0.15, 0.85, 1.0] + maskLayer.startPoint = CGPoint(x: 0.0, y: 0.0) + maskLayer.endPoint = CGPoint(x: 1.0, y: 0.0) + return maskLayer + }) + self.containerNode = ASDisplayNode() + + self.itemNodes = items.map { item in + let itemNode = HighlightTrackingButtonNode() + itemNode.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0) + itemNode.titleNode.maximumNumberOfLines = 1 + itemNode.titleNode.truncationMode = .byTruncatingTail + itemNode.accessibilityLabel = item.title + itemNode.accessibilityTraits = [.button] + itemNode.hitTestSlop = UIEdgeInsets(top: -10.0, left: -5.0, bottom: -10.0, right: -5.0) + itemNode.setTitle(item.title.uppercased(), with: textFont, with: .white, for: .normal) + itemNode.titleNode.shadowColor = UIColor.black.cgColor + itemNode.titleNode.shadowOffset = CGSize() + itemNode.titleNode.layer.shadowRadius = 2.0 + itemNode.titleNode.layer.shadowOpacity = 0.3 + itemNode.titleNode.layer.masksToBounds = false + itemNode.titleNode.layer.shouldRasterize = true + itemNode.titleNode.layer.rasterizationScale = UIScreen.main.scale + return itemNode + } + + super.init() + + self.clipsToBounds = true + + self.addSubnode(self.containerNode) + + self.itemNodes.forEach(self.containerNode.addSubnode(_:)) + self.setupButtons() + } + + override func didLoad() { + super.didLoad() + + self.view.layer.mask = self.maskNode.layer + + self.view.disablesInteractiveTransitionGestureRecognizer = true + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + self.validLayout = size + + let bounds = CGRect(origin: CGPoint(), size: size) + + transition.updateFrame(node: self.maskNode, frame: bounds) + + let spacing: CGFloat = 15.0 + if !self.itemNodes.isEmpty { + var leftOffset: CGFloat = 0.0 + var selectedItemNode: ASDisplayNode? + for i in 0 ..< self.itemNodes.count { + let itemNode = self.itemNodes[i] + let itemSize = itemNode.measure(size) + transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: leftOffset, y: (size.height - itemSize.height) / 2.0), size: itemSize)) + + leftOffset += itemSize.width + spacing + + let isSelected = self.selectedIndex == i + if isSelected { + selectedItemNode = itemNode + } + if itemNode.isSelected != isSelected { + itemNode.isSelected = isSelected + let title = itemNode.attributedTitle(for: .normal)?.string ?? "" + itemNode.setTitle(title, with: isSelected ? selectedTextFont : textFont, with: isSelected ? UIColor(rgb: 0xffd60a) : .white, for: .normal) + if isSelected { + itemNode.accessibilityTraits.insert(.selected) + } else { + itemNode.accessibilityTraits.remove(.selected) + } + } + } + + let totalWidth = leftOffset - spacing + if let selectedItemNode = selectedItemNode { + let itemCenter = selectedItemNode.frame.center + transition.updateFrame(node: self.containerNode, frame: CGRect(x: bounds.width / 2.0 - itemCenter.x, y: 0.0, width: totalWidth, height: bounds.height)) + + for i in 0 ..< self.itemNodes.count { + let itemNode = self.itemNodes[i] + let convertedBounds = itemNode.view.convert(itemNode.bounds, to: self.view) + let position = convertedBounds.center + let offset = position.x - bounds.width / 2.0 + let angle = abs(offset / bounds.width * 0.99) + let sign: CGFloat = offset > 0 ? 1.0 : -1.0 + + var transform = CATransform3DMakeTranslation(-22.0 * angle * angle * sign, 0.0, 0.0) + transform = CATransform3DRotate(transform, angle, 0.0, sign, 0.0) + transition.animateView { + itemNode.transform = transform + } + } + } + } + } + + private func setupButtons() { + for i in 0 ..< self.itemNodes.count { + let itemNode = self.itemNodes[i] + itemNode.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside) + } + } + + @objc private func buttonPressed(_ button: HighlightTrackingButtonNode) { + guard let index = self.itemNodes.firstIndex(of: button) else { + return + } + + self._selectedIndex = index + self.selectedIndexChanged(index) + if let size = self.validLayout { + self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .slide)) + } + } +} From a9d1d482abf057931bc1172456c8403d3ba4da94 Mon Sep 17 00:00:00 2001 From: Sergey Popov Date: Sat, 4 Mar 2023 22:29:46 +0300 Subject: [PATCH 11/11] TELEGRAM-[added incoming video animation] --- .../CallControllerView.swift | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift index f75189806b9..0b73204259c 100644 --- a/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift +++ b/submodules/TelegramCallsUI/Sources/CallViewController/CallControllerView.swift @@ -109,7 +109,7 @@ final class CallControllerView: ViewControllerTracingNodeView { private var removedExpandedVideoNodeValue: CallVideoView? private var isRequestingVideo: Bool = false - private var animateRequestedVideoOnce: Bool = false + private var animateIncomingVideoPreviewContainerOnce: Bool = false private var animateOutgoingVideoPreviewContainerOnce: Bool = false private var hiddenUIForActiveVideoCallOnce: Bool = false @@ -736,7 +736,7 @@ final class CallControllerView: ViewControllerTracingNodeView { return } strongSelf.candidateIncomingVideoNodeValue = nil - + strongSelf.animateIncomingVideoPreviewContainerOnce = true strongSelf.incomingVideoNodeValue = incomingVideoNode if let expandedVideoNode = strongSelf.expandedVideoNode { strongSelf.minimizedVideoNode = expandedVideoNode @@ -1136,6 +1136,7 @@ final class CallControllerView: ViewControllerTracingNodeView { var topOffset: CGFloat = layout.safeInsets.top + 174 + let previousAvatarFrame = avatarNode.view.convert(avatarNode.view.bounds, to: self) let avatarFrame = CGRect(origin: CGPoint(x: (layout.size.width - avatarNode.bounds.width) / 2.0, y: topOffset), size: self.avatarNode.bounds.size) transition.updateFrame(node: self.avatarNode, frame: avatarFrame) @@ -1278,16 +1279,11 @@ final class CallControllerView: ViewControllerTracingNodeView { expandedVideoNode.updateLayout(size: expandedVideoNode.frame.size, cornerRadius: 0.0, isOutgoing: expandedVideoNode === self.outgoingVideoView, deviceOrientation: mappedDeviceOrientation, isCompactLayout: isCompactLayout, transition: expandedVideoTransition) - if self.animateRequestedVideoOnce { - self.animateRequestedVideoOnce = false - if expandedVideoNode === self.outgoingVideoView { - let videoButtonFrame = self.buttonsView.videoButtonFrame().flatMap { frame -> CGRect in - return self.buttonsView.convert(frame, to: self) - } - - if let previousVideoButtonFrame = previousVideoButtonFrame, let videoButtonFrame = videoButtonFrame { - expandedVideoNode.animateRadialMask(from: previousVideoButtonFrame, to: videoButtonFrame) - } + if self.animateIncomingVideoPreviewContainerOnce { + self.animateIncomingVideoPreviewContainerOnce = false + if expandedVideoNode === self.incomingVideoNodeValue { + let avatarFrame = avatarNode.view.convert(avatarNode.view.bounds, to: self) + expandedVideoNode.animateRadialMask(from: previousAvatarFrame, to: avatarFrame) } } } else {